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
+84 -86
View File
@@ -1,45 +1,54 @@
import { useMemo } from 'react';
import { usePlanning } from '@/app/planning-store';
import { absoluteWindow, dayPart, festivalDay } from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import { useAssignments, usePositions, useShifts, useUpdateAssignment, useVolunteers } from '@/api/hooks';
import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
import { isActive } from '@/lib/planning';
import { ENV } from '@/lib/env';
import { t } from '@/i18n';
const DEMO_VOLUNTEER_ID = ENV.demoVolunteerId;
export function MobileVolunteerPage() {
const { state, dispatch } = usePlanning();
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
const volunteers = useVolunteers();
const shifts = useShifts();
const positions = usePositions();
const me = useMemo(() => {
const list = volunteers.data ?? [];
return list.find((v) => v.id === DEMO_VOLUNTEER_ID) ?? list[0];
}, [volunteers.data]);
const assignments = useAssignments(me ? { volunteerId: me.id } : undefined);
const updateAssignment = useUpdateAssignment();
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data],
);
const positionsById = useMemo(
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data],
);
const me = state.volunteers[0];
const slots = (index.byVolunteer.get(me.id) ?? [])
.slice()
.sort(
(a, b) =>
absoluteWindow(a.dayId, a.dayPartCode).start - absoluteWindow(b.dayId, b.dayPartCode).start,
)
.map((assignment) => {
const position = state.positions.find((p) => p.id === assignment.positionId)!;
const positionIndex = state.positions.findIndex((p) => p.id === assignment.positionId);
const lead = state.volunteers[(positionIndex * 5 + 3) % state.volunteers.length];
return {
assignmentId: assignment.id,
day: festivalDay(assignment.dayId).longLabel,
hours: dayPart(assignment.dayPartCode).label,
position: position.name.toUpperCase(),
zone: position.zone,
location: position.location,
leadName: lead.fullName,
leadPhone: lead.phone,
leadPhoneRaw: lead.phone.replace(/\s/g, ''),
confirmed: assignment.status === 'CONFIRMED',
};
});
if (volunteers.isLoading || shifts.isLoading) return <Loading />;
const error = volunteers.error ?? shifts.error ?? assignments.error;
if (error) return <ErrorNote error={error} />;
if (!me) return null;
const toConfirm = slots.filter((s) => !s.confirmed).length;
const totalHours = index.totalHours.get(me.id) ?? 0;
const slots = (assignments.data ?? [])
.filter(isActive)
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) }))
.filter((entry): entry is { assignment: typeof entry.assignment; shift: NonNullable<typeof entry.shift> } =>
Boolean(entry.shift),
)
.sort((a, b) => naiveMinutes(a.shift.starts_at) - naiveMinutes(b.shift.starts_at));
const totalHours = Math.round(
slots.reduce((sum, s) => sum + hoursBetween(s.shift.starts_at, s.shift.ends_at), 0) * 10,
) / 10;
const toConfirm = slots.filter((s) => s.assignment.status !== 'CONFIRMED').length;
return (
<div className="flex flex-wrap items-start gap-[26px] p-6">
@@ -47,7 +56,7 @@ export function MobileVolunteerPage() {
<div className="bg-ink px-4 pb-4 pt-3 text-cream">
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
{t('mobileVol.greeting', { name: me.firstName.toUpperCase() })}
{t('mobileVol.greeting', { name: me.first_name.toUpperCase() })}
</div>
<div className="mt-3 flex gap-2">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
@@ -66,63 +75,52 @@ export function MobileVolunteerPage() {
) : null}
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
{slots.map((slot) => (
<div key={slot.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black">{slot.day}</span>
<span className="ml-auto font-mono text-[17px] font-bold text-yellow">
{slot.hours}
</span>
</div>
<div className="p-[11px]">
<div className="font-display text-[25px] font-black leading-tight tracking-tight">
{slot.position}
{slots.map(({ assignment, shift }) => {
const position = positionsById.get(shift.position_id);
return (
<div key={assignment.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
<span className="font-display text-[15px] font-black">
{longDayLabel(shift.starts_at)}
</span>
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</span>
</div>
<div className="font-display text-[14px] font-semibold opacity-75">{slot.zone}</div>
<div className="mt-[9px] flex flex-col gap-[5px] border-t-2 border-ink pt-[9px]">
<div className="font-mono text-[12px]">
{t('mobileVol.location', { location: slot.location })}
<div className="p-[11px]">
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
{(position?.name ?? '').toUpperCase()}
</div>
<div className="font-mono text-[12px]">
{t('mobileVol.lead', { name: slot.leadName })}
</div>
</div>
<div className="mt-[11px] flex gap-[9px]">
<a
href={`tel:${slot.leadPhoneRaw}`}
className="flex min-h-[52px] flex-none items-center justify-center border-[3px] border-ink bg-cream px-[14px] font-display text-[13px] font-extrabold no-underline"
>
{t('mobileVol.call', { phone: slot.leadPhone })}
</a>
{position?.location ? (
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
{t('mobileVol.location', { location: position.location })}
</div>
) : null}
{assignment.status === 'CONFIRMED' ? (
<div className="mt-[11px] flex min-h-[62px] items-center justify-center gap-[9px] border-[3px] border-ink bg-green font-display text-[17px] font-black text-white">
{t('mobileVol.confirmed')}
</div>
) : (
<button
type="button"
disabled={updateAssignment.isPending}
onClick={() =>
updateAssignment.mutate({ id: assignment.id, body: { status: 'CONFIRMED' } })
}
className="mt-[11px] min-h-[62px] w-full cursor-pointer border-[3px] border-ink bg-yellow font-display text-[19px] font-black shadow-brutal-md hover:bg-green hover:text-white disabled:opacity-60"
>
{t('mobileVol.confirm')}
</button>
)}
</div>
{slot.confirmed ? (
<div className="mt-[11px] flex min-h-[62px] items-center justify-center gap-[9px] border-[3px] border-ink bg-green font-display text-[17px] font-black text-white">
{t('mobileVol.confirmed')}
</div>
) : (
<button
type="button"
onClick={() => dispatch({ type: 'confirmSlot', assignmentId: slot.assignmentId })}
className="mt-[11px] min-h-[62px] w-full cursor-pointer border-[3px] border-ink bg-yellow font-display text-[19px] font-black shadow-brutal-md hover:bg-green hover:text-white"
>
{t('mobileVol.confirm')}
</button>
)}
</div>
);
})}
{slots.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileVol.noSlots')}
</div>
))}
<div className="border-[3px] border-ink bg-blue p-3 shadow-brutal-lg">
<div className="font-display text-[12px] font-black uppercase tracking-widest">
{t('mobileVol.orgMessageTitle')}
</div>
<p className="mt-[7px] font-display text-[14px] font-semibold leading-relaxed">
{t('mobileVol.orgMessageBody')}
</p>
<div className="mt-2 font-mono text-[10px] opacity-70">
{t('mobileVol.orgMessageMeta')}
</div>
</div>
) : null}
</div>
</PhoneFrame>