Frontend : consommation de l'API CRUD (matrice, affectations, validation)

Remplace le magasin d'état fabriqué par une vraie couche API typée, alignée
sur le back-end (Étape 5) :

- src/api/ : schema.ts (miroir de backend/app/api/schemas.py), client HTTP,
  implémentation mock en mémoire (même surface + portage du moteur de
  validation), sélection mock/http via VITE_API_MODE, hooks React Query
  (lectures + mutations avec invalidation).
- Grille des besoins ← GET /matrix ; treillis de colonnes dérivé des
  créneaux réels (plus de plages figées).
- Panneau d'affectation ← GET /assignments?shift_id, POST /assignments/validate
  par candidat (alertes bloquantes / avertissements du moteur),
  POST / PATCH / DELETE /assignments.
- Vue par bénévole ← volunteers + shifts + assignments (surcharge / oubliés
  calculés côté client).
- Confirmations (ex-« Publication ») ← affectations PROPOSED ; publication
  versionnée et emails signalés hors périmètre.
- Mobiles ← assignments filtrées ; confirmation = PATCH ; pointage référent
  local (hors périmètre).
- Configuration ← positions & shifts CRUD (min/idéal, ajout/retrait),
  volunteers CRUD dont suppression RGPD ; messages = maquette locale.
- i18n mis à jour, aucun texte d'interface en dur.
- Vérifs : build tsc + vite, eslint (0 problème), npm run smoke (API mock +
  rendu headless des 6 écrans avec données préchargées).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
This commit is contained in:
AdamThomas
2026-09-09 23:16:38 +02:00
co-authored by Claude Sonnet 5
parent ac469f0cd1
commit c9dda65361
38 changed files with 2930 additions and 2414 deletions
+149
View File
@@ -0,0 +1,149 @@
import type {
AssignmentRead,
MatrixResponse,
PositionCategory,
ShiftRead,
Violation,
} from '@/api/schema';
import { ACTIVE_ASSIGNMENT_STATUSES } from '@/api/schema';
import { t } from '@/i18n';
import { datePart, hoursBetween, naiveMinutes, timePart } from './time';
export function isActive(a: AssignmentRead): boolean {
return ACTIVE_ASSIGNMENT_STATUSES.includes(a.status);
}
export function categoryLabel(category: PositionCategory): string {
return t(`position.category.${category.toLowerCase()}`);
}
export function statusLabel(status: string): string {
return t(`assignment.status.${status.toLowerCase()}`);
}
/** Colonne de la grille : une (date, heure de début) distincte. */
export interface LatticeColumn {
key: string; // `YYYY-MM-DD|HH:MM`
date: string;
time: string;
}
export interface LatticeDay {
date: string;
columns: LatticeColumn[];
}
/** Construit le treillis de colonnes à partir de créneaux (grille alignée). */
export function buildLattice(starts: Array<{ starts_at: string }>): {
days: LatticeDay[];
columns: LatticeColumn[];
} {
const seen = new Map<string, LatticeColumn>();
for (const s of starts) {
const date = datePart(s.starts_at);
const time = timePart(s.starts_at);
const key = `${date}|${time}`;
if (!seen.has(key)) seen.set(key, { key, date, time });
}
const columns = [...seen.values()].sort((a, b) => a.key.localeCompare(b.key));
const days: LatticeDay[] = [];
for (const column of columns) {
let day = days.find((d) => d.date === column.date);
if (!day) {
day = { date: column.date, columns: [] };
days.push(day);
}
day.columns.push(column);
}
return { days, columns };
}
export function latticeFromMatrix(matrix: MatrixResponse) {
return buildLattice(matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at }))));
}
/** Heures actives cumulées par bénévole. */
export function hoursByVolunteer(
assignments: AssignmentRead[],
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
): Map<string, number> {
const totals = new Map<string, number>();
for (const a of assignments) {
if (!isActive(a)) continue;
const shift = shiftsById.get(a.shift_id);
if (!shift) continue;
totals.set(
a.volunteer_id,
(totals.get(a.volunteer_id) ?? 0) + hoursBetween(shift.starts_at, shift.ends_at),
);
}
return totals;
}
/** Surcharge : > 8 h sur une journée civile ou < 8 h de repos entre créneaux. */
export function isOverloaded(
volunteerId: string,
assignments: AssignmentRead[],
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
): boolean {
const shifts = assignments
.filter((a) => a.volunteer_id === volunteerId && isActive(a))
.map((a) => shiftsById.get(a.shift_id))
.filter((s): s is ShiftRead => Boolean(s))
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
const perDay = new Map<string, number>();
for (const s of shifts) {
const day = datePart(s.starts_at);
perDay.set(day, (perDay.get(day) ?? 0) + hoursBetween(s.starts_at, s.ends_at));
}
for (const hours of perDay.values()) if (hours > 8) return true;
for (let i = 1; i < shifts.length; i += 1) {
const gap = naiveMinutes(shifts[i].starts_at) - naiveMinutes(shifts[i - 1].ends_at);
if (gap > 0 && gap < 8 * 60) return true;
}
return false;
}
/**
* Rend une violation en texte lisible. Les `params` suivent exactement le
* contrat du moteur back-end (`backend/app/validation/engine.py`).
*/
export function violationText(
violation: Violation,
resolvePosition?: (shiftId: string) => string | undefined,
): string {
const p = violation.params;
switch (violation.code) {
case 'SHIFT_OVERLAP': {
const where = resolvePosition?.(String(p.other_shift_id));
return where
? t('validation.SHIFT_OVERLAP.at', { position: where })
: t('validation.SHIFT_OVERLAP');
}
case 'OUTSIDE_AVAILABILITY':
return p.status === 'UNAVAILABLE'
? t('validation.OUTSIDE_AVAILABILITY.unavailable')
: t('validation.OUTSIDE_AVAILABILITY');
case 'DAILY_HOURS_EXCEEDED':
return t('validation.DAILY_HOURS_EXCEEDED', {
hours: formatMinutes(Number(p.total_minutes)),
});
case 'INSUFFICIENT_REST':
return t('validation.INSUFFICIENT_REST', {
rest: formatMinutes(Number(p.gap_minutes)),
});
case 'MISSING_REQUIRED_SKILL':
return t('validation.MISSING_REQUIRED_SKILL', { skill: String(p.skill_code) });
default:
return violation.code;
}
}
function formatMinutes(total: number): string {
const h = Math.floor(total / 60);
const m = total % 60;
return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`;
}