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
+16 -14
View File
@@ -1,7 +1,7 @@
import { NavLink, Outlet } from 'react-router-dom';
import { usePlanning } from '@/app/planning-store';
import { buildDiff } from '@/domain/publication';
import { API_MODE } from '@/api';
import { useAssignments, useVolunteers } from '@/api/hooks';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
@@ -15,19 +15,20 @@ const TABS = [
] as const;
export function AppShell() {
const { state } = usePlanning();
const diff = buildDiff(state);
const hasDraft = diff.length > 0;
const volunteers = useVolunteers();
const assignments = useAssignments();
const proposedCount = (assignments.data ?? []).filter((a) => a.status === 'PROPOSED').length;
return (
<div className="flex min-h-screen flex-col">
<header className="sticky top-0 z-20 flex items-stretch border-b-[3px] border-ink bg-yellow">
<div className="flex flex-col justify-center border-r-[3px] border-ink px-4 py-2">
<div className="font-display text-[14px] font-black tracking-tight">
{state.config.festivalName}
{t('header.title')}
</div>
<div className="font-mono text-[9.5px] font-medium opacity-70">
{t('header.meta', { count: state.volunteers.length })}
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
</div>
</div>
@@ -58,15 +59,16 @@ export function AppShell() {
</nav>
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]">
{hasDraft ? (
{API_MODE === 'mock' ? (
<div className="border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider">
{t('header.mockMode')}
</div>
) : null}
{proposedCount > 0 ? (
<div className="flex items-center gap-2 border-2 border-ink bg-hatch-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-yellow">
{t('header.draft', { count: diff.length })}
{t('header.proposed', { count: proposedCount })}
</div>
) : (
<div className="border-2 border-ink bg-green px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-white">
{t('header.published')}
</div>
)}
) : null}
<div className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold">
MB
</div>
-306
View File
@@ -1,306 +0,0 @@
import { createContext, useContext, useMemo, useReducer, type ReactNode } from 'react';
import { DAY_PARTS, FESTIVAL_DAYS, availabilityKey, slotKey } from '@/domain/festival';
import type {
Assignment,
EditionConfig,
Position,
RemovedAssignment,
StaffingNeed,
Volunteer,
} from '@/domain/types';
import { buildDemoDataset } from '@/mock/generate';
const DEFAULT_CONFIG: EditionConfig = {
festivalName: "LES SONS D'AUTOMNE",
datesLabel: '30–31 octobre 2026 (+ montage 29, démontage 1er nov.)',
maxDailyHours: 8,
minRestHours: 8,
autoWarn: true,
mailFrom: 'coordination@lessonsdautomne.fr',
mailReplyTo: 'marie.bertin@lessonsdautomne.fr',
mailSubject: 'Ton planning bénévole a été mis à jour',
mailBody:
"Salut {prenom},\n\nTon planning vient d'être mis à jour. Tu as {nb_creneaux} créneaux, soit {nb_heures} heures.\n\nMerci de confirmer chaque créneau depuis ton planning personnel.",
reminderEnabled: true,
reminderHours: 48,
magicLinkDays: 30,
smsEnabled: false,
};
export interface PlanningState {
positions: Position[];
volunteers: Volunteer[];
assignments: Assignment[];
removed: RemovedAssignment[];
config: EditionConfig;
version: number;
published: boolean;
nextSeq: number;
selectedSlot: string | null;
presence: Record<string, 'present' | 'absent'>;
importedCount: number;
}
type Action =
| { type: 'selectSlot'; slot: string | null }
| { type: 'assign'; volunteerId: string }
| { type: 'unassign'; assignmentId: string }
| { type: 'publish' }
| { type: 'confirmSlot'; assignmentId: string }
| { type: 'setConfig'; patch: Partial<EditionConfig> }
| { type: 'setNeed'; positionId: string; dayId: string; dayPartCode: string; which: 'min' | 'ideal'; delta: number }
| { type: 'toggleSlot'; positionId: string; dayId: string; dayPartCode: string }
| { type: 'editVolunteer'; id: string; field: 'fullName' | 'phone'; value: string }
| { type: 'togglePreference'; id: string; category: Position['category'] }
| { type: 'runImport' }
| { type: 'setPresence'; assignmentId: string; value: 'present' | 'absent' };
function initState(): PlanningState {
const demo = buildDemoDataset();
return {
positions: demo.positions,
volunteers: demo.volunteers,
assignments: demo.assignments,
removed: demo.removed,
config: DEFAULT_CONFIG,
version: 6,
published: false,
nextSeq: demo.nextAssignmentSeq,
selectedSlot: slotKey('PARN', 'ven', 'S'),
presence: {},
importedCount: 0,
};
}
function withNeeds(position: Position, needs: StaffingNeed[]): Position {
return { ...position, needs };
}
function reducer(state: PlanningState, action: Action): PlanningState {
switch (action.type) {
case 'selectSlot':
return { ...state, selectedSlot: action.slot };
case 'assign': {
if (!state.selectedSlot) return state;
const [positionId, dayId, dayPartCode] = state.selectedSlot.split('|');
const assignment: Assignment = {
id: `a${state.nextSeq}`,
volunteerId: action.volunteerId,
positionId,
dayId,
dayPartCode,
status: 'PROPOSED',
source: 'MANUAL',
locked: false,
pending: 'ADD',
};
return {
...state,
assignments: [...state.assignments, assignment],
nextSeq: state.nextSeq + 1,
published: false,
};
}
case 'unassign': {
const target = state.assignments.find((a) => a.id === action.assignmentId);
if (!target) return state;
const volunteer = state.volunteers.find((v) => v.id === target.volunteerId);
const position = state.positions.find((p) => p.id === target.positionId);
const day = FESTIVAL_DAYS.find((d) => d.id === target.dayId);
const part = DAY_PARTS.find((d) => d.code === target.dayPartCode);
const record: RemovedAssignment = {
volunteerName: volunteer?.fullName ?? target.volunteerId,
positionName: position?.name ?? target.positionId,
zone: position?.zone ?? '',
dayShortLabel: day?.shortLabel ?? target.dayId,
dayPartLabel: part?.label ?? target.dayPartCode,
};
return {
...state,
assignments: state.assignments.filter((a) => a.id !== action.assignmentId),
removed: [...state.removed, record],
published: false,
};
}
case 'publish':
return {
...state,
assignments: state.assignments.map((a) => (a.pending ? { ...a, pending: null } : a)),
removed: [],
version: state.version + 1,
published: true,
};
case 'confirmSlot':
return {
...state,
assignments: state.assignments.map((a) =>
a.id === action.assignmentId ? { ...a, status: 'CONFIRMED' } : a,
),
};
case 'setConfig':
return { ...state, config: { ...state.config, ...action.patch } };
case 'setNeed':
return {
...state,
positions: state.positions.map((position) => {
if (position.id !== action.positionId) return position;
const needs = position.needs.map((need) => {
if (need.dayId !== action.dayId || need.dayPartCode !== action.dayPartCode) return need;
let { minStaff, idealStaff } = need;
if (action.which === 'min') {
minStaff = Math.max(0, Math.min(12, minStaff + action.delta));
} else {
idealStaff = Math.max(0, Math.min(14, idealStaff + action.delta));
}
if (idealStaff < minStaff) {
if (action.which === 'min') idealStaff = minStaff;
else minStaff = idealStaff;
}
return { ...need, minStaff, idealStaff };
});
return withNeeds(position, needs);
}),
};
case 'toggleSlot':
return {
...state,
positions: state.positions.map((position) => {
if (position.id !== action.positionId) return position;
const exists = position.needs.some(
(n) => n.dayId === action.dayId && n.dayPartCode === action.dayPartCode,
);
let needs: StaffingNeed[];
if (exists) {
needs = position.needs.filter(
(n) => !(n.dayId === action.dayId && n.dayPartCode === action.dayPartCode),
);
} else {
needs = [
...position.needs,
{ dayId: action.dayId, dayPartCode: action.dayPartCode, minStaff: 2, idealStaff: 3 },
];
}
const order = DAY_PARTS.map((d) => d.code);
needs = [...needs].sort((a, b) => {
if (a.dayId !== b.dayId) {
return (
FESTIVAL_DAYS.findIndex((d) => d.id === a.dayId) -
FESTIVAL_DAYS.findIndex((d) => d.id === b.dayId)
);
}
return order.indexOf(a.dayPartCode) - order.indexOf(b.dayPartCode);
});
return withNeeds(position, needs);
}),
};
case 'editVolunteer':
return {
...state,
volunteers: state.volunteers.map((v) => {
if (v.id !== action.id) return v;
if (action.field === 'phone') return { ...v, phone: action.value };
const parts = action.value.trim().split(/\s+/);
const firstName = parts[0] ?? '';
const lastName = parts.slice(1).join(' ');
return {
...v,
fullName: action.value,
firstName,
lastName,
initials: (firstName[0] ?? '') + (lastName[0] ?? ''),
};
}),
};
case 'togglePreference':
return {
...state,
volunteers: state.volunteers.map((v) => {
if (v.id !== action.id) return v;
const has = v.preferredCategories.includes(action.category);
return {
...v,
preferredCategories: has
? v.preferredCategories.filter((c) => c !== action.category)
: [...v.preferredCategories, action.category],
};
}),
};
case 'runImport': {
const base = state.volunteers.length;
const names: Array<[string, string]> = [
['Océane', 'Thibault'],
['Samir', 'Amrani'],
['Lucie', 'Charpentier'],
['Noé', 'Delaunay'],
];
const added: Volunteer[] = names.map(([firstName, lastName], i) => {
const availability = new Set<string>();
FESTIVAL_DAYS.forEach((day) =>
DAY_PARTS.forEach((part) => {
if ((i + day.id.length + part.startHour) % 3) {
availability.add(availabilityKey(day.id, part.code));
}
}),
);
const categories = ['BAR', 'TICKETING', 'WELCOME', 'PARKING', 'CLEANING', 'CATERING', 'SETUP', 'STAGE'] as const;
return {
id: `v${base + i}`,
firstName,
lastName,
fullName: `${firstName} ${lastName}`,
initials: firstName[0] + lastName[0],
phone: `06 74 12 0${i} ${30 + i}`,
availability,
preferredCategories: [categories[i % categories.length]],
imported: true,
neverAssigned: true,
};
});
return {
...state,
volunteers: [...state.volunteers, ...added],
importedCount: state.importedCount + added.length,
};
}
case 'setPresence':
return {
...state,
presence: { ...state.presence, [action.assignmentId]: action.value },
};
default:
return state;
}
}
interface PlanningContextValue {
state: PlanningState;
dispatch: React.Dispatch<Action>;
}
const PlanningContext = createContext<PlanningContextValue | null>(null);
export function PlanningProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, undefined, initState);
const value = useMemo(() => ({ state, dispatch }), [state]);
return <PlanningContext.Provider value={value}>{children}</PlanningContext.Provider>;
}
// eslint-disable-next-line react-refresh/only-export-components -- hook colocalisé avec son provider
export function usePlanning(): PlanningContextValue {
const ctx = useContext(PlanningContext);
if (!ctx) throw new Error('usePlanning doit être utilisé dans <PlanningProvider>');
return ctx;
}