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
+70 -3
View File
@@ -1,10 +1,13 @@
// Rendu headless de chaque écran pour vérifier qu'aucun composant ne lève.
// Rendu headless de chaque écran, avec les données de démonstration
// préchargées, pour vérifier que la chaîne API → sélecteurs → rendu ne lève pas.
// Lancement : npx tsx --tsconfig tsconfig.app.json scripts/smoke.tsx
import { createElement } from 'react';
import { renderToString } from 'react-dom/server';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider, createMemoryRouter } from 'react-router-dom';
import { PlanningProvider } from '@/app/planning-store';
import { EDITION_ID, api } from '@/api';
import { queryKeys } from '@/api/hooks';
import { routes } from '@/app/router';
const paths = [
@@ -16,12 +19,75 @@ const paths = [
'/configuration',
];
async function prefetch(client: QueryClient) {
const e = EDITION_ID;
await Promise.all([
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
client.prefetchQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(e) }),
client.prefetchQuery({ queryKey: queryKeys.shifts(), queryFn: () => api.shifts.list(e) }),
client.prefetchQuery({ queryKey: queryKeys.assignments(), queryFn: () => api.assignments.list(e) }),
client.prefetchQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(e) }),
]);
}
async function checkMockApi() {
const e = EDITION_ID;
const [volunteers, positions, shifts, assignments, matrix] = await Promise.all([
api.volunteers.list(e),
api.positions.list(e),
api.shifts.list(e),
api.assignments.list(e),
api.matrix.get(e),
]);
const openShift = matrix.positions.flatMap((p) => p.shifts).find((s) => s.confirmed + s.proposed < s.min_staff);
const candidate = volunteers.find(
(v) => !assignments.some((a) => a.shift_id === openShift?.shift_id && a.volunteer_id === v.id),
);
const validation = openShift && candidate
? await api.assignments.validate(e, { volunteer_id: candidate.id, shift_id: openShift.shift_id })
: { blocking: false, violations: [] };
let dupError = 'none';
const existing = assignments[0];
try {
await api.assignments.create(e, { volunteer_id: existing.volunteer_id, shift_id: existing.shift_id });
} catch (err) {
dupError = err instanceof Error ? err.message : String(err);
}
console.log(
JSON.stringify(
{
volunteers: volunteers.length,
positions: positions.length,
shifts: shifts.length,
assignments: assignments.length,
matrixPositions: matrix.positions.length,
matrixShifts: matrix.positions.reduce((n, p) => n + p.shifts.length, 0),
sampleValidation: validation.violations.map((v) => v.code),
duplicateCreateRejectedWith: dupError,
},
null,
2,
),
);
}
let failures = 0;
await checkMockApi().catch((err) => {
failures += 1;
console.error('FAIL checkMockApi', err);
});
for (const path of paths) {
try {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await prefetch(client);
const router = createMemoryRouter(routes, { initialEntries: [path] });
const html = renderToString(
createElement(PlanningProvider, null, createElement(RouterProvider, { router })),
createElement(QueryClientProvider, { client }, createElement(RouterProvider, { router })),
);
console.log(`ok ${path.padEnd(20)} ${html.length} octets`);
} catch (error) {
@@ -29,4 +95,5 @@ for (const path of paths) {
console.error(`FAIL ${path}`, error);
}
}
process.exit(failures ? 1 : 0);