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
+154 -144
View File
@@ -1,69 +1,93 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import { DAY_PARTS, FESTIVAL_DAYS, dayPartHours } from '@/domain/festival';
import {
useCreatePosition,
useCreateShift,
useDeleteShift,
usePositions,
useShifts,
useUpdateShift,
} from '@/api/hooks';
import type { ShiftRead } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state';
import { categoryLabel } from '@/lib/planning';
import { datePart, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
export function ConfigGrid() {
const { state, dispatch } = usePlanning();
const [positionId, setPositionId] = useState(state.positions[0]?.id ?? '');
const position = state.positions.find((p) => p.id === positionId) ?? state.positions[0];
const positions = usePositions();
const allShifts = useShifts();
const createPosition = useCreatePosition();
const [selectedId, setSelectedId] = useState<string | null>(null);
const position =
(positions.data ?? []).find((p) => p.id === selectedId) ?? (positions.data ?? [])[0];
const positionShifts = useShifts(position?.id);
const updateShift = useUpdateShift();
const deleteShift = useDeleteShift();
const createShift = useCreateShift();
const totals = useMemo(() => {
let slots = 0;
let min = 0;
let ideal = 0;
state.positions.forEach((p) =>
p.needs.forEach((need) => {
slots += 1;
min += need.minStaff * dayPartHours(need.dayPartCode);
ideal += need.idealStaff * dayPartHours(need.dayPartCode);
}),
);
return { slots, min, ideal };
}, [state.positions]);
for (const s of allShifts.data ?? []) {
const h = hoursBetween(s.starts_at, s.ends_at);
min += s.min_staff * h;
ideal += s.ideal_staff * h;
}
return { min: Math.round(min), ideal: Math.round(ideal), count: (allShifts.data ?? []).length };
}, [allShifts.data]);
const positionMin = position.needs.reduce(
(sum, need) => sum + need.minStaff * dayPartHours(need.dayPartCode),
0,
);
const positionIdeal = position.needs.reduce(
(sum, need) => sum + need.idealStaff * dayPartHours(need.dayPartCode),
0,
);
if (positions.isLoading) return <Loading />;
if (positions.error) return <ErrorNote error={positions.error} />;
if (!position) {
return (
<div className="p-6">
<button
type="button"
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
className="cursor-pointer border-[3px] border-ink bg-yellow px-4 py-3 font-display text-[12px] font-extrabold uppercase shadow-brutal"
>
{t('config.grid.addPosition')}
</button>
</div>
);
}
const byDay = groupByDay(positionShifts.data ?? []);
return (
<div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] w-[250px] flex-none overflow-auto border-r-[3px] border-ink">
<div className="sticky top-0 z-[2] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('config.grid.positionsHeader', { count: totals.slots })}
{t('config.grid.positionsHeader', { count: totals.count })}
</div>
{state.positions.map((entry) => {
{(positions.data ?? []).map((entry) => {
const active = entry.id === position.id;
const n = (allShifts.data ?? []).filter((s) => s.position_id === entry.id).length;
return (
<button
key={entry.id}
type="button"
onClick={() => setPositionId(entry.id)}
onClick={() => setSelectedId(entry.id)}
className={cx(
'block w-full cursor-pointer border-b border-ink px-[9px] py-[7px] text-left',
active
? 'border-l-[6px] bg-yellow'
: 'border-l-[6px] border-l-transparent bg-paper hover:bg-sand',
active ? 'border-l-[6px] bg-yellow' : 'border-l-[6px] border-l-transparent bg-paper hover:bg-sand',
)}
>
<div className="font-display text-[12.5px] font-bold leading-tight">
{entry.name} <span className="opacity-60">{entry.zone}</span>
<div className="truncate font-display text-[12.5px] font-bold leading-tight">
{entry.name}
</div>
<div className="font-mono text-[9px] opacity-60">
{t('config.grid.slotsCount', { count: entry.needs.length })}
{categoryLabel(entry.category)} · {t('config.grid.slotsCount', { count: n })}
</div>
</button>
);
})}
<button
type="button"
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
className="w-full cursor-pointer border-t-[3px] border-ink bg-cream p-3 font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-yellow"
>
{t('config.grid.addPosition')}
@@ -73,128 +97,89 @@ export function ConfigGrid() {
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
<div>
<div className="font-display text-[26px] font-black tracking-tight">
{position.name} — {position.zone}
</div>
<div className="font-display text-[24px] font-black tracking-tight">{position.name}</div>
<div className="font-mono text-[10.5px] opacity-65">
{t(`position.category.${position.category.toLowerCase()}`)} · {position.location}
{categoryLabel(position.category)}
{position.location ? ` · ${position.location}` : ''}
</div>
</div>
<div className="ml-auto flex gap-2">
<div className="border-[3px] border-ink bg-paper px-[11px] py-2 shadow-brutal-md">
<div className="font-mono text-[20px] font-bold">
{t('unit.hours', { count: positionMin })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.grid.minTotal')}
</div>
</div>
<div className="border-[3px] border-ink bg-yellow px-[11px] py-2 shadow-brutal-md">
<div className="font-mono text-[20px] font-bold">
{t('unit.hours', { count: positionIdeal })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.grid.idealTotal')}
</div>
</div>
<MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} />
<MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent />
</div>
</div>
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(280px,1fr))]">
{FESTIVAL_DAYS.map((day) => (
<div key={day.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="flex items-baseline gap-[7px] bg-ink px-[10px] py-[7px] text-cream">
<span className="font-display text-[13px] font-black">{day.shortLabel}</span>
<span className="font-mono text-[8.5px] text-yellow">{day.tag}</span>
{positionShifts.isLoading ? <Loading /> : null}
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]">
{byDay.map(([day, shifts]) => (
<div key={day} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
{longDayLabel(`${day}T12:00`)}
</div>
{DAY_PARTS.map((part) => {
const need = position.needs.find(
(n) => n.dayId === day.id && n.dayPartCode === part.code,
);
if (!need) {
return (
<div
key={part.code}
className="flex items-center gap-2 border-b border-sand-line bg-hatch-sand px-[9px] py-[7px]"
>
<span className="w-[52px] flex-none font-mono text-[11px] opacity-50">
{part.label}
</span>
<span className="font-display text-[10px] font-semibold opacity-55">
{t('config.grid.closed')}
</span>
<button
type="button"
onClick={() =>
dispatch({
type: 'toggleSlot',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
})
}
className="ml-auto cursor-pointer border-2 border-ink bg-cream px-2 py-1 font-display text-[9.5px] font-extrabold hover:bg-yellow"
>
{t('config.grid.open')}
</button>
</div>
);
}
return (
<div
key={part.code}
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
{shifts.map((shift) => (
<div
key={shift.id}
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
>
<span className="w-[92px] flex-none font-mono text-[11px]">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</span>
<Stepper
label={t('config.grid.min')}
value={shift.min_staff}
onChange={(delta) =>
updateShift.mutate({
id: shift.id,
body: { min_staff: Math.max(0, shift.min_staff + delta) },
})
}
/>
<Stepper
label={t('config.grid.ideal')}
accent
value={shift.ideal_staff}
onChange={(delta) =>
updateShift.mutate({
id: shift.id,
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
})
}
/>
<button
type="button"
onClick={() => deleteShift.mutate(shift.id)}
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
aria-label={t('action.remove')}
>
<span className="w-[52px] flex-none font-mono text-[11px]">{part.label}</span>
<NeedStepper
label={t('config.grid.min')}
value={need.minStaff}
onChange={(delta) =>
dispatch({
type: 'setNeed',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
which: 'min',
delta,
})
}
/>
<NeedStepper
label={t('config.grid.ideal')}
value={need.idealStaff}
accent
onChange={(delta) =>
dispatch({
type: 'setNeed',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
which: 'ideal',
delta,
})
}
/>
<button
type="button"
onClick={() =>
dispatch({
type: 'toggleSlot',
positionId: position.id,
dayId: day.id,
dayPartCode: part.code,
})
}
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
aria-label={t('action.close')}
>
✕
</button>
</div>
);
})}
✕
</button>
</div>
))}
<button
type="button"
onClick={() => {
const start = `${day}T12:00:00`;
const end = `${day}T17:00:00`;
createShift.mutate({
position_id: position.id,
starts_at: start,
ends_at: end,
min_staff: 2,
ideal_staff: 3,
});
}}
className="w-full cursor-pointer bg-cream p-2 font-display text-[10px] font-extrabold uppercase tracking-wide hover:bg-yellow"
>
{t('config.grid.addShift')}
</button>
</div>
))}
{byDay.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.grid.noShifts')}
</div>
) : null}
</div>
<div className="mt-[14px] max-w-[760px] border-[3px] border-dashed border-ink p-[11px] font-mono text-[10.5px] leading-relaxed opacity-75">
@@ -205,7 +190,32 @@ export function ConfigGrid() {
);
}
function NeedStepper({
function groupByDay(shifts: ShiftRead[]): Array<[string, ShiftRead[]]> {
const map = new Map<string, ShiftRead[]>();
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {
const day = datePart(s.starts_at);
map.set(day, [...(map.get(day) ?? []), s]);
}
return [...map.entries()];
}
function MiniStat({ value, label, accent = false }: { value: string; label: string; accent?: boolean }) {
return (
<div
className={cx(
'border-[3px] border-ink px-[11px] py-2 shadow-brutal-md',
accent ? 'bg-yellow' : 'bg-paper',
)}
>
<div className="font-mono text-[20px] font-bold">{value}</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{label}
</div>
</div>
);
}
function Stepper({
label,
value,
accent = false,