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 -128
View File
@@ -1,35 +1,50 @@
import { useMemo, useState } from 'react';
import { usePlanning } from '@/app/planning-store';
import {
useCreateVolunteer,
useDeleteVolunteer,
useUpdateVolunteer,
useVolunteers,
} from '@/api/hooks';
import type { TshirtSize } from '@/api/schema';
import { FieldLabel, TextInput } from '@/components/ui';
import { DEMO_CATEGORIES, categoryLabelKey } from '@/domain/festival';
import { indexPlanning } from '@/domain/selectors';
import { ErrorNote, Loading } from '@/components/state';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
const TABLE_COLUMNS = 'minmax(0,2fr) minmax(0,1.3fr) minmax(0,1.6fr) minmax(0,1fr)';
function demoEmail(firstName: string, lastName: string): string {
const normalize = (s: string) =>
s
.toLowerCase()
.normalize('NFD')
.replace(/[^a-z]/g, '');
return `${normalize(firstName)}.${normalize(lastName)}@mail.fr`;
}
const TSHIRT_SIZES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
const IMPORT_SAMPLE: Array<[string, string]> = [
['Océane', 'Thibault'],
['Samir', 'Amrani'],
['Lucie', 'Charpentier'],
['Noé', 'Delaunay'],
];
export function ConfigVolunteers() {
const { state, dispatch } = usePlanning();
const [selectedId, setSelectedId] = useState(state.volunteers[0]?.id ?? '');
const selected = state.volunteers.find((v) => v.id === selectedId) ?? state.volunteers[0];
const volunteers = useVolunteers();
const updateVolunteer = useUpdateVolunteer();
const deleteVolunteer = useDeleteVolunteer();
const createVolunteer = useCreateVolunteer();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [skillDraft, setSkillDraft] = useState('');
const index = useMemo(
() => indexPlanning(state.assignments, state.volunteers),
[state.assignments, state.volunteers],
);
const selected = useMemo(() => {
const list = volunteers.data ?? [];
return list.find((v) => v.id === selectedId) ?? list[0];
}, [volunteers.data, selectedId]);
const rows = state.volunteers.slice(0, 26);
const assignedHours = index.totalHours.get(selected.id) ?? 0;
if (volunteers.isLoading) return <Loading />;
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
const rows = (volunteers.data ?? []).slice(0, 40);
const patch = (body: Parameters<typeof updateVolunteer.mutate>[0]['body']) =>
selected && updateVolunteer.mutate({ id: selected.id, body });
const runImport = () => {
for (const [first_name, last_name] of IMPORT_SAMPLE) {
createVolunteer.mutate({ first_name, last_name });
}
};
return (
<div className="flex min-h-0 flex-1 items-stretch">
@@ -49,40 +64,33 @@ export function ConfigVolunteers() {
</span>
<button
type="button"
onClick={() => dispatch({ type: 'runImport' })}
className="flex-none cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white"
onClick={runImport}
disabled={createVolunteer.isPending}
className="flex-none cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white disabled:opacity-60"
>
{t('config.vol.importButton')}
</button>
</div>
<div className="flex-none border-[3px] border-ink bg-paper px-[14px] py-3 shadow-brutal-lg">
<div className="font-mono text-[24px] font-bold">{state.volunteers.length}</div>
<div className="font-mono text-[24px] font-bold">{volunteers.data?.length ?? 0}</div>
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
{t('config.vol.profiles')}
</div>
</div>
{state.importedCount > 0 ? (
<div className="flex-none border-[3px] border-ink bg-green px-[14px] py-3 text-white shadow-brutal-lg">
<div className="font-mono text-[24px] font-bold">+{state.importedCount}</div>
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
{t('config.vol.imported')}
</div>
</div>
) : null}
</div>
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
style={{ gridTemplateColumns: TABLE_COLUMNS }}
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
>
<span>{t('config.vol.colName')}</span>
<span>{t('config.vol.colPhone')}</span>
<span>{t('config.vol.colWanted')}</span>
<span>{t('config.vol.colAvailability')}</span>
<span>{t('config.vol.colEmail')}</span>
<span>{t('config.vol.colSkills')}</span>
</div>
{rows.map((volunteer) => {
const active = volunteer.id === selected.id;
const active = volunteer.id === selected?.id;
return (
<button
key={volunteer.id}
@@ -92,29 +100,23 @@ export function ConfigVolunteers() {
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
active ? 'bg-yellow' : 'hover:bg-cream',
)}
style={{ gridTemplateColumns: TABLE_COLUMNS }}
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
>
<span className="flex min-w-0 items-center gap-[7px]">
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
{volunteer.initials}
{volunteer.first_name[0]}
{volunteer.last_name[0]}
</span>
<span className="truncate font-display text-[12.5px] font-bold">
{volunteer.fullName}
{volunteer.first_name} {volunteer.last_name}
</span>
{volunteer.imported ? (
<span className="flex-none border-[1.5px] border-ink bg-green px-1 py-[2px] font-display text-[8px] font-extrabold text-white">
{t('config.vol.new')}
</span>
) : null}
</span>
<span className="font-mono text-[11px] opacity-80">{volunteer.phone}</span>
<span className="truncate font-display text-[11px] font-semibold opacity-80">
{volunteer.preferredCategories
.map((c) => t(categoryLabelKey(c)))
.join(' · ')}
<span className="truncate font-mono text-[11px] opacity-80">{volunteer.phone ?? '—'}</span>
<span className="truncate font-mono text-[10.5px] opacity-70">
{volunteer.email ?? '—'}
</span>
<span className="font-mono text-[10.5px] opacity-65">
{t('config.vol.slotsDeclared', { count: volunteer.availability.size })}
<span className="truncate font-mono text-[10px] opacity-70">
{volunteer.skill_codes.join(' · ') || '—'}
</span>
</button>
);
@@ -122,95 +124,119 @@ export function ConfigVolunteers() {
</div>
</div>
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
<div className="bg-ink px-[13px] py-[11px] text-cream">
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
<div className="mt-[7px] flex items-center gap-[9px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
{selected.initials}
</span>
<span className="font-display text-[19px] font-black tracking-tight">
{selected.fullName}
</span>
</div>
</div>
<div className="flex flex-col gap-3 p-[13px]">
<label className="block">
<FieldLabel>{t('config.vol.fullName')}</FieldLabel>
<TextInput
value={selected.fullName}
onChange={(value) =>
dispatch({ type: 'editVolunteer', id: selected.id, field: 'fullName', value })
}
/>
</label>
<label className="block">
<FieldLabel>{t('config.vol.phone')}</FieldLabel>
<TextInput
mono
value={selected.phone}
onChange={(value) =>
dispatch({ type: 'editVolunteer', id: selected.id, field: 'phone', value })
}
/>
</label>
<div>
<FieldLabel>{t('config.vol.email')}</FieldLabel>
<div className="truncate border-2 border-ink bg-sand p-[9px] font-mono text-[12px]">
{demoEmail(selected.firstName, selected.lastName)}
{selected ? (
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
<div className="bg-ink px-[13px] py-[11px] text-cream">
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
<div className="mt-[7px] flex items-center gap-[9px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
{selected.first_name[0]}
{selected.last_name[0]}
</span>
<span className="font-display text-[18px] font-black tracking-tight">
{selected.first_name} {selected.last_name}
</span>
</div>
</div>
<div>
<FieldLabel>{t('config.vol.wanted')}</FieldLabel>
<div className="flex flex-wrap gap-[6px]">
{DEMO_CATEGORIES.map((category) => {
const on = selected.preferredCategories.includes(category);
return (
<div className="flex flex-col gap-3 p-[13px]">
<div className="grid grid-cols-2 gap-2">
<label className="block">
<FieldLabel>{t('config.vol.firstName')}</FieldLabel>
<TextInput value={selected.first_name} onChange={(v) => patch({ first_name: v })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.lastName')}</FieldLabel>
<TextInput value={selected.last_name} onChange={(v) => patch({ last_name: v })} />
</label>
</div>
<label className="block">
<FieldLabel>{t('config.vol.phone')}</FieldLabel>
<TextInput mono value={selected.phone ?? ''} onChange={(v) => patch({ phone: v || null })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.email')}</FieldLabel>
<TextInput mono value={selected.email ?? ''} onChange={(v) => patch({ email: v || null })} />
</label>
<label className="block">
<FieldLabel>{t('config.vol.tshirt')}</FieldLabel>
<select
value={selected.tshirt_size ?? ''}
onChange={(e) => patch({ tshirt_size: (e.target.value || null) as TshirtSize | null })}
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
>
<option value="">—</option>
{TSHIRT_SIZES.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
</label>
<div>
<FieldLabel>{t('config.vol.skills')}</FieldLabel>
<div className="mb-2 flex flex-wrap gap-[6px]">
{selected.skill_codes.map((code) => (
<button
key={category}
key={code}
type="button"
onClick={() =>
dispatch({ type: 'togglePreference', id: selected.id, category })
patch({ skill_codes: selected.skill_codes.filter((c) => c !== code) })
}
className={cx(
'cursor-pointer border-2 border-ink px-2 py-[6px] font-display text-[10.5px]',
on ? 'bg-blue font-extrabold' : 'bg-cream font-semibold hover:bg-yellow',
)}
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
>
{on ? '★ ' : ''}
{t(categoryLabelKey(category))}
{code} ✕
</button>
);
})}
</div>
</div>
<div className="flex gap-[9px] border-t-2 border-ink pt-[11px]">
<div className="flex-1 border-2 border-ink bg-cream p-2">
<div className="font-mono text-[17px] font-bold">
{t('unit.hours', { count: assignedHours })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.vol.hoursAssigned')}
))}
{selected.skill_codes.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{t('config.vol.noSkills')}</span>
) : null}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const code = skillDraft.trim().toUpperCase();
if (code && !selected.skill_codes.includes(code)) {
patch({ skill_codes: [...selected.skill_codes, code] });
}
setSkillDraft('');
}}
className="flex gap-2"
>
<input
value={skillDraft}
onChange={(e) => setSkillDraft(e.target.value)}
placeholder={t('config.vol.skillPlaceholder')}
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
/>
<button
type="submit"
className="cursor-pointer border-2 border-ink bg-yellow px-3 font-display text-[11px] font-extrabold uppercase hover:bg-green hover:text-white"
>
{t('action.add')}
</button>
</form>
</div>
<div
className="flex-1 border-2 border-ink bg-dots-sand p-2"
style={{ backgroundSize: '5px 5px' }}
<button
type="button"
onClick={() => {
if (window.confirm(t('config.vol.deleteConfirm'))) {
deleteVolunteer.mutate(selected.id);
setSelectedId(null);
}
}}
className="mt-2 cursor-pointer border-[3px] border-ink bg-cream p-[10px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal hover:bg-orange"
>
<div className="font-mono text-[11px] leading-tight">
{t('config.vol.availabilityValue', { count: selected.availability.size })}
</div>
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('config.vol.availabilityDeclared')}
</div>
{t('config.vol.delete')}
</button>
<div className="font-mono text-[10px] leading-relaxed opacity-65">
{t('config.vol.note')}
</div>
</div>
<div className="font-mono text-[10px] leading-relaxed opacity-65">
{t('config.vol.note')}
</div>
</div>
</aside>
</aside>
) : null}
</div>
);
}