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:
co-authored by
Claude Sonnet 5
parent
ac469f0cd1
commit
c9dda65361
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user