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
+58 -46
View File
@@ -1,40 +1,54 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import { slotKey } from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel } from '@/lib/time';
import { isActive, statusLabel } from '@/lib/planning';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
const LEAD_SLOT = slotKey('BAR', 'ven', 'S');
type Presence = Record<string, 'present' | 'absent'>;
export function MobileLeadPage() {
const { state, dispatch } = usePlanning();
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
const positions = usePositions();
const shifts = useShifts();
const volunteers = useVolunteers();
const [presence, setPresence] = useState<Presence>({});
// Poste de démonstration : le premier ; créneau le plus fourni.
const position = (positions.data ?? [])[0];
const positionShifts = useMemo(
() => (shifts.data ?? []).filter((s) => s.position_id === position?.id),
[shifts.data, position?.id],
);
const shift = positionShifts[Math.floor(positionShifts.length / 2)] ?? positionShifts[0];
const shiftAssignments = useAssignments(shift ? { shiftId: shift.id } : undefined);
const volunteersById = useMemo(
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
[volunteers.data],
);
const position = state.positions.find((p) => p.id === 'BAR')!;
const leadDemo = state.volunteers[1];
if (positions.isLoading || shifts.isLoading) return <Loading />;
const error = positions.error ?? shifts.error ?? volunteers.error;
if (error) return <ErrorNote error={error} />;
if (!position || !shift) return null;
const team = (index.bySlot.get(LEAD_SLOT) ?? []).map((assignment) => {
const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId)!;
const presence = state.presence[assignment.id];
return {
assignmentId: assignment.id,
name: volunteer.fullName,
initials: volunteer.initials,
phone: volunteer.phone,
status:
assignment.status === 'CONFIRMED'
? t('mobileLead.statusConfirmed')
: t('mobileLead.statusProposed'),
present: presence === 'present',
absent: presence === 'absent',
};
});
const team = (shiftAssignments.data ?? [])
.filter(isActive)
.map((a) => {
const volunteer = volunteersById.get(a.volunteer_id);
return {
id: a.id,
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
phone: volunteer?.phone ?? '',
status: statusLabel(a.status),
present: presence[a.id] === 'present',
absent: presence[a.id] === 'absent',
};
});
const presentCount = team.filter((m) => m.present).length;
const absentCount = team.filter((m) => m.absent).length;
@@ -43,18 +57,18 @@ export function MobileLeadPage() {
<div className="flex flex-wrap items-start gap-[26px] p-6">
<PhoneFrame time={t('mobileLead.statusTime')}>
<div className="bg-ink px-4 pb-[15px] pt-3 text-cream">
<div className="font-mono text-[10px] text-yellow">
{t('mobileLead.kicker', { name: leadDemo.fullName })}
</div>
<div className="mt-[5px] font-display text-[27px] font-black leading-tight tracking-tight">
{`${position.name} — ${position.zone}`.toUpperCase()}
</div>
<div className="font-display text-[13px] font-semibold opacity-80">
{position.zone} · {position.location}
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div>
<div className="mt-[5px] font-display text-[25px] font-black leading-tight tracking-tight">
{position.name.toUpperCase()}
</div>
{position.location ? (
<div className="font-display text-[13px] font-semibold opacity-80">{position.location}</div>
) : null}
<div className="mt-3 flex items-stretch gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
{t('mobileLead.inProgress', { hours: t('mobileLead.shiftHours') })}
{t('mobileLead.inProgress', {
hours: hourRangeLabel(shift.starts_at, shift.ends_at),
})}
</div>
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
{t('mobileLead.present', { present: presentCount, total: team.length })}
@@ -81,7 +95,7 @@ export function MobileLeadPage() {
{t('mobileLead.teamTitle')}
</div>
{team.map((member) => (
<div key={member.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-md">
<div key={member.id} className="border-[3px] border-ink bg-paper shadow-brutal-md">
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold">
{member.initials}
@@ -108,9 +122,7 @@ export function MobileLeadPage() {
<div className="grid grid-cols-2 border-t-[3px] border-ink">
<button
type="button"
onClick={() =>
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'present' })
}
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'present' }))}
className={cx(
'min-h-[56px] cursor-pointer border-r-[3px] border-ink bg-cream font-display text-[15px] font-black hover:bg-green hover:text-white',
)}
@@ -119,9 +131,7 @@ export function MobileLeadPage() {
</button>
<button
type="button"
onClick={() =>
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'absent' })
}
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'absent' }))}
className="min-h-[56px] cursor-pointer bg-cream font-display text-[15px] font-black hover:bg-orange"
>
{t('mobileLead.markAbsent')}
@@ -129,9 +139,11 @@ export function MobileLeadPage() {
</div>
</div>
))}
<div className="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.nextSlot')}
</div>
{team.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.emptyTeam')}
</div>
) : null}
</div>
</PhoneFrame>