From c9dda653616cf0e5f647cd8b3aa088f11431544d Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 9 Sep 2026 23:16:21 +0200 Subject: [PATCH] Frontend : consommation de l'API CRUD (matrice, affectations, validation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2 --- frontend/.env.example | 13 +- frontend/README.md | 96 ++-- frontend/package-lock.json | 27 ++ frontend/package.json | 1 + frontend/scripts/smoke.tsx | 73 ++- frontend/src/api/client.ts | 42 -- frontend/src/api/hooks.ts | 213 +++++++++ frontend/src/api/http-client.ts | 113 +++++ frontend/src/api/index.ts | 19 + frontend/src/api/mock/dataset.ts | 293 ++++++++++++ frontend/src/api/mock/mock-client.ts | 427 ++++++++++++++++++ frontend/src/api/schema.ts | 210 +++++++++ frontend/src/api/types.ts | 76 ++++ frontend/src/app/AppShell.tsx | 30 +- frontend/src/app/planning-store.tsx | 306 ------------- frontend/src/components/state.tsx | 29 ++ frontend/src/domain/festival.ts | 69 --- frontend/src/domain/publication.ts | 50 -- frontend/src/domain/rules.ts | 105 ----- frontend/src/domain/selectors.ts | 71 --- frontend/src/domain/types.ts | 117 ----- frontend/src/i18n/locales/fr.json | 204 ++++----- frontend/src/lib/env.ts | 10 + frontend/src/lib/planning.ts | 149 ++++++ frontend/src/lib/time.ts | 53 +++ frontend/src/main.tsx | 12 +- frontend/src/mock/generate.ts | 205 --------- frontend/src/mock/positions.ts | 98 ---- frontend/src/pages/GrillePage.tsx | 246 +++++----- frontend/src/pages/MobileLeadPage.tsx | 104 +++-- frontend/src/pages/MobileVolunteerPage.tsx | 170 ++++--- frontend/src/pages/PublicationPage.tsx | 132 +++--- frontend/src/pages/VolunteerViewPage.tsx | 231 +++++----- frontend/src/pages/config/ConfigGrid.tsx | 298 ++++++------ frontend/src/pages/config/ConfigMail.tsx | 235 +++------- frontend/src/pages/config/ConfigRules.tsx | 156 ++----- .../src/pages/config/ConfigVolunteers.tsx | 282 ++++++------ frontend/src/pages/grille/AssignmentPanel.tsx | 379 +++++++++------- 38 files changed, 2930 insertions(+), 2414 deletions(-) delete mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/hooks.ts create mode 100644 frontend/src/api/http-client.ts create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/api/mock/dataset.ts create mode 100644 frontend/src/api/mock/mock-client.ts create mode 100644 frontend/src/api/schema.ts create mode 100644 frontend/src/api/types.ts delete mode 100644 frontend/src/app/planning-store.tsx create mode 100644 frontend/src/components/state.tsx delete mode 100644 frontend/src/domain/festival.ts delete mode 100644 frontend/src/domain/publication.ts delete mode 100644 frontend/src/domain/rules.ts delete mode 100644 frontend/src/domain/selectors.ts delete mode 100644 frontend/src/domain/types.ts create mode 100644 frontend/src/lib/env.ts create mode 100644 frontend/src/lib/planning.ts create mode 100644 frontend/src/lib/time.ts delete mode 100644 frontend/src/mock/generate.ts delete mode 100644 frontend/src/mock/positions.ts diff --git a/frontend/.env.example b/frontend/.env.example index 14f0e59..af6f2e2 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,12 @@ -# URL du backend visée par le proxy Vite en dev. -VITE_API_TARGET=http://localhost:8000 +# Mode d'accès aux données : +# mock → jeu de démonstration en mémoire (défaut, aucun back-end requis) +# http → API back-end réelle +VITE_API_MODE=mock -# En production, l'app est servie en statique et tape directement l'API. +# Mode http uniquement : édition ciblée et base de l'API. +# VITE_EDITION_ID=00000000-0000-4000-8000-000000000001 # VITE_API_BASE_URL=/api +# VITE_API_TARGET=http://localhost:8000 # cible du proxy Vite en dev + +# Bénévole affiché sur l'écran « Bénévole · mobile » (défaut : le premier). +# VITE_DEMO_VOLUNTEER_ID= diff --git a/frontend/README.md b/frontend/README.md index 1b55e30..6ccdbaf 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -2,63 +2,69 @@ React + Vite + TypeScript + Tailwind. Interface en français, code en anglais. -Intégration du design Claude Design « Bénévoles Festival » -(`Bénévoles Festival.dc.html`) : les 6 écrans du projet sont implémentés en -statique, pilotés par un **jeu de démonstration déterministe** (portage du -générateur du design). Le branchement sur l'API back-end est l'étape suivante. +Intègre le design Claude Design « Bénévoles Festival » et **consomme l'API +back-end** (`editions/{id}/volunteers · positions · shifts · assignments · +assignments/validate · matrix`). + +## Modes de données + +`VITE_API_MODE` (voir `.env.example`) : + +- **`mock`** (défaut) — jeu de démonstration déterministe **en mémoire**, + implémenté avec exactement la même surface que l'API (`src/api/mock/`). + Aucun back-end requis ; utile tant que le seed serveur n'existe pas. +- **`http`** — API réelle. `VITE_EDITION_ID` désigne l'édition ; en dev Vite + proxifie `/api` vers `VITE_API_TARGET` (défaut `http://localhost:8000`). + +Le passage d'un mode à l'autre ne change **aucun** écran : tout passe par +`src/api/` (client HTTP ou mock) et les hooks React Query de `src/api/hooks.ts`. ## Écrans (onglets) -| # | Route | Écran | -|---|----------------------|----------------------------------------------------------| -| 01 | `/grille` | Grille des besoins (postes × jours × plages) + panneau d'affectation (candidats triés, alertes bloquantes / avertissements) | -| 02 | `/benevoles` | Vue par bénévole (affectations + disponibilités déclarées, surcharge / oubliés) | -| 03 | `/publication` | Différentiel brouillon ↔ version publiée, aperçu du message, publication | -| 04 | `/mobile-benevole` | Maquette mobile : planning personnel, confirmation de créneau | -| 05 | `/mobile-referent` | Maquette mobile : pointage de l'équipe sur le créneau en cours | -| 06 | `/configuration` | Grille des besoins, bénévoles & import, messages & envois, règles du festival | +| # | Route | Source de données | Actions | +|---|-------|-------------------|---------| +| 01 | `/grille` | `GET /matrix` | clic cellule → panneau : `GET /assignments?shift_id`, `POST /assignments/validate` par candidat, `POST` / `PATCH` / `DELETE /assignments` | +| 02 | `/benevoles` | `volunteers` + `shifts` + `assignments` | lecture (surcharge / oubliés calculés côté client) | +| 03 | `/publication` | `assignments` (statut `PROPOSED`) | lecture — publication versionnée & emails **hors périmètre** (CLAUDE.md §4) | +| 04 | `/mobile-benevole` | `assignments?volunteer_id` | `PATCH /assignments/{id}` (confirmer) | +| 05 | `/mobile-referent` | `assignments?shift_id` | pointage **local** (hors périmètre) | +| 06 | `/configuration` | | `positions` CRUD, `shifts` CRUD (min/idéal, ajout/retrait), `volunteers` CRUD (dont suppression RGPD). Messages = maquette locale. | ## Commandes -| Action | Commande | -|------------------------|-----------------------------| -| Installer | `npm install` | -| Dev (hot reload) | `npm run dev` → http://localhost:5173 | -| Build de production | `npm run build` | -| Lint | `npm run lint` | -| Vérif. de types | `npm run typecheck` | -| Rendu headless des 6 écrans | `npm run smoke` | - -En dev, Vite proxifie `/api` vers le backend (`VITE_API_TARGET`, défaut -`http://localhost:8000`). +| Action | Commande | +|--------|----------| +| Installer | `npm install` | +| Dev | `npm run dev` → http://localhost:5173 | +| Build | `npm run build` | +| Lint | `npm run lint` | +| Types | `npm run typecheck` | +| Rendu headless + vérifs API mock | `npm run smoke` | ## Conventions -- **Aucun texte d'interface en dur.** Tout passe par `src/i18n` (`t(key, params)`) - et `src/i18n/locales/fr.json`. Les libellés *de données* (nom d'un poste, - d'une plage, d'une journée) sont portés par l'entité — ils viendront de l'API. -- Code, noms de fichiers et de variables en anglais. Alias `@/` → `src/`. -- Vocabulaire métier : glossaire FR ↔ EN de `CLAUDE.md`. -- Le moteur de validation (`src/domain/rules.ts`) reprend le contrat du back-end - (CLAUDE.md §3) : violations typées `BLOCKING` / `WARNING`, jamais de phrase. +- **Aucun texte d'interface en dur** : `src/i18n` (`t(key, params)`) + `locales/fr.json`. +- Les libellés *de données* (nom de poste, etc.) viennent de l'API. +- `src/api/schema.ts` est le miroir de `backend/app/api/schemas.py` — à garder synchrone. +- `violationText()` (`src/lib/planning.ts`) rend les violations du moteur ; + les `params` suivent le contrat de `backend/app/validation/engine.py`. +- Alias `@/` → `src/`. ## Arborescence ``` src/ -├── main.tsx # point d'entrée (Router + PlanningProvider) -├── app/ -│ ├── AppShell.tsx # en-tête + onglets + badge brouillon -│ ├── router.tsx # routes (tableau réutilisable) -│ └── planning-store.tsx # état + actions (reducer) sur le jeu de démo -├── domain/ -│ ├── types.ts # modèle (aligné CLAUDE.md) -│ ├── festival.ts # jours, plages, helpers temps -│ ├── rules.ts # moteur de validation (portage back-end) -│ ├── selectors.ts # index des affectations, surcharge -│ └── publication.ts # différentiel de publication -├── mock/ # jeu de démonstration déterministe -├── components/ui.tsx # primitives visuelles (bordures/ombres franches) -├── pages/ # un fichier par écran (+ sous-dossiers grille/, config/, mobile/) -└── i18n/ # t() + locales/fr.json +├── main.tsx +├── api/ +│ ├── schema.ts # types miroir du back-end +│ ├── types.ts # interface PlanningApi + ApiError +│ ├── http-client.ts # implémentation HTTP +│ ├── mock/ # implémentation mémoire (dataset + client + moteur) +│ ├── index.ts # sélection mock / http, EDITION_ID +│ └── hooks.ts # hooks React Query (lectures + mutations) +├── app/ AppShell (onglets) · router +├── pages/ un fichier par écran (+ grille/, config/, mobile/) +├── components/ ui.tsx (primitives) · state.tsx (Loading / ErrorNote) +├── lib/ time.ts · planning.ts (treillis, heures, surcharge, violationText) · env.ts · cx.ts +└── i18n/ t() + locales/fr.json ``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2cbca8c..0393205 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "benevoles-frontend", "version": "0.1.0", "dependencies": { + "@tanstack/react-query": "^5.59.16", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0" @@ -1543,6 +1544,32 @@ "win32" ] }, + "node_modules/@tanstack/query-core": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.102.8.tgz", + "integrity": "sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.102.8", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.102.8.tgz", + "integrity": "sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.102.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3dbab56..32d310e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "smoke": "tsx --tsconfig tsconfig.app.json scripts/smoke.tsx" }, "dependencies": { + "@tanstack/react-query": "^5.59.16", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0" diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index 6a75971..2d1dab0 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -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); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts deleted file mode 100644 index dcad813..0000000 --- a/frontend/src/api/client.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Client HTTP minimal. En dev, Vite proxifie `/api` vers le backend (:8000). -const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api'; - -export class ApiError extends Error { - constructor( - public readonly status: number, - public readonly body: unknown, - ) { - super(`API ${status}`); - this.name = 'ApiError'; - } -} - -async function request(path: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE_URL}${path}`, { - ...init, - headers: { - 'Content-Type': 'application/json', - ...init?.headers, - }, - credentials: 'include', - }); - - const isJson = res.headers.get('content-type')?.includes('application/json'); - const body = isJson ? await res.json() : await res.text(); - - if (!res.ok) { - throw new ApiError(res.status, body); - } - return body as T; -} - -export const api = { - get: (path: string) => request(path), - post: (path: string, data?: unknown) => - request(path, { method: 'POST', body: data === undefined ? undefined : JSON.stringify(data) }), - patch: (path: string, data?: unknown) => - request(path, { method: 'PATCH', body: data === undefined ? undefined : JSON.stringify(data) }), - put: (path: string, data?: unknown) => - request(path, { method: 'PUT', body: data === undefined ? undefined : JSON.stringify(data) }), - delete: (path: string) => request(path, { method: 'DELETE' }), -}; diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts new file mode 100644 index 0000000..798ef32 --- /dev/null +++ b/frontend/src/api/hooks.ts @@ -0,0 +1,213 @@ +import { + useMutation, + useQuery, + useQueryClient, + type UseQueryOptions, +} from '@tanstack/react-query'; + +import { EDITION_ID, api } from './index'; +import type { + AssignmentCreate, + AssignmentUpdate, + AssignmentValidateResult, + PositionCreate, + PositionUpdate, + ShiftCreate, + ShiftUpdate, + VolunteerCreate, + VolunteerUpdate, +} from './schema'; + +const edition = EDITION_ID; + +export const queryKeys = { + volunteers: ['volunteers', edition] as const, + positions: ['positions', edition] as const, + shifts: (positionId?: string) => ['shifts', edition, positionId ?? null] as const, + assignments: (params?: { volunteerId?: string; shiftId?: string }) => + ['assignments', edition, params?.volunteerId ?? null, params?.shiftId ?? null] as const, + matrix: ['matrix', edition] as const, + validate: (volunteerId: string, shiftId: string) => + ['validate', edition, volunteerId, shiftId] as const, +}; + +// --------------------------------------------------------------------------- // +// Lectures // +// --------------------------------------------------------------------------- // +export function useVolunteers() { + return useQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(edition) }); +} + +export function usePositions() { + return useQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(edition) }); +} + +export function useShifts(positionId?: string) { + return useQuery({ + queryKey: queryKeys.shifts(positionId), + queryFn: () => api.shifts.list(edition, positionId ? { positionId } : undefined), + }); +} + +export function useAssignments(params?: { volunteerId?: string; shiftId?: string }) { + return useQuery({ + queryKey: queryKeys.assignments(params), + queryFn: () => api.assignments.list(edition, params), + }); +} + +export function useMatrix() { + return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) }); +} + +export function useValidateAssignment( + volunteerId: string, + shiftId: string, + options?: Partial>, +) { + return useQuery({ + queryKey: queryKeys.validate(volunteerId, shiftId), + queryFn: () => api.assignments.validate(edition, { volunteer_id: volunteerId, shift_id: shiftId }), + staleTime: 60_000, + ...options, + }); +} + +// --------------------------------------------------------------------------- // +// Écritures // +// --------------------------------------------------------------------------- // +function useInvalidate() { + const client = useQueryClient(); + return { + volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }), + positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }), + shifts: () => client.invalidateQueries({ queryKey: ['shifts', edition] }), + planning: () => { + client.invalidateQueries({ queryKey: ['assignments', edition] }); + client.invalidateQueries({ queryKey: ['matrix', edition] }); + client.invalidateQueries({ queryKey: ['validate', edition] }); + }, + }; +} + +export function useCreateVolunteer() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: VolunteerCreate) => api.volunteers.create(edition, body), + onSuccess: invalidate.volunteers, + }); +} + +export function useUpdateVolunteer() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: VolunteerUpdate }) => + api.volunteers.update(edition, id, body), + onSuccess: invalidate.volunteers, + }); +} + +export function useDeleteVolunteer() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.volunteers.remove(edition, id), + onSuccess: () => { + invalidate.volunteers(); + invalidate.planning(); + }, + }); +} + +export function useCreatePosition() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: PositionCreate) => api.positions.create(edition, body), + onSuccess: () => { + invalidate.positions(); + invalidate.planning(); + }, + }); +} + +export function useUpdatePosition() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: PositionUpdate }) => + api.positions.update(edition, id, body), + onSuccess: () => { + invalidate.positions(); + invalidate.planning(); + }, + }); +} + +export function useDeletePosition() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.positions.remove(edition, id), + onSuccess: () => { + invalidate.positions(); + invalidate.shifts(); + invalidate.planning(); + }, + }); +} + +export function useCreateShift() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: ShiftCreate) => api.shifts.create(edition, body), + onSuccess: () => { + invalidate.shifts(); + invalidate.planning(); + }, + }); +} + +export function useUpdateShift() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: ShiftUpdate }) => + api.shifts.update(edition, id, body), + onSuccess: () => { + invalidate.shifts(); + invalidate.planning(); + }, + }); +} + +export function useDeleteShift() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.shifts.remove(edition, id), + onSuccess: () => { + invalidate.shifts(); + invalidate.planning(); + }, + }); +} + +export function useCreateAssignment() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (body: AssignmentCreate) => api.assignments.create(edition, body), + onSuccess: invalidate.planning, + }); +} + +export function useUpdateAssignment() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ id, body }: { id: string; body: AssignmentUpdate }) => + api.assignments.update(edition, id, body), + onSuccess: invalidate.planning, + }); +} + +export function useDeleteAssignment() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: (id: string) => api.assignments.remove(edition, id), + onSuccess: invalidate.planning, + }); +} diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts new file mode 100644 index 0000000..af70091 --- /dev/null +++ b/frontend/src/api/http-client.ts @@ -0,0 +1,113 @@ +import type { + AssignmentCreate, + AssignmentUpdate, + AssignmentValidateRequest, + PositionCreate, + PositionUpdate, + ShiftCreate, + ShiftUpdate, + VolunteerCreate, + VolunteerUpdate, +} from './schema'; +import { ApiError, type PlanningApi } from './types'; + +// Implémentation HTTP réelle. En dev, Vite proxifie `/api` vers le back-end. +export function createHttpApi(baseUrl: string): PlanningApi { + async function request(path: string, init?: RequestInit): Promise { + const res = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { 'Content-Type': 'application/json', ...init?.headers }, + credentials: 'include', + }); + + if (res.status === 204) return undefined as T; + + const isJson = res.headers.get('content-type')?.includes('application/json'); + const body: unknown = isJson ? await res.json() : await res.text(); + + if (!res.ok) { + const detail = + body && typeof body === 'object' && 'detail' in body + ? (body as { detail: unknown }).detail + : body; + const code = + detail && typeof detail === 'object' && 'code' in detail + ? String((detail as { code: unknown }).code) + : 'error.generic'; + const violations = + detail && typeof detail === 'object' && 'violations' in detail + ? ((detail as { violations: [] }).violations ?? []) + : []; + throw new ApiError(res.status, code, violations); + } + return body as T; + } + + const query = (params: Record) => { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value != null) search.set(key, value); + } + const str = search.toString(); + return str ? `?${str}` : ''; + }; + + const base = (editionId: string) => `/editions/${editionId}`; + + return { + volunteers: { + list: (editionId) => request(`${base(editionId)}/volunteers`), + get: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`), + create: (editionId, body: VolunteerCreate) => + request(`${base(editionId)}/volunteers`, { method: 'POST', body: JSON.stringify(body) }), + update: (editionId, id, body: VolunteerUpdate) => + request(`${base(editionId)}/volunteers/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), + remove: (editionId, id) => + request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }), + }, + positions: { + list: (editionId) => request(`${base(editionId)}/positions`), + get: (editionId, id) => request(`${base(editionId)}/positions/${id}`), + create: (editionId, body: PositionCreate) => + request(`${base(editionId)}/positions`, { method: 'POST', body: JSON.stringify(body) }), + update: (editionId, id, body: PositionUpdate) => + request(`${base(editionId)}/positions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), + remove: (editionId, id) => + request(`${base(editionId)}/positions/${id}`, { method: 'DELETE' }), + }, + shifts: { + list: (editionId, params) => + request(`${base(editionId)}/shifts${query({ position_id: params?.positionId })}`), + get: (editionId, id) => request(`${base(editionId)}/shifts/${id}`), + create: (editionId, body: ShiftCreate) => + request(`${base(editionId)}/shifts`, { method: 'POST', body: JSON.stringify(body) }), + update: (editionId, id, body: ShiftUpdate) => + request(`${base(editionId)}/shifts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), + remove: (editionId, id) => request(`${base(editionId)}/shifts/${id}`, { method: 'DELETE' }), + }, + assignments: { + list: (editionId, params) => + request( + `${base(editionId)}/assignments${query({ + volunteer_id: params?.volunteerId, + shift_id: params?.shiftId, + })}`, + ), + get: (editionId, id) => request(`${base(editionId)}/assignments/${id}`), + create: (editionId, body: AssignmentCreate) => + request(`${base(editionId)}/assignments`, { method: 'POST', body: JSON.stringify(body) }), + update: (editionId, id, body: AssignmentUpdate) => + request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), + remove: (editionId, id) => + request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }), + validate: (editionId, body: AssignmentValidateRequest) => + request(`${base(editionId)}/assignments/validate`, { + method: 'POST', + body: JSON.stringify(body), + }), + }, + matrix: { + get: (editionId) => request(`${base(editionId)}/matrix`), + }, + }; +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..439a27d --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,19 @@ +import { ENV } from '@/lib/env'; + +import { createHttpApi } from './http-client'; +import { MOCK_EDITION_ID } from './mock/dataset'; +import { createMockApi } from './mock/mock-client'; +import type { PlanningApi } from './types'; + +// `VITE_API_MODE` : `mock` (défaut, jeu de démonstration en mémoire) ou `http` +// (back-end réel). En mode http, `VITE_EDITION_ID` doit désigner une édition. +export const API_MODE = ENV.apiMode; + +export const EDITION_ID: string = ENV.editionId ?? MOCK_EDITION_ID; + +export const api: PlanningApi = + API_MODE === 'http' ? createHttpApi(ENV.apiBaseUrl) : createMockApi(); + +export { ApiError } from './types'; +export type { PlanningApi } from './types'; +export * from './schema'; diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts new file mode 100644 index 0000000..54c366d --- /dev/null +++ b/frontend/src/api/mock/dataset.ts @@ -0,0 +1,293 @@ +import { hoursBetween } from '@/lib/time'; + +import type { + AssignmentRead, + AssignmentStatus, + PositionCategory, + PositionRead, + ShiftRead, + VolunteerRead, +} from '../schema'; + +// Jeu de démonstration déterministe, mis en forme comme les réponses de l'API. +// Sert quand `VITE_API_MODE=mock` (défaut tant que le back-end n'a pas de seed). + +export const MOCK_EDITION_ID = '00000000-0000-4000-8000-000000000001'; + +export interface MockState { + volunteers: VolunteerRead[]; + positions: PositionRead[]; + shifts: ShiftRead[]; + assignments: AssignmentRead[]; + /** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */ + availability: Map>; + seq: number; +} + +const DAY_DATES = ['2026-10-15', '2026-10-16', '2026-10-17', '2026-10-18']; +const PART_BOUNDS: Array<[string, number, number]> = [ + ['M', 8, 12], + ['A', 12, 17], + ['S', 17, 22], + ['N', 22, 26], // 26 => 02 h le lendemain +]; + +type RawSlots = Record>; + +interface RawPosition { + id: string; + category: PositionCategory; + name: string; + location: string; + requiredSkills?: string[]; + slots: RawSlots; +} + +const RAW_POSITIONS: RawPosition[] = [ + { id: 'BAR', category: 'BAR', name: 'Bar — Grande scène', location: 'Chapiteau nord', slots: { '2026-10-16': [['A', 3, 4], ['S', 4, 6], ['N', 3, 5]], '2026-10-17': [['A', 3, 4], ['S', 5, 6], ['N', 4, 5]], '2026-10-18': [['M', 2, 3]] } }, + { id: 'BARV', category: 'BAR', name: 'Bar — Village assos', location: 'Village est', slots: { '2026-10-16': [['A', 2, 3], ['S', 3, 4]], '2026-10-17': [['A', 2, 3], ['S', 3, 4], ['N', 2, 3]], '2026-10-18': [['M', 2, 2]] } }, + { id: 'BILA', category: 'TICKETING', name: 'Billetterie — Entrée A', location: 'Portail est', requiredSkills: ['CAISSE'], slots: { '2026-10-16': [['A', 4, 5], ['S', 3, 4]], '2026-10-17': [['A', 4, 5], ['S', 3, 4]] } }, + { id: 'BILB', category: 'TICKETING', name: 'Billetterie — Entrée B', location: 'Portail ouest', requiredSkills: ['CAISSE'], slots: { '2026-10-16': [['A', 2, 3], ['S', 2, 3]], '2026-10-17': [['A', 3, 4], ['S', 2, 3]] } }, + { id: 'ACCB', category: 'WELCOME', name: 'Accueil — Bénévoles', location: 'Base bénévoles', slots: { '2026-10-15': [['M', 2, 3], ['A', 2, 3]], '2026-10-16': [['M', 2, 2], ['A', 2, 3], ['S', 2, 3]], '2026-10-17': [['M', 2, 2], ['A', 2, 3], ['S', 2, 3]], '2026-10-18': [['M', 2, 2]] } }, + { id: 'ACCA', category: 'WELCOME', name: 'Accueil — Artistes', location: 'Loges', slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3]], '2026-10-17': [['A', 2, 2], ['S', 2, 3]] } }, + { id: 'PARN', category: 'PARKING', name: 'Parking — Nord', location: 'Route de Bréal', slots: { '2026-10-16': [['A', 3, 4], ['S', 3, 4], ['N', 2, 3]], '2026-10-17': [['A', 3, 4], ['S', 4, 5], ['N', 2, 3]] } }, + { id: 'PARS', category: 'PARKING', name: 'Parking — Sud', location: 'Chemin des Prés', slots: { '2026-10-16': [['A', 2, 3], ['S', 2, 3]], '2026-10-17': [['A', 2, 3], ['S', 3, 4], ['N', 2, 2]] } }, + { id: 'PROS', category: 'CLEANING', name: 'Propreté — Site', location: 'Ensemble du site', slots: { '2026-10-16': [['S', 2, 3], ['N', 3, 4]], '2026-10-17': [['M', 3, 4], ['S', 2, 3], ['N', 3, 5]], '2026-10-18': [['M', 4, 6], ['A', 4, 6]] } }, + { id: 'PROA', category: 'CLEANING', name: 'Propreté — Sanitaires', location: 'Blocs 1 à 4', slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]], '2026-10-17': [['M', 2, 2], ['A', 2, 2], ['S', 2, 3], ['N', 2, 2]] } }, + { id: 'CATB', category: 'CATERING', name: 'Catering — Bénévoles', location: 'Tente cuisine', slots: { '2026-10-15': [['M', 2, 3], ['A', 2, 3]], '2026-10-16': [['M', 2, 3], ['A', 3, 4], ['S', 3, 4]], '2026-10-17': [['M', 2, 3], ['A', 3, 4], ['S', 3, 4]], '2026-10-18': [['M', 2, 3], ['A', 2, 2]] } }, + { id: 'CATA', category: 'CATERING', name: 'Catering — Artistes', location: 'Backstage', slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3]], '2026-10-17': [['A', 2, 2], ['S', 2, 3]] } }, + { id: 'MONS', category: 'SETUP', name: 'Montage — Scène', location: 'Grande scène', requiredSkills: ['CARISTE'], slots: { '2026-10-15': [['M', 6, 8], ['A', 6, 8], ['S', 4, 5]], '2026-10-18': [['A', 6, 8], ['S', 4, 6]] } }, + { id: 'MONI', category: 'SETUP', name: 'Montage — Signalétique', location: 'Site entier', slots: { '2026-10-15': [['M', 3, 4], ['A', 3, 4]], '2026-10-18': [['A', 3, 4]] } }, + { id: 'REG', category: 'STAGE', name: 'Régie — Régie centrale', location: 'Cabane régie', requiredSkills: ['REGIE'], slots: { '2026-10-16': [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]], '2026-10-17': [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]] } }, +]; + +const FIRST_NAMES = [ + 'Camille', 'Yanis', 'Léa', 'Mathis', 'Soraya', 'Thomas', 'Awa', 'Julien', 'Chloé', 'Nadia', + 'Hugo', 'Inès', 'Baptiste', 'Manon', 'Karim', 'Élodie', 'Rémi', 'Sarah', 'Antoine', 'Fanny', + 'Malik', 'Clara', 'Victor', 'Jeanne', +]; +const LAST_NAMES = [ + 'Fournier', 'Berger', 'Moreau', 'Lambert', 'Benali', 'Rey', 'Diallo', 'Chevalier', 'Marchand', + 'Perrin', 'Lefèvre', 'Nguyen', 'Rossi', 'Garnier', 'Barbier', 'Vasseur', 'Colin', 'Prévost', + 'Renard', 'Dupuy', +]; +const SKILL_POOL = ['CAISSE', 'CARISTE', 'REGIE', 'SECOURISME']; + +const NOW = '2026-10-10T09:00:00'; +const NEVER_ASSIGNED = new Set([7, 21, 34, 48, 55]); +const OVERLOADED = [3, 9]; + +function mulberry32(seed: number): () => number { + let a = seed; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function iso(dateStr: string, hour: number): string { + if (hour >= 24) { + const [y, m, d] = dateStr.split('-').map(Number); + const next = new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10); + return `${next}T${String(hour - 24).padStart(2, '0')}:00:00`; + } + return `${dateStr}T${String(hour).padStart(2, '0')}:00:00`; +} + +function rollStatus(roll: number): AssignmentStatus { + if (roll < 0.72) return 'CONFIRMED'; + if (roll < 0.95) return 'PROPOSED'; + return 'DECLINED'; +} + +function demoEmail(firstName: string, lastName: string): string { + const n = (s: string) => s.toLowerCase().normalize('NFD').replace(/[^a-z]/g, ''); + return `${n(firstName)}.${n(lastName)}@mail.fr`; +} + +export function buildMockState(): MockState { + const rnd = mulberry32(20260715); + const edition_id = MOCK_EDITION_ID; + + const positions: PositionRead[] = RAW_POSITIONS.map((p) => ({ + id: p.id, + edition_id, + name: p.name, + location: p.location, + category: p.category, + lead_account_id: null, + created_at: NOW, + updated_at: NOW, + })); + + const shifts: ShiftRead[] = []; + let shiftSeq = 1; + for (const raw of RAW_POSITIONS) { + for (const [dateStr, slots] of Object.entries(raw.slots)) { + for (const [code, min_staff, ideal_staff] of slots) { + const bounds = PART_BOUNDS.find((b) => b[0] === code)!; + shifts.push({ + id: `s${shiftSeq}`, + edition_id, + position_id: raw.id, + starts_at: iso(dateStr, bounds[1]), + ends_at: iso(dateStr, bounds[2]), + min_staff, + ideal_staff, + required_skill_codes: raw.requiredSkills ?? [], + created_at: NOW, + updated_at: NOW, + }); + shiftSeq += 1; + } + } + } + + const volunteers: VolunteerRead[] = []; + const availability = new Map>(); + for (let i = 0; i < 64; i += 1) { + const first_name = FIRST_NAMES[i % FIRST_NAMES.length]; + const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length]; + const id = `v${i}`; + const avail = new Set(); + for (const dateStr of DAY_DATES) { + const edge = dateStr === DAY_DATES[0] || dateStr === DAY_DATES[3]; + for (const [, startHour] of PART_BOUNDS) { + if (rnd() < (edge ? 0.4 : 0.6)) { + avail.add(`${dateStr}|${String(startHour).padStart(2, '0')}:00`); + } + } + } + availability.set(id, avail); + const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2); + const skill_codes = rnd() < 0.3 ? [SKILL_POOL[Math.floor(rnd() * SKILL_POOL.length)]] : []; + volunteers.push({ + id, + edition_id, + first_name, + last_name, + email: rnd() < 0.85 ? demoEmail(first_name, last_name) : null, + phone: `06 ${pair()} ${pair()} ${pair()} ${pair()}`, + tshirt_size: (['S', 'M', 'L', 'XL'] as const)[Math.floor(rnd() * 4)], + diet: rnd() < 0.15 ? 'végétarien' : null, + comment: null, + skill_codes, + created_at: NOW, + updated_at: NOW, + }); + } + + const me = volunteers[0]; + availability.set( + me.id, + new Set( + DAY_DATES.flatMap((dateStr) => + PART_BOUNDS.map(([, h]) => `${dateStr}|${String(h).padStart(2, '0')}:00`), + ), + ), + ); + NEVER_ASSIGNED.forEach((i) => { + volunteers[i].comment = 'Inscrit, pas encore affecté'; + }); + + const assignments: AssignmentRead[] = []; + const busy = new Set(); + const dayHours = new Map(); + const totalHours = new Map(); + let seq = 1; + let slotIndex = 0; + + for (const shift of shifts) { + slotIndex += 1; + let target = shift.ideal_staff; + if (slotIndex % 7 === 0) target = Math.max(1, shift.min_staff - 1); + else if (slotIndex % 5 === 0) target = shift.min_staff; + else if (slotIndex % 3 === 0) target = Math.max(shift.min_staff, shift.ideal_staff - 1); + + const duration = hoursBetween(shift.starts_at, shift.ends_at); + const dateStr = shift.starts_at.slice(0, 10); + const availKey = `${dateStr}|${shift.starts_at.slice(11, 16)}`; + + const pool = volunteers + .filter( + (v, idx) => + !NEVER_ASSIGNED.has(idx) && + !busy.has(`${v.id}|${shift.id}`) && + (dayHours.get(`${v.id}|${dateStr}`) ?? 0) + duration <= 8, + ) + .sort((a, b) => score(b) - score(a)); + + function score(v: VolunteerRead): number { + const available = availability.get(v.id)?.has(availKey) ? 100 : 0; + const skilled = + shift.required_skill_codes.length > 0 && + shift.required_skill_codes.every((c) => v.skill_codes.includes(c)) + ? 40 + : 0; + return available + skilled - (totalHours.get(v.id) ?? 0) * 3; + } + + for (const v of pool.slice(0, target)) { + const status = rollStatus(rnd()); + assignments.push({ + id: `a${seq}`, + edition_id, + volunteer_id: v.id, + shift_id: shift.id, + status, + source: 'SOLVER', + locked: false, + created_at: NOW, + updated_at: NOW, + }); + seq += 1; + if (status !== 'DECLINED') { + busy.add(`${v.id}|${shift.id}`); + dayHours.set(`${v.id}|${dateStr}`, (dayHours.get(`${v.id}|${dateStr}`) ?? 0) + duration); + totalHours.set(v.id, (totalHours.get(v.id) ?? 0) + duration); + } + } + } + + assignments + .filter((a) => a.volunteer_id === me.id) + .forEach((a, i) => { + a.status = i === 0 ? 'CONFIRMED' : 'PROPOSED'; + }); + + OVERLOADED.forEach((index) => { + const v = volunteers[index]; + const friday = shifts.filter((s) => s.starts_at.slice(0, 10) === '2026-10-17'); + const shift = friday.find( + (s) => + !assignments.some( + (a) => + a.volunteer_id === v.id && + shiftsOverlap(shifts.find((x) => x.id === a.shift_id), s), + ), + ); + if (!shift) return; + assignments.push({ + id: `a${seq}`, + edition_id, + volunteer_id: v.id, + shift_id: shift.id, + status: 'CONFIRMED', + source: 'MANUAL', + locked: false, + created_at: NOW, + updated_at: NOW, + }); + seq += 1; + }); + + return { volunteers, positions, shifts, assignments, availability, seq }; +} + +function shiftsOverlap(a: ShiftRead | undefined, b: ShiftRead): boolean { + if (!a) return false; + return a.starts_at < b.ends_at && b.starts_at < a.ends_at; +} diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts new file mode 100644 index 0000000..c5e7ab3 --- /dev/null +++ b/frontend/src/api/mock/mock-client.ts @@ -0,0 +1,427 @@ +import { hoursBetween, naiveMinutes } from '@/lib/time'; + +import type { + AssignmentRead, + AssignmentValidateResult, + MatrixResponse, + ShiftRead, + Violation, +} from '../schema'; +import { ACTIVE_ASSIGNMENT_STATUSES } from '../schema'; +import { ApiError, type PlanningApi } from '../types'; +import { buildMockState, type MockState } from './dataset'; + +// Implémentation en mémoire de `PlanningApi`. Un délai réseau simulé rend le +// comportement (chargements, invalidations) proche du back-end réel. + +const LATENCY_MS = 120; +const EIGHT_HOURS_MIN = 8 * 60; + +let state: MockState = buildMockState(); + +/** Réinitialise le jeu de démonstration (utile en test). */ +export function resetMockState(): void { + state = buildMockState(); +} + +const delay = (value: T): Promise => + new Promise((resolve) => setTimeout(() => resolve(structuredClone(value)), LATENCY_MS)); + +const notFound = (): never => { + throw new ApiError(404, 'error.not_found'); +}; + +const touch = () => new Date().toISOString().replace(/\.\d+Z$/, ''); + +function shiftById(id: string): ShiftRead { + const shift = state.shifts.find((s) => s.id === id); + if (!shift) return notFound(); + return shift; +} + +function overlaps(a: ShiftRead, b: ShiftRead): boolean { + return a.starts_at < b.ends_at && b.starts_at < a.ends_at; +} + +/** Portage frontend du moteur (`backend/app/validation/engine.py`). */ +function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignmentId?: string): Violation[] { + const volunteer = state.volunteers.find((v) => v.id === volunteerId); + if (!volunteer) return notFound(); + + const existing = state.assignments + .filter( + (a) => + a.volunteer_id === volunteerId && + a.id !== excludeAssignmentId && + a.shift_id !== candidate.id && + ACTIVE_ASSIGNMENT_STATUSES.includes(a.status), + ) + .map((a) => shiftById(a.shift_id)); + + const violations: Violation[] = []; + + // SHIFT_OVERLAP (bloquante) + for (const other of existing) { + if (overlaps(candidate, other)) { + violations.push({ + code: 'SHIFT_OVERLAP', + severity: 'BLOCKING', + params: { + other_shift_id: other.id, + other_starts_at: other.starts_at, + other_ends_at: other.ends_at, + }, + }); + } + } + + // OUTSIDE_AVAILABILITY (avertissement) — le mock ne connaît que AVAILABLE / non déclaré. + const declared = state.availability.get(volunteerId) ?? new Set(); + const availKey = `${candidate.starts_at.slice(0, 10)}|${candidate.starts_at.slice(11, 16)}`; + if (!declared.has(availKey)) { + violations.push({ + code: 'OUTSIDE_AVAILABILITY', + severity: 'WARNING', + params: { + date: candidate.starts_at.slice(0, 10), + day_part_code: candidate.starts_at.slice(11, 16), + status: 'UNDECLARED', + }, + }); + } + + // DAILY_HOURS_EXCEEDED (avertissement) — journée = date de début du créneau. + const dayKey = candidate.starts_at.slice(0, 10); + let totalMin = (naiveMinutes(candidate.ends_at) - naiveMinutes(candidate.starts_at)); + for (const other of existing) { + if (other.starts_at.slice(0, 10) === dayKey) { + totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at); + } + } + if (totalMin > EIGHT_HOURS_MIN) { + violations.push({ + code: 'DAILY_HOURS_EXCEEDED', + severity: 'WARNING', + params: { date: dayKey, total_minutes: totalMin }, + }); + } + + // INSUFFICIENT_REST (avertissement) — < 8 h entre deux créneaux (strict). + for (const other of existing) { + if (overlaps(candidate, other)) continue; + let gap: number; + let direction: 'BEFORE' | 'AFTER'; + if (naiveMinutes(other.ends_at) <= naiveMinutes(candidate.starts_at)) { + gap = naiveMinutes(candidate.starts_at) - naiveMinutes(other.ends_at); + direction = 'BEFORE'; + } else if (naiveMinutes(candidate.ends_at) <= naiveMinutes(other.starts_at)) { + gap = naiveMinutes(other.starts_at) - naiveMinutes(candidate.ends_at); + direction = 'AFTER'; + } else { + continue; + } + if (gap < EIGHT_HOURS_MIN) { + violations.push({ + code: 'INSUFFICIENT_REST', + severity: 'WARNING', + params: { other_shift_id: other.id, direction, gap_minutes: gap }, + }); + } + } + + // MISSING_REQUIRED_SKILL (avertissement) + for (const code of [...candidate.required_skill_codes].sort()) { + if (!volunteer.skill_codes.includes(code)) { + violations.push({ + code: 'MISSING_REQUIRED_SKILL', + severity: 'WARNING', + params: { skill_code: code }, + }); + } + } + + return violations; +} + +function hasBlocking(violations: Violation[]): boolean { + return violations.some((v) => v.severity === 'BLOCKING'); +} + +function buildMatrix(): MatrixResponse { + const counts = new Map(); + for (const a of state.assignments) { + const entry = counts.get(a.shift_id) ?? { confirmed: 0, proposed: 0 }; + if (a.status === 'CONFIRMED') entry.confirmed += 1; + if (a.status === 'PROPOSED') entry.proposed += 1; + counts.set(a.shift_id, entry); + } + return { + edition_id: state.positions[0]?.edition_id ?? '', + positions: [...state.positions] + .sort((a, b) => a.name.localeCompare(b.name, 'fr')) + .map((position) => ({ + id: position.id, + name: position.name, + category: position.category, + location: position.location, + shifts: state.shifts + .filter((s) => s.position_id === position.id) + .sort((a, b) => a.starts_at.localeCompare(b.starts_at)) + .map((s) => { + const c = counts.get(s.id) ?? { confirmed: 0, proposed: 0 }; + return { + shift_id: s.id, + starts_at: s.starts_at, + ends_at: s.ends_at, + min_staff: s.min_staff, + ideal_staff: s.ideal_staff, + confirmed: c.confirmed, + proposed: c.proposed, + }; + }), + })), + }; +} + +export function createMockApi(): PlanningApi { + return { + volunteers: { + list: () => + delay( + [...state.volunteers].sort( + (a, b) => + a.last_name.localeCompare(b.last_name, 'fr') || + a.first_name.localeCompare(b.first_name, 'fr'), + ), + ), + get: (_edition, id) => { + const v = state.volunteers.find((x) => x.id === id) ?? notFound(); + return delay(v); + }, + create: (_edition, body) => { + const now = touch(); + const volunteer = { + id: `v${state.seq++}`, + edition_id: state.positions[0].edition_id, + first_name: body.first_name, + last_name: body.last_name, + email: body.email ?? null, + phone: body.phone ?? null, + tshirt_size: body.tshirt_size ?? null, + diet: body.diet ?? null, + comment: body.comment ?? null, + skill_codes: body.skill_codes ?? [], + created_at: now, + updated_at: now, + }; + state.volunteers.push(volunteer); + state.availability.set(volunteer.id, new Set()); + return delay(volunteer); + }, + update: (_edition, id, body) => { + const v = state.volunteers.find((x) => x.id === id) ?? notFound(); + Object.assign(v, { + ...('first_name' in body ? { first_name: body.first_name } : {}), + ...('last_name' in body ? { last_name: body.last_name } : {}), + ...('email' in body ? { email: body.email ?? null } : {}), + ...('phone' in body ? { phone: body.phone ?? null } : {}), + ...('tshirt_size' in body ? { tshirt_size: body.tshirt_size ?? null } : {}), + ...('diet' in body ? { diet: body.diet ?? null } : {}), + ...('comment' in body ? { comment: body.comment ?? null } : {}), + ...(body.skill_codes != null ? { skill_codes: body.skill_codes } : {}), + updated_at: touch(), + }); + return delay(v); + }, + remove: (_edition, id) => { + state.volunteers = state.volunteers.filter((x) => x.id !== id); + state.assignments = state.assignments.filter((a) => a.volunteer_id !== id); + state.availability.delete(id); + return delay(undefined); + }, + }, + + positions: { + list: () => + delay([...state.positions].sort((a, b) => a.name.localeCompare(b.name, 'fr'))), + get: (_edition, id) => delay(state.positions.find((x) => x.id === id) ?? notFound()), + create: (_edition, body) => { + const now = touch(); + const position = { + id: `p${state.seq++}`, + edition_id: state.positions[0].edition_id, + name: body.name, + location: body.location ?? null, + category: body.category, + lead_account_id: body.lead_account_id ?? null, + created_at: now, + updated_at: now, + }; + state.positions.push(position); + return delay(position); + }, + update: (_edition, id, body) => { + const p = state.positions.find((x) => x.id === id) ?? notFound(); + Object.assign(p, { + ...('name' in body ? { name: body.name } : {}), + ...('location' in body ? { location: body.location ?? null } : {}), + ...('category' in body ? { category: body.category } : {}), + ...('lead_account_id' in body ? { lead_account_id: body.lead_account_id ?? null } : {}), + updated_at: touch(), + }); + return delay(p); + }, + remove: (_edition, id) => { + const shiftIds = new Set(state.shifts.filter((s) => s.position_id === id).map((s) => s.id)); + state.positions = state.positions.filter((x) => x.id !== id); + state.shifts = state.shifts.filter((s) => s.position_id !== id); + state.assignments = state.assignments.filter((a) => !shiftIds.has(a.shift_id)); + return delay(undefined); + }, + }, + + shifts: { + list: (_edition, params) => + delay( + [...state.shifts] + .filter((s) => !params?.positionId || s.position_id === params.positionId) + .sort((a, b) => a.starts_at.localeCompare(b.starts_at)), + ), + get: (_edition, id) => delay(shiftById(id)), + create: (_edition, body) => { + if (naiveMinutes(body.ends_at) <= naiveMinutes(body.starts_at)) { + throw new ApiError(422, 'shift.ends_not_after_starts'); + } + if (!state.positions.some((p) => p.id === body.position_id)) { + throw new ApiError(422, 'shift.position_wrong_edition'); + } + const now = touch(); + const shift: ShiftRead = { + id: `s${state.seq++}`, + edition_id: state.positions[0].edition_id, + position_id: body.position_id, + starts_at: body.starts_at, + ends_at: body.ends_at, + min_staff: body.min_staff ?? 0, + ideal_staff: body.ideal_staff ?? 0, + required_skill_codes: body.required_skill_codes ?? [], + created_at: now, + updated_at: now, + }; + state.shifts.push(shift); + return delay(shift); + }, + update: (_edition, id, body) => { + const s = shiftById(id); + Object.assign(s, { + ...('position_id' in body ? { position_id: body.position_id } : {}), + ...('starts_at' in body ? { starts_at: body.starts_at } : {}), + ...('ends_at' in body ? { ends_at: body.ends_at } : {}), + ...('min_staff' in body ? { min_staff: body.min_staff } : {}), + ...('ideal_staff' in body ? { ideal_staff: body.ideal_staff } : {}), + ...(body.required_skill_codes != null + ? { required_skill_codes: body.required_skill_codes } + : {}), + updated_at: touch(), + }); + if (naiveMinutes(s.ends_at) <= naiveMinutes(s.starts_at)) { + throw new ApiError(422, 'shift.ends_not_after_starts'); + } + return delay(s); + }, + remove: (_edition, id) => { + state.shifts = state.shifts.filter((x) => x.id !== id); + state.assignments = state.assignments.filter((a) => a.shift_id !== id); + return delay(undefined); + }, + }, + + assignments: { + list: (_edition, params) => + delay( + state.assignments.filter( + (a) => + (!params?.volunteerId || a.volunteer_id === params.volunteerId) && + (!params?.shiftId || a.shift_id === params.shiftId), + ), + ), + get: (_edition, id) => delay(state.assignments.find((x) => x.id === id) ?? notFound()), + create: (_edition, body) => { + const shift = shiftById(body.shift_id); + if (!state.volunteers.some((v) => v.id === body.volunteer_id)) { + throw new ApiError(422, 'assignment.wrong_edition'); + } + if ( + state.assignments.some( + (a) => a.volunteer_id === body.volunteer_id && a.shift_id === body.shift_id, + ) + ) { + throw new ApiError(409, 'assignment.duplicate'); + } + const status = body.status ?? 'PROPOSED'; + let warnings: Violation[] = []; + if (ACTIVE_ASSIGNMENT_STATUSES.includes(status)) { + warnings = runValidation(body.volunteer_id, shift); + if (hasBlocking(warnings)) { + throw new ApiError(422, 'error.validation_failed', warnings); + } + } + const now = touch(); + const assignment: AssignmentRead = { + id: `a${state.seq++}`, + edition_id: shift.edition_id, + volunteer_id: body.volunteer_id, + shift_id: body.shift_id, + status, + source: body.source ?? 'MANUAL', + locked: body.locked ?? false, + created_at: now, + updated_at: now, + }; + state.assignments.push(assignment); + return delay({ assignment, warnings }); + }, + update: (_edition, id, body) => { + const a = state.assignments.find((x) => x.id === id) ?? notFound(); + Object.assign(a, { + ...('status' in body ? { status: body.status } : {}), + ...('source' in body ? { source: body.source } : {}), + ...('locked' in body ? { locked: body.locked } : {}), + updated_at: touch(), + }); + let warnings: Violation[] = []; + if (ACTIVE_ASSIGNMENT_STATUSES.includes(a.status)) { + warnings = runValidation(a.volunteer_id, shiftById(a.shift_id), a.id); + if (hasBlocking(warnings)) { + throw new ApiError(422, 'error.validation_failed', warnings); + } + } + return delay({ assignment: a, warnings }); + }, + remove: (_edition, id) => { + state.assignments = state.assignments.filter((x) => x.id !== id); + return delay(undefined); + }, + validate: (_edition, body): Promise => { + const violations = runValidation(body.volunteer_id, shiftById(body.shift_id)); + return delay({ blocking: hasBlocking(violations), violations }); + }, + }, + + matrix: { + get: () => delay(buildMatrix()), + }, + }; +} + +/** Heures actives d'un bénévole (utilitaire mock, exposé pour les tests). */ +export function mockVolunteerHours(volunteerId: string): number { + return state.assignments + .filter( + (a) => a.volunteer_id === volunteerId && ACTIVE_ASSIGNMENT_STATUSES.includes(a.status), + ) + .reduce((sum, a) => { + const s = state.shifts.find((x) => x.id === a.shift_id); + return s ? sum + hoursBetween(s.starts_at, s.ends_at) : sum; + }, 0); +} diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts new file mode 100644 index 0000000..77fc98d --- /dev/null +++ b/frontend/src/api/schema.ts @@ -0,0 +1,210 @@ +// Types miroir des schémas de l'API back-end (`backend/app/api/schemas.py`). +// Toute évolution du contrat doit être répercutée ici. + +export type PositionCategory = + | 'BAR' + | 'TICKETING' + | 'WELCOME' + | 'PARKING' + | 'CLEANING' + | 'CATERING' + | 'SETUP' + | 'STAGE' + | 'OTHER'; + +export type AssignmentStatus = 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLED'; +export type AssignmentSource = 'SOLVER' | 'MANUAL'; +export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL'; +export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE'; + +/** Statuts « actifs » : seuls comptés pour chevauchement / cumul / repos. */ +export const ACTIVE_ASSIGNMENT_STATUSES: AssignmentStatus[] = ['PROPOSED', 'CONFIRMED']; + +export type ViolationCode = + | 'SHIFT_OVERLAP' + | 'OUTSIDE_AVAILABILITY' + | 'DAILY_HOURS_EXCEEDED' + | 'INSUFFICIENT_REST' + | 'MISSING_REQUIRED_SKILL'; + +export type ViolationSeverity = 'BLOCKING' | 'WARNING'; + +export interface Violation { + code: ViolationCode; + severity: ViolationSeverity; + params: Record; +} + +// --------------------------------------------------------------------------- // +// Volunteer // +// --------------------------------------------------------------------------- // +export interface VolunteerRead { + id: string; + edition_id: string; + first_name: string; + last_name: string; + email: string | null; + phone: string | null; + tshirt_size: TshirtSize | null; + diet: string | null; + comment: string | null; + skill_codes: string[]; + created_at: string; + updated_at: string; +} + +export interface VolunteerCreate { + first_name: string; + last_name: string; + email?: string | null; + phone?: string | null; + tshirt_size?: TshirtSize | null; + diet?: string | null; + comment?: string | null; + skill_codes?: string[]; +} + +export interface VolunteerUpdate { + first_name?: string; + last_name?: string; + email?: string | null; + phone?: string | null; + tshirt_size?: TshirtSize | null; + diet?: string | null; + comment?: string | null; + /** null / absent = inchangé ; [] = vider les compétences. */ + skill_codes?: string[] | null; +} + +// --------------------------------------------------------------------------- // +// Position // +// --------------------------------------------------------------------------- // +export interface PositionRead { + id: string; + edition_id: string; + name: string; + location: string | null; + category: PositionCategory; + lead_account_id: string | null; + created_at: string; + updated_at: string; +} + +export interface PositionCreate { + name: string; + location?: string | null; + category: PositionCategory; + lead_account_id?: string | null; +} + +export interface PositionUpdate { + name?: string; + location?: string | null; + category?: PositionCategory; + lead_account_id?: string | null; +} + +// --------------------------------------------------------------------------- // +// Shift // +// --------------------------------------------------------------------------- // +export interface ShiftRead { + id: string; + edition_id: string; + position_id: string; + /** Datetime naïf, heure locale Europe/Paris (ISO sans fuseau). */ + starts_at: string; + ends_at: string; + min_staff: number; + ideal_staff: number; + required_skill_codes: string[]; + created_at: string; + updated_at: string; +} + +export interface ShiftCreate { + position_id: string; + starts_at: string; + ends_at: string; + min_staff?: number; + ideal_staff?: number; + required_skill_codes?: string[]; +} + +export interface ShiftUpdate { + position_id?: string; + starts_at?: string; + ends_at?: string; + min_staff?: number; + ideal_staff?: number; + required_skill_codes?: string[] | null; +} + +// --------------------------------------------------------------------------- // +// Assignment // +// --------------------------------------------------------------------------- // +export interface AssignmentRead { + id: string; + edition_id: string; + volunteer_id: string; + shift_id: string; + status: AssignmentStatus; + source: AssignmentSource; + locked: boolean; + created_at: string; + updated_at: string; +} + +export interface AssignmentCreate { + volunteer_id: string; + shift_id: string; + status?: AssignmentStatus; + source?: AssignmentSource; + locked?: boolean; +} + +export interface AssignmentUpdate { + status?: AssignmentStatus; + source?: AssignmentSource; + locked?: boolean; +} + +export interface AssignmentWriteResult { + assignment: AssignmentRead; + warnings: Violation[]; +} + +export interface AssignmentValidateRequest { + volunteer_id: string; + shift_id: string; +} + +export interface AssignmentValidateResult { + blocking: boolean; + violations: Violation[]; +} + +// --------------------------------------------------------------------------- // +// Matrice poste × créneau // +// --------------------------------------------------------------------------- // +export interface MatrixShift { + shift_id: string; + starts_at: string; + ends_at: string; + min_staff: number; + ideal_staff: number; + confirmed: number; + proposed: number; +} + +export interface MatrixPosition { + id: string; + name: string; + category: PositionCategory; + location: string | null; + shifts: MatrixShift[]; +} + +export interface MatrixResponse { + edition_id: string; + positions: MatrixPosition[]; +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..ef045ea --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,76 @@ +import type { + AssignmentCreate, + AssignmentRead, + AssignmentUpdate, + AssignmentValidateRequest, + AssignmentValidateResult, + AssignmentWriteResult, + MatrixResponse, + PositionCreate, + PositionRead, + PositionUpdate, + ShiftCreate, + ShiftRead, + ShiftUpdate, + Violation, + VolunteerCreate, + VolunteerRead, + VolunteerUpdate, +} from './schema'; + +/** + * Surface consommée par l'application. Deux implémentations : HTTP (back-end + * réel) et mémoire (jeu de démonstration). Les écrans ne dépendent que d'ici. + */ +export interface PlanningApi { + volunteers: { + list(editionId: string): Promise; + get(editionId: string, id: string): Promise; + create(editionId: string, body: VolunteerCreate): Promise; + update(editionId: string, id: string, body: VolunteerUpdate): Promise; + remove(editionId: string, id: string): Promise; + }; + positions: { + list(editionId: string): Promise; + get(editionId: string, id: string): Promise; + create(editionId: string, body: PositionCreate): Promise; + update(editionId: string, id: string, body: PositionUpdate): Promise; + remove(editionId: string, id: string): Promise; + }; + shifts: { + list(editionId: string, params?: { positionId?: string }): Promise; + get(editionId: string, id: string): Promise; + create(editionId: string, body: ShiftCreate): Promise; + update(editionId: string, id: string, body: ShiftUpdate): Promise; + remove(editionId: string, id: string): Promise; + }; + assignments: { + list( + editionId: string, + params?: { volunteerId?: string; shiftId?: string }, + ): Promise; + get(editionId: string, id: string): Promise; + create(editionId: string, body: AssignmentCreate): Promise; + update(editionId: string, id: string, body: AssignmentUpdate): Promise; + remove(editionId: string, id: string): Promise; + validate( + editionId: string, + body: AssignmentValidateRequest, + ): Promise; + }; + matrix: { + get(editionId: string): Promise; + }; +} + +/** Erreur normalisée : le back-end répond `{ detail: { code, violations? } }`. */ +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly code: string, + public readonly violations: Violation[] = [], + ) { + super(code); + this.name = 'ApiError'; + } +} diff --git a/frontend/src/app/AppShell.tsx b/frontend/src/app/AppShell.tsx index a3fc7db..75f08ad 100644 --- a/frontend/src/app/AppShell.tsx +++ b/frontend/src/app/AppShell.tsx @@ -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 (
- {state.config.festivalName} + {t('header.title')}
- {t('header.meta', { count: state.volunteers.length })} + {t('header.meta', { count: volunteers.data?.length ?? 0 })}
@@ -58,15 +59,16 @@ export function AppShell() {
- {hasDraft ? ( + {API_MODE === 'mock' ? ( +
+ {t('header.mockMode')} +
+ ) : null} + {proposedCount > 0 ? (
- {t('header.draft', { count: diff.length })} + {t('header.proposed', { count: proposedCount })}
- ) : ( -
- {t('header.published')} -
- )} + ) : null}
MB
diff --git a/frontend/src/app/planning-store.tsx b/frontend/src/app/planning-store.tsx deleted file mode 100644 index 366bea1..0000000 --- a/frontend/src/app/planning-store.tsx +++ /dev/null @@ -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; - 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 } - | { 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(); - 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; -} - -const PlanningContext = createContext(null); - -export function PlanningProvider({ children }: { children: ReactNode }) { - const [state, dispatch] = useReducer(reducer, undefined, initState); - const value = useMemo(() => ({ state, dispatch }), [state]); - return {children}; -} - -// 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 '); - return ctx; -} diff --git a/frontend/src/components/state.tsx b/frontend/src/components/state.tsx new file mode 100644 index 0000000..8e63103 --- /dev/null +++ b/frontend/src/components/state.tsx @@ -0,0 +1,29 @@ +import { ApiError } from '@/api'; +import { t } from '@/i18n'; + +export function Loading() { + return ( +
+ + {t('app.loading')} +
+ ); +} + +export function ErrorNote({ error }: { error: unknown }) { + const code = + error instanceof ApiError + ? error.code + : error instanceof Error + ? error.message + : 'error.generic'; + const known = code.startsWith('error.') || code.includes('.'); + return ( +
+ {t('app.error.generic')} + + {known ? t(code) : code} + +
+ ); +} diff --git a/frontend/src/domain/festival.ts b/frontend/src/domain/festival.ts deleted file mode 100644 index 60c02a4..0000000 --- a/frontend/src/domain/festival.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { DayPart, FestivalDay, PositionCategory } from './types'; - -// Découpage de journée du jeu de démonstration : 4 plages, la dernière passe -// minuit (22 h → 02 h). Repris tel quel du design. -export const DAY_PARTS: DayPart[] = [ - { code: 'M', label: '08–12', startHour: 8, endHour: 12, order: 0 }, - { code: 'A', label: '12–17', startHour: 12, endHour: 17, order: 1 }, - { code: 'S', label: '17–22', startHour: 17, endHour: 22, order: 2 }, - { code: 'N', label: '22–02', startHour: 22, endHour: 26, order: 3 }, -]; - -export const FESTIVAL_DAYS: FestivalDay[] = [ - { id: 'mer', index: 0, shortLabel: 'MER 15', tag: 'MONTAGE', longLabel: 'Mercredi 15' }, - { id: 'jeu', index: 1, shortLabel: 'JEU 16', tag: 'JOUR 1', longLabel: 'Jeudi 16' }, - { id: 'ven', index: 2, shortLabel: 'VEN 17', tag: 'JOUR 2', longLabel: 'Vendredi 17' }, - { id: 'sam', index: 3, shortLabel: 'SAM 18', tag: 'DÉMONTAGE', longLabel: 'Samedi 18' }, -]; - -/** Ordre d'affichage des catégories de poste dans le jeu de démonstration. */ -export const DEMO_CATEGORIES: PositionCategory[] = [ - 'BAR', - 'TICKETING', - 'WELCOME', - 'PARKING', - 'CLEANING', - 'CATERING', - 'SETUP', - 'STAGE', -]; - -export function dayPart(code: string): DayPart { - const found = DAY_PARTS.find((d) => d.code === code); - if (!found) throw new Error(`Plage inconnue : ${code}`); - return found; -} - -export function festivalDay(id: string): FestivalDay { - const found = FESTIVAL_DAYS.find((d) => d.id === id); - if (!found) throw new Error(`Journée inconnue : ${id}`); - return found; -} - -/** Durée d'une plage en heures. */ -export function dayPartHours(code: string): number { - const d = dayPart(code); - return d.endHour - d.startHour; -} - -/** - * Fenêtre absolue d'une (journée, plage) en heures depuis le début du festival, - * pour comparer chevauchements et repos. Reprend `abs()` du design. - */ -export function absoluteWindow(dayId: string, dayPartCode: string): { start: number; end: number } { - const day = festivalDay(dayId); - const part = dayPart(dayPartCode); - return { start: day.index * 24 + part.startHour, end: day.index * 24 + part.endHour }; -} - -export function categoryLabelKey(category: PositionCategory): string { - return `position.category.${category.toLowerCase()}`; -} - -export function slotKey(positionId: string, dayId: string, dayPartCode: string): string { - return `${positionId}|${dayId}|${dayPartCode}`; -} - -export function availabilityKey(dayId: string, dayPartCode: string): string { - return `${dayId}.${dayPartCode}`; -} diff --git a/frontend/src/domain/publication.ts b/frontend/src/domain/publication.ts deleted file mode 100644 index 37e66dd..0000000 --- a/frontend/src/domain/publication.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { PlanningState } from '@/app/planning-store'; - -import { DAY_PARTS, FESTIVAL_DAYS } from './festival'; - -export interface DiffEntry { - kind: 'ADD' | 'REMOVE'; - volunteerName: string; - positionName: string; - zone: string; - dayShortLabel: string; - dayPartLabel: string; -} - -/** - * Différentiel entre le brouillon et la dernière version publiée : ajouts non - * publiés (`pending`) et retraits mémorisés. Trié par nom de bénévole. - */ -export function buildDiff(state: PlanningState): DiffEntry[] { - const added: DiffEntry[] = state.assignments - .filter((a) => a.pending) - .map((a) => { - const position = state.positions.find((p) => p.id === a.positionId); - const volunteer = state.volunteers.find((v) => v.id === a.volunteerId); - const day = FESTIVAL_DAYS.find((d) => d.id === a.dayId); - const part = DAY_PARTS.find((d) => d.code === a.dayPartCode); - return { - kind: 'ADD' as const, - volunteerName: volunteer?.fullName ?? a.volunteerId, - positionName: position?.name ?? a.positionId, - zone: position?.zone ?? '', - dayShortLabel: day?.shortLabel ?? a.dayId, - dayPartLabel: part?.label ?? a.dayPartCode, - }; - }); - - const removed: DiffEntry[] = state.removed.map((r) => ({ - kind: 'REMOVE' as const, - volunteerName: r.volunteerName, - positionName: r.positionName, - zone: r.zone, - dayShortLabel: r.dayShortLabel, - dayPartLabel: r.dayPartLabel, - })); - - return [...added, ...removed].sort((a, b) => a.volunteerName.localeCompare(b.volunteerName, 'fr')); -} - -export function countConcernedVolunteers(diff: DiffEntry[]): number { - return new Set(diff.map((d) => d.volunteerName)).size; -} diff --git a/frontend/src/domain/rules.ts b/frontend/src/domain/rules.ts deleted file mode 100644 index d66cbd9..0000000 --- a/frontend/src/domain/rules.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { absoluteWindow, dayPartHours, festivalDay } from './festival'; -import type { Assignment, Position, Volunteer } from './types'; - -// Portage frontend du moteur de validation (CLAUDE.md §3). Même contrat que le -// backend : chaque violation est un code + des paramètres structurés, jamais une -// phrase. La traduction se fait via i18n (`validation.`). - -export type ViolationSeverity = 'BLOCKING' | 'WARNING'; - -export interface Violation { - code: 'SHIFT_OVERLAP' | 'OUTSIDE_AVAILABILITY' | 'DAILY_HOURS_EXCEEDED' | 'INSUFFICIENT_REST' | 'MISSING_REQUIRED_SKILL'; - severity: ViolationSeverity; - params: Record; -} - -/** Seuils paramétrables (proviennent de l'édition). */ -export interface RuleThresholds { - maxDailyHours: number; - minRestHours: number; -} - -interface EvaluateInput { - volunteer: Volunteer; - /** Poste + (journée, plage) visés par l'affectation envisagée. */ - target: { position: Position; dayId: string; dayPartCode: string }; - /** Affectations actives du bénévole (PROPOSED + CONFIRMED), hors cible. */ - currentAssignments: Assignment[]; - positionsById: Map; - thresholds: RuleThresholds; -} - -/** - * Évalue l'affectation d'un bénévole sur un créneau. Renvoie la liste des - * violations : une seule `BLOCKING` suffit à interdire l'enregistrement. - */ -export function evaluateAssignment(input: EvaluateInput): Violation[] { - const { volunteer, target, currentAssignments, positionsById, thresholds } = input; - const { dayId, dayPartCode } = target; - const violations: Violation[] = []; - - const targetHours = dayPartHours(dayPartCode); - const targetWindow = absoluteWindow(dayId, dayPartCode); - - // SHIFT_OVERLAP — deux créneaux qui se chevauchent (créneaux jointifs exclus). - for (const other of currentAssignments) { - const otherWindow = absoluteWindow(other.dayId, other.dayPartCode); - const overlaps = otherWindow.start < targetWindow.end && targetWindow.start < otherWindow.end; - if (overlaps) { - const otherPosition = positionsById.get(other.positionId); - violations.push({ - code: 'SHIFT_OVERLAP', - severity: 'BLOCKING', - params: { - position: otherPosition ? `${otherPosition.name} ${otherPosition.zone}` : other.positionId, - }, - }); - break; - } - } - - // OUTSIDE_AVAILABILITY — plage non déclarée disponible. - if (!volunteer.availability.has(`${dayId}.${dayPartCode}`)) { - violations.push({ code: 'OUTSIDE_AVAILABILITY', severity: 'WARNING', params: {} }); - } - - // DAILY_HOURS_EXCEEDED — > seuil d'heures cumulées sur la journée. - const sameDayHours = - currentAssignments - .filter((a) => a.dayId === dayId) - .reduce((sum, a) => sum + dayPartHours(a.dayPartCode), 0) + targetHours; - if (sameDayHours > thresholds.maxDailyHours) { - violations.push({ - code: 'DAILY_HOURS_EXCEEDED', - severity: 'WARNING', - params: { - max: thresholds.maxDailyHours, - total: sameDayHours, - day: festivalDay(dayId).longLabel.toLowerCase(), - }, - }); - } - - // INSUFFICIENT_REST — < seuil d'heures entre deux créneaux (strict). - let minRest = Number.POSITIVE_INFINITY; - for (const other of currentAssignments) { - const otherWindow = absoluteWindow(other.dayId, other.dayPartCode); - let gap = 0; - if (otherWindow.start >= targetWindow.end) gap = otherWindow.start - targetWindow.end; - else if (targetWindow.start >= otherWindow.end) gap = targetWindow.start - otherWindow.end; - if (gap < minRest) minRest = gap; - } - if (minRest > 0 && minRest < thresholds.minRestHours) { - violations.push({ - code: 'INSUFFICIENT_REST', - severity: 'WARNING', - params: { min: thresholds.minRestHours, rest: minRest }, - }); - } - - return violations; -} - -export function hasBlocking(violations: Violation[]): boolean { - return violations.some((v) => v.severity === 'BLOCKING'); -} diff --git a/frontend/src/domain/selectors.ts b/frontend/src/domain/selectors.ts deleted file mode 100644 index 38aadbc..0000000 --- a/frontend/src/domain/selectors.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { absoluteWindow, dayPartHours, slotKey } from './festival'; -import type { Assignment, EditionConfig, Volunteer } from './types'; - -/** Statuts pris en compte pour chevauchement / cumul / repos (CLAUDE.md §3). */ -export function isActive(assignment: Assignment): boolean { - return assignment.status === 'PROPOSED' || assignment.status === 'CONFIRMED'; -} - -export interface PlanningIndex { - /** Affectations actives par créneau (clé `position|day|daypart`). */ - bySlot: Map; - /** Affectations actives par bénévole. */ - byVolunteer: Map; - /** Heures actives cumulées par bénévole. */ - totalHours: Map; - /** Heures actives par (bénévole, journée) — clé `volunteerId|dayId`. */ - dailyHours: Map; -} - -export function indexPlanning(assignments: Assignment[], volunteers: Volunteer[]): PlanningIndex { - const bySlot = new Map(); - const byVolunteer = new Map(); - const totalHours = new Map(); - const dailyHours = new Map(); - - volunteers.forEach((v) => { - byVolunteer.set(v.id, []); - totalHours.set(v.id, 0); - }); - - assignments.forEach((assignment) => { - if (!isActive(assignment)) return; - const key = slotKey(assignment.positionId, assignment.dayId, assignment.dayPartCode); - const slotList = bySlot.get(key) ?? []; - slotList.push(assignment); - bySlot.set(key, slotList); - - const volList = byVolunteer.get(assignment.volunteerId) ?? []; - volList.push(assignment); - byVolunteer.set(assignment.volunteerId, volList); - - const hours = dayPartHours(assignment.dayPartCode); - totalHours.set(assignment.volunteerId, (totalHours.get(assignment.volunteerId) ?? 0) + hours); - const dayKey = `${assignment.volunteerId}|${assignment.dayId}`; - dailyHours.set(dayKey, (dailyHours.get(dayKey) ?? 0) + hours); - }); - - return { bySlot, byVolunteer, totalHours, dailyHours }; -} - -/** - * Un bénévole est « en surcharge » s'il dépasse le max journalier ou s'il a - * moins de repos minimum entre deux créneaux (mêmes seuils que le moteur). - */ -export function isOverloaded( - volunteer: Volunteer, - index: PlanningIndex, - config: Pick, -): boolean { - for (const [key, hours] of index.dailyHours) { - if (key.startsWith(`${volunteer.id}|`) && hours > config.maxDailyHours) return true; - } - const windows = (index.byVolunteer.get(volunteer.id) ?? []) - .map((a) => absoluteWindow(a.dayId, a.dayPartCode)) - .sort((a, b) => a.start - b.start); - for (let i = 1; i < windows.length; i += 1) { - const gap = windows[i].start - windows[i - 1].end; - if (gap > 0 && gap < config.minRestHours) return true; - } - return false; -} diff --git a/frontend/src/domain/types.ts b/frontend/src/domain/types.ts deleted file mode 100644 index fd18e4a..0000000 --- a/frontend/src/domain/types.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Modèle de domaine côté frontend. Vocabulaire aligné sur CLAUDE.md : -// code et types en anglais. Les libellés d'interface passent par i18n ; les -// libellés *de données* (nom d'un poste, d'une plage, d'une journée) sont -// portés par l'entité elle-même car ils viendront de l'API / de l'édition. - -export type PositionCategory = - | 'BAR' - | 'TICKETING' - | 'WELCOME' - | 'PARKING' - | 'CLEANING' - | 'CATERING' - | 'SETUP' - | 'STAGE' - | 'OTHER'; - -export type AssignmentStatus = 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLED'; - -export type AssignmentSource = 'SOLVER' | 'MANUAL'; - -export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER'; - -/** Tri-état d'une disponibilité déclarée. */ -export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE'; - -/** Découpage d'une journée, propre à l'édition (paramétrable). */ -export interface DayPart { - /** Code court stable (M/A/S/N dans le jeu de démonstration). */ - code: string; - label: string; - /** Heure locale de début (0–23). */ - startHour: number; - /** Heure de fin ; > 24 signifie « passe minuit » (ex. 26 = 02 h). */ - endHour: number; - order: number; -} - -/** Une journée du festival (montage et démontage inclus). */ -export interface FestivalDay { - id: string; - index: number; - shortLabel: string; - tag: string; - longLabel: string; -} - -/** Besoin d'effectif sur un poste pour une (journée, plage). */ -export interface StaffingNeed { - dayId: string; - dayPartCode: string; - minStaff: number; - idealStaff: number; -} - -export interface Position { - id: string; - name: string; - zone: string; - location: string; - category: PositionCategory; - needs: StaffingNeed[]; -} - -export interface Volunteer { - id: string; - firstName: string; - lastName: string; - fullName: string; - initials: string; - phone: string; - /** Clés « dayId.dayPartCode » déclarées disponibles. */ - availability: Set; - preferredCategories: PositionCategory[]; - /** Inscrit mais jamais affecté sur les 4 jours. */ - neverAssigned?: boolean; - /** Ajouté via un import (encore à affecter). */ - imported?: boolean; -} - -export interface Assignment { - id: string; - volunteerId: string; - positionId: string; - dayId: string; - dayPartCode: string; - status: AssignmentStatus; - source: AssignmentSource; - locked: boolean; - /** Modification non encore publiée (« ajout » dans le différentiel). */ - pending: 'ADD' | null; -} - -/** Retrait non publié, mémorisé pour le différentiel de publication. */ -export interface RemovedAssignment { - volunteerName: string; - positionName: string; - zone: string; - dayShortLabel: string; - dayPartLabel: string; -} - -/** Paramètres d'édition modifiables depuis l'écran Configuration. */ -export interface EditionConfig { - festivalName: string; - datesLabel: string; - maxDailyHours: number; - minRestHours: number; - autoWarn: boolean; - mailFrom: string; - mailReplyTo: string; - mailSubject: string; - mailBody: string; - reminderEnabled: boolean; - reminderHours: number; - magicLinkDays: number; - smsEnabled: boolean; -} diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index b96c938..331f8a9 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -3,19 +3,30 @@ "legend.title": "LÉGENDE", "action.close": "Fermer", "action.remove": "Retirer", + "action.add": "Ajouter", - "unit.hours": "{count} h", - "unit.days": "{count} j", - "time.now": "à l’instant", - "time.yesterday": "hier 18:42", + "app.loading": "Chargement…", + "app.error.generic": "Une erreur est survenue", + "error.generic": "Erreur inattendue", + "error.not_found": "Ressource introuvable", + "error.forbidden": "Accès refusé", + "error.unauthorized": "Authentification requise", + "error.validation_failed": "La validation a échoué (règle bloquante)", + "assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau", + "assignment.wrong_edition": "Bénévole ou créneau hors de cette édition", + "volunteer.duplicate_email": "Un bénévole avec cet email existe déjà", + "shift.ends_not_after_starts": "La fin doit être après le début", + "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition", + + "header.title": "GESTION DES BÉNÉVOLES", "header.meta": "BÉNÉVOLAT · {count} INSCRITS", - "header.draft": "BROUILLON · {count} MODIF.", - "header.published": "✓ TOUT EST PUBLIÉ", + "header.mockMode": "DONNÉES DE DÉMO", + "header.proposed": "{count} PROPOSITION(S)", "tab.grille": "Grille des besoins", "tab.volunteers": "Vue par bénévole", - "tab.publication": "Publication", + "tab.publication": "Confirmations", "tab.mobileVolunteer": "Bénévole · mobile", "tab.mobileLead": "Référent · mobile", "tab.config": "Configuration", @@ -35,38 +46,42 @@ "position.category.stage": "Régie", "position.category.other": "Autre", - "validation.SHIFT_OVERLAP": "Déjà affecté sur {position} au même créneau", + "validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau", + "validation.SHIFT_OVERLAP.at": "Déjà affecté sur {position} au même moment", + "validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent.", "validation.OUTSIDE_AVAILABILITY": "Hors disponibilités déclarées", - "validation.DAILY_HOURS_EXCEEDED": "Plus de {max} h le {day} ({total} h)", - "validation.INSUFFICIENT_REST": "Moins de {min} h entre deux créneaux ({rest} h)", + "validation.OUTSIDE_AVAILABILITY.unavailable": "Déclaré indisponible sur ce créneau", + "validation.OUTSIDE_AVAILABILITY.rule": "Une demi-journée du créneau est indisponible ou sans déclaration.", + "validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h sur la journée ({hours})", + "validation.DAILY_HOURS_EXCEEDED.rule": "Plus de 8 h 00 d'affectations cumulées sur une même journée.", + "validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux ({rest})", + "validation.INSUFFICIENT_REST.rule": "Moins de 8 h 00 entre la fin d'un créneau et le début du suivant.", "validation.MISSING_REQUIRED_SKILL": "Compétence requise manquante : {skill}", + "validation.MISSING_REQUIRED_SKILL.rule": "Le bénévole n'a pas une compétence requise par le créneau.", "grille.filter.day": "JOUR", "grille.filter.position": "POSTE", - "grille.legend.title": "LÉGENDE", "grille.legend.critical": "SOUS-EFFECTIF CRITIQUE", "grille.legend.incomplete": "INCOMPLET", "grille.legend.complete": "COMPLET", - "grille.legend.unpublished": "NON PUBLIÉ", "grille.positionsCount": "{count} POSTES", - "grille.assignedRequired": "AFFECTÉS / REQUIS", - "grille.footnote": "Colonnes = plages de la journée (08–12 / 12–17 / 17–22 / 22–02). Cliquer une cellule ouvre le panneau d’affectation à droite.", + "grille.assignedRequired": "CONFIRMÉS / IDÉAL", + "grille.footnote": "Chaque cellule est un créneau. Nombre = confirmés, + = proposés, / = effectif idéal. Cliquer ouvre le panneau d'affectation à droite.", "grille.panel.empty.title": "Cliquez une cellule de la grille pour affecter des bénévoles.", - "grille.panel.empty.body": "Le panneau s’ouvre ici, sans quitter la grille : candidats triés par pertinence, alertes bloquantes et avertissements.", + "grille.panel.empty.body": "Le panneau s'ouvre ici, sans quitter la grille : candidats triés par charge, alertes bloquantes et avertissements du moteur de validation.", "grille.panel.slotDuration": "CRÉNEAU DE {hours} H", - "grille.panel.assigned": "AFFECTÉS", + "grille.panel.confirmed": "CONFIRMÉS", + "grille.panel.proposedCount": "PROPOSÉS {count}", "grille.panel.min": "MINIMUM {count}", "grille.panel.ideal": "IDÉAL {count}", "grille.panel.assignmentsTitle": "AFFECTATIONS ({count})", - "grille.panel.noneAssigned": "Personne n’est encore affecté sur ce créneau.", + "grille.panel.noneAssigned": "Personne n'est encore affecté sur ce créneau.", "grille.panel.hoursTotal": "{hours} H AU TOTAL", "grille.panel.candidates": "CANDIDATS", - "grille.panel.candidatesHint": "TRIÉS PAR PERTINENCE", - "grille.panel.hoursAssigned": "{hours} H DÉJÀ AFFECTÉES · {slots} CRÉNEAUX", + "grille.panel.candidatesHint": "LES MOINS CHARGÉS D'ABORD", + "grille.panel.hoursAssigned": "{hours} H · {slots} CRÉNEAUX", "grille.panel.assign": "AFFECTER", "grille.panel.blocked": "BLOQUÉ", - "grille.panel.availableTag": "✓ Disponible sur ce créneau", - "grille.panel.wantedTag": "★ A demandé ce poste", "grille.panel.blockingPrefix": "BLOQUANT — {reason}", "grille.panel.warningsTitle": "⚠ AVERTISSEMENTS — AFFECTATION POSSIBLE", @@ -76,36 +91,26 @@ "volunteers.filter.unassigned": "SANS AFFECTATION", "volunteers.legend.confirmed": "AFFECTATION CONFIRMÉE", "volunteers.legend.proposed": "AFFECTATION PROPOSÉE", - "volunteers.legend.availability": "DISPONIBILITÉ DÉCLARÉE (NON AFFECTÉ)", "volunteers.count": "{count} BÉNÉVOLES", "volunteers.totalHours": "TOTAL H", "volunteers.name": "NOM", "volunteers.cumulative": "CUMUL", "volunteers.badge.overloaded": "▲ SURCHARGE", "volunteers.badge.unassigned": "○ OUBLIÉ", - "volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l’ensemble de l’équipe.", - "volunteers.footnote": "Surcharge = plus de {maxHours} h sur une même journée ou moins de {restHours} h entre deux créneaux. Oublié = aucune affectation sur les 4 jours.", + "volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.", + "volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation active.", - "publication.stat.changes": "MODIFICATIONS NON PUBLIÉES", + "publication.stat.proposed": "AFFECTATIONS PROPOSÉES", "publication.stat.concerned": "BÉNÉVOLES CONCERNÉS", - "publication.stat.version": "VERSION PUBLIÉE · {when}", - "publication.draftTitle": "BROUILLON EN COURS D’ÉDITION", - "publication.diffWith": "DIFFÉRENTIEL AVEC LA v{version}", - "publication.add": "+ AJOUT", - "publication.remove": "− RETRAIT", - "publication.noDiff": "Le brouillon est identique à la version publiée. Chaque bénévole a reçu son planning à jour.", - "publication.mailPreviewTitle": "APERÇU DU MESSAGE ENVOYÉ", - "publication.mailFrom": "DE : {from}", + "publication.stat.confirmed": "AFFECTATIONS CONFIRMÉES", + "publication.toConfirmTitle": "EN ATTENTE DE CONFIRMATION", + "publication.allConfirmed": "Aucune affectation en attente : tout est confirmé.", + "publication.mailPreviewTitle": "APERÇU DU MESSAGE (MAQUETTE)", "publication.mailTo": "À : {count} bénévoles concernés", - "publication.mailSubjectPrefix": "Objet — {subject}", - "publication.mailGreeting": "Salut {name},", - "publication.mailBody": "Ton planning pour le festival vient d’être mis à jour. Tu as désormais {slots} créneaux, soit {hours} heures.", - "publication.mailBody2": "Merci de confirmer chaque créneau depuis ton planning personnel : ton référent de poste s’appuie dessus pour le pointage sur le site.", + "publication.mailSubject": "Objet — Ton planning bénévole a été mis à jour", + "publication.mailBody": "Ton planning pour le festival vient d'être mis à jour. Merci de confirmer chaque créneau depuis ton planning personnel.", "publication.mailCta": "VOIR MON PLANNING →", - "publication.magicLinkNote": "Lien magique valable {days} jours, aucun mot de passe.", - "publication.publishButton": "PUBLIER ET ENVOYER À {count} BÉNÉVOLES", - "publication.publishNote": "La publication fige le brouillon en version v{version}. Les affectations restent « proposées » jusqu’à confirmation par chaque bénévole.", - "publication.publishedState": "✓ PUBLIÉ — VERSION v{version} ENVOYÉE {when}", + "publication.outOfScope": "La publication versionnée et l'envoi d'emails / SMS sont hors périmètre du premier incrément (CLAUDE.md §4). Cet écran liste les affectations « proposées » que les bénévoles n'ont pas encore confirmées.", "design.noteTitle": "CHOIX DE CONCEPTION", @@ -116,108 +121,93 @@ "mobileVol.hours": "{count} HEURES", "mobileVol.toConfirm": "⚠ {count} CRÉNEAU(X) À CONFIRMER", "mobileVol.location": "📍 {location}", - "mobileVol.lead": "RÉFÉRENT · {name}", - "mobileVol.call": "📞 {phone}", - "mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ", "mobileVol.confirm": "JE CONFIRME", - "mobileVol.orgMessageTitle": "MESSAGE DE L’ORGANISATION", - "mobileVol.orgMessageBody": "Le briefing bénévoles a lieu jeudi 11h sous le chapiteau nord. Récupération des t-shirts et des badges à l’accueil bénévoles dès mercredi 14h.", - "mobileVol.orgMessageMeta": "COORDINATION · MARIE B. · HIER 18:42", - "mobileVol.designNote": "Lisibilité plein soleil : fonds opaques, texte noir sur jaune ou blanc, aucun gris clair, aucune ombre portée sur du texte. Cibles tactiles à 52–62 px de haut. Une seule action par créneau, hiérarchie en trois niveaux : jour + horaire, poste, référent. Le téléphone du référent est un lien d’appel direct.", + "mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ", + "mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.", + "mobileVol.designNote": "Lisibilité plein soleil : fonds opaques, texte noir sur jaune ou blanc, aucun gris clair. Cibles tactiles à 52–62 px de haut. Une seule action par créneau. Les données proviennent des affectations réelles de l'API.", "mobileLead.statusTime": "18:07", - "mobileLead.kicker": "RÉFÉRENT DE POSTE · {name}", - "mobileLead.shiftHours": "17:00 → 22:00", - "mobileLead.inProgress": "EN COURS · {hours}", - "mobileLead.present": "{present}/{total} PRÉSENTS", + "mobileLead.kicker": "RÉFÉRENT DE POSTE", + "mobileLead.inProgress": "CRÉNEAU · {hours}", + "mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS", "mobileLead.absenceAlert": "⚠ {count} ABSENCE(S) SUR LE CRÉNEAU", "mobileLead.requestBackup": "DEMANDER UN RENFORT À LA COORDINATION", - "mobileLead.teamTitle": "POINTAGE DE L’ÉQUIPE", - "mobileLead.statusConfirmed": "Confirmée", - "mobileLead.statusProposed": "Proposée · non confirmée", + "mobileLead.teamTitle": "POINTAGE DE L'ÉQUIPE", "mobileLead.badgePresent": "✓ PRÉSENT", "mobileLead.badgeAbsent": "✕ ABSENT", "mobileLead.markPresent": "PRÉSENT", "mobileLead.markAbsent": "ABSENT", - "mobileLead.nextSlot": "Créneau suivant · 22:00 → 02:00 · 4 bénévoles attendus", - "mobileLead.designNote": "Le référent ne voit que son poste et le créneau en cours — aucune navigation, aucun filtre. Le pointage est un geste unique par personne, deux boutons pleine largeur de 56 px. Toute absence remonte immédiatement en haut de l’écran avec une action unique vers la coordination.", + "mobileLead.emptyTeam": "Aucun bénévole affecté à ce créneau.", + "mobileLead.designNote": "Le référent ne voit que son poste et un créneau. Le pointage est un geste unique par personne. Il est local à cette maquette : le pointage sur site est hors périmètre (CLAUDE.md §4).", "config.admin": "ADMINISTRATION", "config.reserved": "RÉSERVÉ À LA COORDINATION", - "config.tab.grid": "GRILLE DES BESOINS", + "config.tab.grid": "POSTES & CRÉNEAUX", "config.tab.volunteers": "BÉNÉVOLES & IMPORT", - "config.tab.mail": "MESSAGES & ENVOIS", - "config.tab.rules": "RÈGLES DU FESTIVAL", + "config.tab.mail": "MESSAGES (MAQUETTE)", + "config.tab.rules": "RÈGLES & ÉDITION", "config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX", "config.grid.slotsCount": "{count} créneaux", "config.grid.addPosition": "+ AJOUTER UN POSTE", - "config.grid.minTotal": "BESOIN MINIMUM · TOUT LE FESTIVAL", - "config.grid.idealTotal": "BESOIN IDÉAL", + "config.grid.addShift": "+ AJOUTER UN CRÉNEAU (12–17)", + "config.grid.newPositionName": "Nouveau poste", + "config.grid.minTotal": "BESOIN MINIMUM · TOTAL", + "config.grid.idealTotal": "BESOIN IDÉAL · TOTAL", "config.grid.min": "MIN", "config.grid.ideal": "IDÉAL", - "config.grid.closed": "POSTE FERMÉ", - "config.grid.open": "+ OUVRIR", - "config.grid.note": "Toute modification est répercutée immédiatement sur la grille des besoins (onglet 01) : baisser un effectif idéal peut faire passer une cellule de « incomplet » à « complet ».", + "config.grid.noShifts": "Aucun créneau sur ce poste. Ajoutez-en un pour qu'il apparaisse dans la grille.", + "config.grid.note": "Les modifications d'effectif (min / idéal) et les créneaux sont enregistrés immédiatement via l'API et se répercutent sur la grille des besoins (onglet 01).", "config.vol.importTitle": "IMPORTER DES INSCRIPTIONS", - "config.vol.importHint": "Glissez un export CSV du formulaire · colonnes reconnues : nom, email, téléphone, postes souhaités, disponibilités", - "config.vol.importButton": "SIMULER UN IMPORT", + "config.vol.importHint": "L'import de fichier CSV / Excel se fait via l'endpoint dédié. Ce bouton crée 4 bénévoles d'exemple via l'API.", + "config.vol.importButton": "CRÉER 4 EXEMPLES", "config.vol.profiles": "PROFILS ENREGISTRÉS", - "config.vol.imported": "IMPORTÉS · À AFFECTER", "config.vol.colName": "NOM", "config.vol.colPhone": "TÉLÉPHONE", - "config.vol.colWanted": "POSTES SOUHAITÉS", - "config.vol.colAvailability": "DISPONIBILITÉS", - "config.vol.new": "NOUVEAU", - "config.vol.slotsDeclared": "{count} plages", + "config.vol.colEmail": "EMAIL", + "config.vol.colSkills": "COMPÉTENCES", "config.vol.ficheKicker": "FICHE BÉNÉVOLE", - "config.vol.fullName": "NOM COMPLET", + "config.vol.firstName": "PRÉNOM", + "config.vol.lastName": "NOM", "config.vol.phone": "TÉLÉPHONE", "config.vol.email": "EMAIL (LIEN MAGIQUE)", - "config.vol.wanted": "POSTES SOUHAITÉS", - "config.vol.hoursAssigned": "DÉJÀ AFFECTÉES", - "config.vol.availabilityDeclared": "DISPONIBILITÉS DÉCLARÉES", - "config.vol.availabilityValue": "{count} plages déclarées sur 16", - "config.vol.note": "Les disponibilités proviennent du formulaire d’inscription : la coordination peut les corriger mais jamais les confondre avec une affectation.", + "config.vol.tshirt": "TAILLE DE T-SHIRT", + "config.vol.skills": "COMPÉTENCES", + "config.vol.noSkills": "Aucune compétence déclarée", + "config.vol.skillPlaceholder": "Code (ex. CAISSE)", + "config.vol.delete": "SUPPRIMER (RGPD — EFFACEMENT COMPLET)", + "config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?", + "config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade (RGPD).", + "config.mail.draftBanner": "Écran maquette : l'envoi d'emails / SMS est hors périmètre. Ces réglages ne sont pas enregistrés.", "config.mail.templateTitle": "MODÈLE DE MESSAGE DE PUBLICATION", "config.mail.from": "EXPÉDITEUR", "config.mail.replyTo": "RÉPONDRE À", "config.mail.subject": "OBJET", "config.mail.body": "CORPS DU MESSAGE", "config.mail.variables": "VARIABLES DISPONIBLES", - "config.mail.sendsTitle": "ENVOIS ET RELANCES", - "config.mail.reminderToggle": "Relance automatique des créneaux non confirmés", - "config.mail.on": "ACTIVÉE", - "config.mail.off": "DÉSACTIVÉE", - "config.mail.reminderDelay": "Délai avant relance", - "config.mail.magicLinkValidity": "Validité du lien magique", - "config.mail.smsToggle": "Doubler par SMS le jour J", - "config.mail.smsCost": "Coût estimé : 0,05 € par bénévole", - "config.mail.onM": "ACTIVÉ", - "config.mail.offM": "DÉSACTIVÉ", - "config.mail.previewTitle": "APERÇU AVEC DONNÉES RÉELLES", + "config.mail.previewTitle": "APERÇU", "config.mail.previewFrom": "DE : {from}", "config.mail.cta": "VOIR MON PLANNING →", - "config.rules.editionTitle": "ÉDITION", - "config.rules.festivalName": "NOM DU FESTIVAL", - "config.rules.dates": "DATES COUVERTES", - "config.rules.datesNote": "Les colonnes de la grille sont générées à partir de ces dates : 4 journées × 4 plages (08–12 / 12–17 / 17–22 / 22–02).", - "config.rules.thresholdsTitle": "SEUILS D’ALERTE", - "config.rules.maxDaily": "Maximum d’heures par journée", - "config.rules.maxDailyNote": "Avertissement non bloquant lors de l’affectation", + "config.rules.editionTitle": "ÉDITION & CONNEXION", + "config.rules.editionId": "Identifiant d'édition", + "config.rules.apiMode": "Mode API", + "config.rules.volunteersCount": "Bénévoles", + "config.rules.positionsCount": "Postes", + "config.rules.shiftsCount": "Créneaux", + "config.rules.editionNote": "Il n'y a pas encore d'endpoint de configuration d'édition : nom, dates et rétention se règlent en base. En mode HTTP, VITE_EDITION_ID désigne l'édition.", + "config.rules.thresholdsTitle": "MOTEUR DE VALIDATION", + "config.rules.maxDaily": "Maximum d'heures par journée", "config.rules.minRest": "Repos minimum entre deux créneaux", - "config.rules.minRestNote": "Les créneaux enchaînés dans la même journée ne comptent pas", - "config.rules.overCount": "{count} bénévoles dépassent les seuils actuels — visibles dans la vue par bénévole.", - "config.rules.overlapNote": "Le double emploi au même créneau reste toujours bloquant : ce seuil n’est pas paramétrable.", - "config.rules.rolesTitle": "RÔLES ET ACCÈS", - "config.rules.roleCoordinator": "Coordination · 3 personnes", - "config.rules.roleCoordinatorDesc": "Accès complet, publication, configuration", - "config.rules.roleLead": "Référents de poste · 15 personnes", - "config.rules.roleLeadDesc": "Leur poste, leur créneau, pointage seulement", - "config.rules.roleVolunteer": "Bénévoles · {count} personnes", - "config.rules.roleVolunteerDesc": "Lien magique par email, sans mot de passe", - "config.rules.designateLead": "DÉSIGNER UN RÉFÉRENT DE POSTE" + "config.rules.thresholdsNote": "Seuils figés côté back-end (CLAUDE.md §3) : non paramétrables. Le double emploi au même créneau est toujours bloquant.", + "config.rules.rulesTitle": "RÈGLES APPLIQUÉES", + "config.rules.rolesTitle2": "RÔLES ET ACCÈS", + "config.rules.roleCoordinator": "Coordination", + "config.rules.roleCoordinatorDesc": "Accès complet, construit et publie les plannings", + "config.rules.roleLead": "Référent de poste", + "config.rules.roleLeadDesc": "Lecture sur l'édition, écriture sur ses postes", + "config.rules.roleVolunteer": "Bénévole", + "config.rules.roleVolunteerDesc": "Ses seules données, lien magique par email" } diff --git a/frontend/src/lib/env.ts b/frontend/src/lib/env.ts new file mode 100644 index 0000000..d07fc80 --- /dev/null +++ b/frontend/src/lib/env.ts @@ -0,0 +1,10 @@ +// Accès défensif à `import.meta.env` : sous Vite il est peuplé, mais les +// scripts Node (smoke, tests) chargent les modules sans, d'où le repli. +const raw = ((import.meta as { env?: Record }).env ?? {}); + +export const ENV = { + apiMode: (raw.VITE_API_MODE ?? 'mock') as 'mock' | 'http', + apiBaseUrl: raw.VITE_API_BASE_URL ?? '/api', + editionId: raw.VITE_EDITION_ID, + demoVolunteerId: raw.VITE_DEMO_VOLUNTEER_ID ?? '', +}; diff --git a/frontend/src/lib/planning.ts b/frontend/src/lib/planning.ts new file mode 100644 index 0000000..5fec709 --- /dev/null +++ b/frontend/src/lib/planning.ts @@ -0,0 +1,149 @@ +import type { + AssignmentRead, + MatrixResponse, + PositionCategory, + ShiftRead, + Violation, +} from '@/api/schema'; +import { ACTIVE_ASSIGNMENT_STATUSES } from '@/api/schema'; +import { t } from '@/i18n'; + +import { datePart, hoursBetween, naiveMinutes, timePart } from './time'; + +export function isActive(a: AssignmentRead): boolean { + return ACTIVE_ASSIGNMENT_STATUSES.includes(a.status); +} + +export function categoryLabel(category: PositionCategory): string { + return t(`position.category.${category.toLowerCase()}`); +} + +export function statusLabel(status: string): string { + return t(`assignment.status.${status.toLowerCase()}`); +} + +/** Colonne de la grille : une (date, heure de début) distincte. */ +export interface LatticeColumn { + key: string; // `YYYY-MM-DD|HH:MM` + date: string; + time: string; +} + +export interface LatticeDay { + date: string; + columns: LatticeColumn[]; +} + +/** Construit le treillis de colonnes à partir de créneaux (grille alignée). */ +export function buildLattice(starts: Array<{ starts_at: string }>): { + days: LatticeDay[]; + columns: LatticeColumn[]; +} { + const seen = new Map(); + for (const s of starts) { + const date = datePart(s.starts_at); + const time = timePart(s.starts_at); + const key = `${date}|${time}`; + if (!seen.has(key)) seen.set(key, { key, date, time }); + } + const columns = [...seen.values()].sort((a, b) => a.key.localeCompare(b.key)); + const days: LatticeDay[] = []; + for (const column of columns) { + let day = days.find((d) => d.date === column.date); + if (!day) { + day = { date: column.date, columns: [] }; + days.push(day); + } + day.columns.push(column); + } + return { days, columns }; +} + +export function latticeFromMatrix(matrix: MatrixResponse) { + return buildLattice(matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at })))); +} + +/** Heures actives cumulées par bénévole. */ +export function hoursByVolunteer( + assignments: AssignmentRead[], + shiftsById: Map>, +): Map { + const totals = new Map(); + for (const a of assignments) { + if (!isActive(a)) continue; + const shift = shiftsById.get(a.shift_id); + if (!shift) continue; + totals.set( + a.volunteer_id, + (totals.get(a.volunteer_id) ?? 0) + hoursBetween(shift.starts_at, shift.ends_at), + ); + } + return totals; +} + +/** Surcharge : > 8 h sur une journée civile ou < 8 h de repos entre créneaux. */ +export function isOverloaded( + volunteerId: string, + assignments: AssignmentRead[], + shiftsById: Map>, +): boolean { + const shifts = assignments + .filter((a) => a.volunteer_id === volunteerId && isActive(a)) + .map((a) => shiftsById.get(a.shift_id)) + .filter((s): s is ShiftRead => Boolean(s)) + .sort((a, b) => a.starts_at.localeCompare(b.starts_at)); + + const perDay = new Map(); + for (const s of shifts) { + const day = datePart(s.starts_at); + perDay.set(day, (perDay.get(day) ?? 0) + hoursBetween(s.starts_at, s.ends_at)); + } + for (const hours of perDay.values()) if (hours > 8) return true; + + for (let i = 1; i < shifts.length; i += 1) { + const gap = naiveMinutes(shifts[i].starts_at) - naiveMinutes(shifts[i - 1].ends_at); + if (gap > 0 && gap < 8 * 60) return true; + } + return false; +} + +/** + * Rend une violation en texte lisible. Les `params` suivent exactement le + * contrat du moteur back-end (`backend/app/validation/engine.py`). + */ +export function violationText( + violation: Violation, + resolvePosition?: (shiftId: string) => string | undefined, +): string { + const p = violation.params; + switch (violation.code) { + case 'SHIFT_OVERLAP': { + const where = resolvePosition?.(String(p.other_shift_id)); + return where + ? t('validation.SHIFT_OVERLAP.at', { position: where }) + : t('validation.SHIFT_OVERLAP'); + } + case 'OUTSIDE_AVAILABILITY': + return p.status === 'UNAVAILABLE' + ? t('validation.OUTSIDE_AVAILABILITY.unavailable') + : t('validation.OUTSIDE_AVAILABILITY'); + case 'DAILY_HOURS_EXCEEDED': + return t('validation.DAILY_HOURS_EXCEEDED', { + hours: formatMinutes(Number(p.total_minutes)), + }); + case 'INSUFFICIENT_REST': + return t('validation.INSUFFICIENT_REST', { + rest: formatMinutes(Number(p.gap_minutes)), + }); + case 'MISSING_REQUIRED_SKILL': + return t('validation.MISSING_REQUIRED_SKILL', { skill: String(p.skill_code) }); + default: + return violation.code; + } +} + +function formatMinutes(total: number): string { + const h = Math.floor(total / 60); + const m = total % 60; + return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`; +} diff --git a/frontend/src/lib/time.ts b/frontend/src/lib/time.ts new file mode 100644 index 0000000..2fac711 --- /dev/null +++ b/frontend/src/lib/time.ts @@ -0,0 +1,53 @@ +// Aides temps. Les datetimes de l'API sont **naïfs** (heure locale +// Europe/Paris, ISO sans fuseau) : on les manipule comme des chaînes / +// nombres, jamais via `Date` local du navigateur qui réintroduirait un fuseau. + +const ISO_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/; + +/** Minutes depuis l'epoch naïf (1970-01-01 00:00), sans fuseau. */ +export function naiveMinutes(iso: string): number { + const m = ISO_RE.exec(iso); + if (!m) throw new Error(`Datetime ISO invalide : ${iso}`); + const [, y, mo, d, h, mi] = m.map(Number); + return Date.UTC(y, mo - 1, d, h, mi) / 60000; +} + +/** Durée d'un créneau en heures (décimales). */ +export function hoursBetween(startIso: string, endIso: string): number { + return (naiveMinutes(endIso) - naiveMinutes(startIso)) / 60; +} + +/** Partie date `YYYY-MM-DD`. */ +export function datePart(iso: string): string { + return iso.slice(0, 10); +} + +/** Heure de début `HH:MM` (utile pour composer les colonnes de la grille). */ +export function timePart(iso: string): string { + return iso.slice(11, 16); +} + +const WEEKDAYS = ['dim.', 'lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.']; +const WEEKDAYS_LONG = ['Dimanche', 'Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi']; + +function weekdayIndex(datePartStr: string): number { + const [y, m, d] = datePartStr.split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d)).getUTCDay(); +} + +/** Ex. « MER 15 ». */ +export function shortDayLabel(iso: string): string { + const dp = datePart(iso); + return `${WEEKDAYS[weekdayIndex(dp)].slice(0, 3).toUpperCase()} ${Number(dp.slice(8, 10))}`; +} + +/** Ex. « Mercredi 15 ». */ +export function longDayLabel(iso: string): string { + const dp = datePart(iso); + return `${WEEKDAYS_LONG[weekdayIndex(dp)]} ${Number(dp.slice(8, 10))}`; +} + +/** Ex. « 17:00 → 22:00 ». */ +export function hourRangeLabel(startIso: string, endIso: string): string { + return `${timePart(startIso)} → ${timePart(endIso)}`; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9562247..04c8f36 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,17 +1,23 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider, createBrowserRouter } from 'react-router-dom'; -import { PlanningProvider } from '@/app/planning-store'; import { routes } from '@/app/router'; import './index.css'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 15_000, refetchOnWindowFocus: false, retry: 1 }, + }, +}); + const router = createBrowserRouter(routes); createRoot(document.getElementById('root')!).render( - + - + , ); diff --git a/frontend/src/mock/generate.ts b/frontend/src/mock/generate.ts deleted file mode 100644 index a736885..0000000 --- a/frontend/src/mock/generate.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { - DAY_PARTS, - DEMO_CATEGORIES, - FESTIVAL_DAYS, - availabilityKey, - dayPartHours, -} from '@/domain/festival'; -import type { Assignment, AssignmentStatus, Position, Volunteer } from '@/domain/types'; - -import { buildPositions } from './positions'; - -export interface DemoDataset { - positions: Position[]; - volunteers: Volunteer[]; - assignments: Assignment[]; - removed: { volunteerName: string; positionName: string; zone: string; dayShortLabel: string; dayPartLabel: string }[]; - nextAssignmentSeq: number; -} - -// Générateur pseudo-aléatoire déterministe (mulberry32), repris du design pour -// obtenir exactement le même jeu de démonstration à chaque chargement. -function mulberry32(seed: number): () => number { - let a = seed; - return function () { - a |= 0; - a = (a + 0x6d2b79f5) | 0; - let t = Math.imul(a ^ (a >>> 15), 1 | a); - t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; -} - -const FIRST_NAMES = [ - 'Camille', 'Yanis', 'Léa', 'Mathis', 'Soraya', 'Thomas', 'Awa', 'Julien', 'Chloé', 'Nadia', - 'Hugo', 'Inès', 'Baptiste', 'Manon', 'Karim', 'Élodie', 'Rémi', 'Sarah', 'Antoine', 'Fanny', - 'Malik', 'Clara', 'Victor', 'Jeanne', -]; -const LAST_NAMES = [ - 'Fournier', 'Berger', 'Moreau', 'Lambert', 'Benali', 'Rey', 'Diallo', 'Chevalier', 'Marchand', - 'Perrin', 'Lefèvre', 'Nguyen', 'Rossi', 'Garnier', 'Barbier', 'Vasseur', 'Colin', 'Prévost', - 'Renard', 'Dupuy', -]; - -const VOLUNTEER_COUNT = 64; -const NEVER_ASSIGNED_INDEXES = [7, 21, 34, 48, 55]; -const OVERLOADED_INDEXES = [3, 9]; - -function statusFromRoll(roll: number): AssignmentStatus { - if (roll < 0.72) return 'CONFIRMED'; - if (roll < 0.95) return 'PROPOSED'; - return 'DECLINED'; -} - -export function buildDemoDataset(): DemoDataset { - const positions = buildPositions(); - const rnd = mulberry32(20260715); - - const volunteers: Volunteer[] = []; - for (let i = 0; i < VOLUNTEER_COUNT; i += 1) { - const firstName = FIRST_NAMES[i % FIRST_NAMES.length]; - const lastName = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length]; - const availability = new Set(); - FESTIVAL_DAYS.forEach((day) => { - DAY_PARTS.forEach((part) => { - const isEdgeDay = day.index === 0 || day.index === 3; - if (rnd() < (isEdgeDay ? 0.4 : 0.6)) { - availability.add(availabilityKey(day.id, part.code)); - } - }); - }); - const preferredCategories = [DEMO_CATEGORIES[Math.floor(rnd() * DEMO_CATEGORIES.length)]]; - if (rnd() < 0.45) { - preferredCategories.push(DEMO_CATEGORIES[Math.floor(rnd() * DEMO_CATEGORIES.length)]); - } - const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2); - const phone = `06 ${pair()} ${pair()} ${pair()} ${pair()}`; - volunteers.push({ - id: `v${i}`, - firstName, - lastName, - fullName: `${firstName} ${lastName}`, - initials: firstName[0] + lastName[0], - phone, - availability, - preferredCategories, - }); - } - - // Bénévole de démonstration (« moi ») : disponible partout, demande Bar + Accueil. - const me = volunteers[0]; - me.preferredCategories = ['BAR', 'WELCOME']; - FESTIVAL_DAYS.forEach((day) => - DAY_PARTS.forEach((part) => me.availability.add(availabilityKey(day.id, part.code))), - ); - // Référent de démonstration. - volunteers[1].preferredCategories = ['BAR']; - // Bénévoles inscrits mais jamais affectés (« oubliés »). - NEVER_ASSIGNED_INDEXES.forEach((i) => { - volunteers[i].neverAssigned = true; - }); - - const assignments: Assignment[] = []; - const busy: Record = {}; - const dailyHours: Record = {}; - const totalHours: Record = {}; - let seq = 1; - let slotIndex = 0; - - positions.forEach((position) => { - FESTIVAL_DAYS.forEach((day) => { - position.needs - .filter((need) => need.dayId === day.id) - .forEach((need) => { - slotIndex += 1; - let target = need.idealStaff; - if (slotIndex % 7 === 0) target = Math.max(1, need.minStaff - 1); - else if (slotIndex % 5 === 0) target = need.minStaff; - else if (slotIndex % 3 === 0) target = Math.max(need.minStaff, need.idealStaff - 1); - - const hours = dayPartHours(need.dayPartCode); - const pool = volunteers - .filter( - (v) => - !v.neverAssigned && - !busy[`${v.id}|${day.id}|${need.dayPartCode}`] && - (dailyHours[`${v.id}|${day.id}`] ?? 0) + hours <= 8, - ) - .sort((a, b) => score(b) - score(a)); - - function score(v: Volunteer): number { - const available = v.availability.has(availabilityKey(day.id, need.dayPartCode)) ? 100 : 0; - const wanted = v.preferredCategories.includes(position.category) ? 40 : 0; - return available + wanted - (totalHours[v.id] ?? 0) * 3; - } - - pool.slice(0, target).forEach((v) => { - const status = statusFromRoll(rnd()); - assignments.push({ - id: `a${seq}`, - volunteerId: v.id, - positionId: position.id, - dayId: day.id, - dayPartCode: need.dayPartCode, - status, - source: 'SOLVER', - locked: false, - pending: null, - }); - seq += 1; - if (status !== 'DECLINED') { - busy[`${v.id}|${day.id}|${need.dayPartCode}`] = true; - dailyHours[`${v.id}|${day.id}`] = (dailyHours[`${v.id}|${day.id}`] ?? 0) + hours; - totalHours[v.id] = (totalHours[v.id] ?? 0) + hours; - } - }); - }); - }); - }); - - // « Moi » : 1 créneau confirmé puis des propositions à confirmer. - assignments - .filter((a) => a.volunteerId === me.id) - .forEach((a, i) => { - a.status = i === 0 ? 'CONFIRMED' : 'PROPOSED'; - }); - - // Deux surcharges volontaires : plus de 8 h sur le vendredi. - OVERLOADED_INDEXES.forEach((index) => { - const volunteer = volunteers[index]; - const dayId = 'ven'; - const taken = assignments - .filter((a) => a.volunteerId === volunteer.id && a.dayId === dayId) - .map((a) => a.dayPartCode); - const freeCode = ['A', 'S', 'N', 'M'].find((code) => !taken.includes(code)); - if (!freeCode) return; - const position = positions.find((p) => - p.needs.some((n) => n.dayId === dayId && n.dayPartCode === freeCode), - ); - if (!position) return; - assignments.push({ - id: `a${seq}`, - volunteerId: volunteer.id, - positionId: position.id, - dayId, - dayPartCode: freeCode, - status: 'CONFIRMED', - source: 'MANUAL', - locked: false, - pending: null, - }); - seq += 1; - }); - - // Modifications non encore publiées. - assignments.forEach((a, i) => { - if (i % 29 === 7) a.pending = 'ADD'; - }); - - const removed = [ - { volunteerName: 'Sarah Prévost', positionName: 'Parking', zone: 'Nord', dayShortLabel: 'VEN 17', dayPartLabel: '17–22' }, - { volunteerName: 'Karim Benali', positionName: 'Propreté', zone: 'Site', dayShortLabel: 'SAM 18', dayPartLabel: '12–17' }, - ]; - - return { positions, volunteers, assignments, removed, nextAssignmentSeq: seq }; -} diff --git a/frontend/src/mock/positions.ts b/frontend/src/mock/positions.ts deleted file mode 100644 index 62e2913..0000000 --- a/frontend/src/mock/positions.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { Position, PositionCategory, StaffingNeed } from '@/domain/types'; - -// Jeu de démonstration des postes, repris du design Claude Design. -// `raw` = [codePlage, minStaff, idealStaff] par journée. -type RawSlots = Record>; - -interface RawPosition { - id: string; - category: PositionCategory; - name: string; - zone: string; - location: string; - slots: RawSlots; -} - -const RAW_POSITIONS: RawPosition[] = [ - { - id: 'BAR', category: 'BAR', name: 'Bar', zone: 'Grande scène', location: 'Chapiteau nord', - slots: { jeu: [['A', 3, 4], ['S', 4, 6], ['N', 3, 5]], ven: [['A', 3, 4], ['S', 5, 6], ['N', 4, 5]], sam: [['M', 2, 3]] }, - }, - { - id: 'BARV', category: 'BAR', name: 'Bar', zone: 'Village assos', location: 'Village est', - slots: { jeu: [['A', 2, 3], ['S', 3, 4]], ven: [['A', 2, 3], ['S', 3, 4], ['N', 2, 3]], sam: [['M', 2, 2]] }, - }, - { - id: 'BILA', category: 'TICKETING', name: 'Billetterie', zone: 'Entrée A', location: 'Portail est', - slots: { jeu: [['A', 4, 5], ['S', 3, 4]], ven: [['A', 4, 5], ['S', 3, 4]] }, - }, - { - id: 'BILB', category: 'TICKETING', name: 'Billetterie', zone: 'Entrée B', location: 'Portail ouest', - slots: { jeu: [['A', 2, 3], ['S', 2, 3]], ven: [['A', 3, 4], ['S', 2, 3]] }, - }, - { - id: 'ACCB', category: 'WELCOME', name: 'Accueil', zone: 'Bénévoles', location: 'Base bénévoles', - slots: { mer: [['M', 2, 3], ['A', 2, 3]], jeu: [['M', 2, 2], ['A', 2, 3], ['S', 2, 3]], ven: [['M', 2, 2], ['A', 2, 3], ['S', 2, 3]], sam: [['M', 2, 2]] }, - }, - { - id: 'ACCA', category: 'WELCOME', name: 'Accueil', zone: 'Artistes', location: 'Loges', - slots: { jeu: [['A', 2, 2], ['S', 2, 3]], ven: [['A', 2, 2], ['S', 2, 3]] }, - }, - { - id: 'PARN', category: 'PARKING', name: 'Parking', zone: 'Nord', location: 'Route de Bréal', - slots: { jeu: [['A', 3, 4], ['S', 3, 4], ['N', 2, 3]], ven: [['A', 3, 4], ['S', 4, 5], ['N', 2, 3]] }, - }, - { - id: 'PARS', category: 'PARKING', name: 'Parking', zone: 'Sud', location: 'Chemin des Prés', - slots: { jeu: [['A', 2, 3], ['S', 2, 3]], ven: [['A', 2, 3], ['S', 3, 4], ['N', 2, 2]] }, - }, - { - id: 'PROS', category: 'CLEANING', name: 'Propreté', zone: 'Site', location: 'Ensemble du site', - slots: { jeu: [['S', 2, 3], ['N', 3, 4]], ven: [['M', 3, 4], ['S', 2, 3], ['N', 3, 5]], sam: [['M', 4, 6], ['A', 4, 6]] }, - }, - { - id: 'PROA', category: 'CLEANING', name: 'Propreté', zone: 'Sanitaires', location: 'Blocs 1 à 4', - slots: { jeu: [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]], ven: [['M', 2, 2], ['A', 2, 2], ['S', 2, 3], ['N', 2, 2]] }, - }, - { - id: 'CATB', category: 'CATERING', name: 'Catering', zone: 'Bénévoles', location: 'Tente cuisine', - slots: { mer: [['M', 2, 3], ['A', 2, 3]], jeu: [['M', 2, 3], ['A', 3, 4], ['S', 3, 4]], ven: [['M', 2, 3], ['A', 3, 4], ['S', 3, 4]], sam: [['M', 2, 3], ['A', 2, 2]] }, - }, - { - id: 'CATA', category: 'CATERING', name: 'Catering', zone: 'Artistes', location: 'Backstage', - slots: { jeu: [['A', 2, 2], ['S', 2, 3]], ven: [['A', 2, 2], ['S', 2, 3]] }, - }, - { - id: 'MONS', category: 'SETUP', name: 'Montage', zone: 'Scène', location: 'Grande scène', - slots: { mer: [['M', 6, 8], ['A', 6, 8], ['S', 4, 5]], sam: [['A', 6, 8], ['S', 4, 6]] }, - }, - { - id: 'MONI', category: 'SETUP', name: 'Montage', zone: 'Signalétique', location: 'Site entier', - slots: { mer: [['M', 3, 4], ['A', 3, 4]], sam: [['A', 3, 4]] }, - }, - { - id: 'REG', category: 'STAGE', name: 'Régie', zone: 'Régie centrale', location: 'Cabane régie', - slots: { jeu: [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]], ven: [['A', 2, 2], ['S', 2, 3], ['N', 2, 2]] }, - }, -]; - -function toNeeds(slots: RawSlots): StaffingNeed[] { - const needs: StaffingNeed[] = []; - for (const [dayId, list] of Object.entries(slots)) { - for (const [dayPartCode, minStaff, idealStaff] of list) { - needs.push({ dayId, dayPartCode, minStaff, idealStaff }); - } - } - return needs; -} - -export function buildPositions(): Position[] { - return RAW_POSITIONS.map((p) => ({ - id: p.id, - name: p.name, - zone: p.zone, - location: p.location, - category: p.category, - needs: toNeeds(p.slots), - })); -} diff --git a/frontend/src/pages/GrillePage.tsx b/frontend/src/pages/GrillePage.tsx index 1bdbe07..54fbd26 100644 --- a/frontend/src/pages/GrillePage.tsx +++ b/frontend/src/pages/GrillePage.tsx @@ -1,54 +1,57 @@ import { useMemo, useState } from 'react'; -import { usePlanning } from '@/app/planning-store'; +import { useMatrix } from '@/api/hooks'; +import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema'; import { Chip } from '@/components/ui'; +import { ErrorNote, Loading } from '@/components/state'; import { AssignmentPanel } from '@/pages/grille/AssignmentPanel'; -import { - DAY_PARTS, - DEMO_CATEGORIES, - FESTIVAL_DAYS, - categoryLabelKey, - slotKey, -} from '@/domain/festival'; -import { indexPlanning } from '@/domain/selectors'; -import type { PositionCategory } from '@/domain/types'; +import { categoryLabel, latticeFromMatrix, type LatticeColumn } from '@/lib/planning'; +import { datePart, shortDayLabel, timePart } from '@/lib/time'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; -type DayFilter = 'all' | string; -type CategoryFilter = 'all' | PositionCategory; - export function GrillePage() { - const { state, dispatch } = usePlanning(); - const [dayFilter, setDayFilter] = useState('all'); - const [categoryFilter, setCategoryFilter] = useState('all'); + const matrix = useMatrix(); + const [dayFilter, setDayFilter] = useState<'all' | string>('all'); + const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all'); + const [selectedShiftId, setSelectedShiftId] = useState(null); - const index = useMemo( - () => indexPlanning(state.assignments, state.volunteers), - [state.assignments, state.volunteers], + const lattice = useMemo( + () => (matrix.data ? latticeFromMatrix(matrix.data) : { days: [], columns: [] }), + [matrix.data], ); - const shownDays = - dayFilter === 'all' ? FESTIVAL_DAYS : FESTIVAL_DAYS.filter((d) => d.id === dayFilter); + if (matrix.isLoading) return ; + if (matrix.error) return ; + if (!matrix.data) return null; + + const categories = [...new Set(matrix.data.positions.map((p) => p.category))].sort(); + const shownDays = dayFilter === 'all' ? lattice.days : lattice.days.filter((d) => d.date === dayFilter); + const shownColumns = shownDays.flatMap((d) => d.columns); const shownPositions = categoryFilter === 'all' - ? state.positions - : state.positions.filter((p) => p.category === categoryFilter); + ? matrix.data.positions + : matrix.data.positions.filter((p) => p.category === categoryFilter); - const gridTemplateColumns = `210px repeat(${shownDays.length * DAY_PARTS.length}, 66px)`; + const gridTemplateColumns = `230px repeat(${shownColumns.length}, 66px)`; + + const selected = selectedShiftId + ? matrix.data.positions + .flatMap((p) => p.shifts.map((s) => ({ position: p, shift: s }))) + .find((entry) => entry.shift.shift_id === selectedShiftId) ?? null + : null; return (
- {/* Barre de filtres + légende */}
{t('grille.filter.day')} setDayFilter('all')}> {t('filter.all')} - {FESTIVAL_DAYS.map((day) => ( - setDayFilter(day.id)}> - {day.shortLabel} + {lattice.days.map((day) => ( + setDayFilter(day.date)}> + {shortDayLabel(`${day.date}T12:00`)} ))}
@@ -58,82 +61,68 @@ export function GrillePage() { setCategoryFilter('all')}> {t('filter.all')} - {DEMO_CATEGORIES.map((category) => ( + {categories.map((category) => ( setCategoryFilter(category)} > - {t(categoryLabelKey(category)).toUpperCase()} + {categoryLabel(category).toUpperCase()} ))}
- {t('grille.legend.title')} - + {t('legend.title')} + {t('grille.legend.critical')} - + {t('grille.legend.incomplete')} - + {t('grille.legend.complete')} - - - {t('grille.legend.unpublished')} -
-
- {/* Ligne 1 : coin + entêtes de journée */} +
- {t('grille.positionsCount', { count: state.positions.length })} + {t('grille.positionsCount', { count: matrix.data.positions.length })}
{shownDays.map((day) => (
- {day.shortLabel} + {shortDayLabel(`${day.date}T12:00`)} - {day.tag}
))} - {/* Ligne 2 : légende de colonne + libellés de plage */}
{t('grille.assignedRequired')}
- {shownDays.map((day) => - DAY_PARTS.map((part) => ( -
- {part.label} -
- )), - )} + {shownColumns.map((column) => ( +
+ {column.time} +
+ ))} - {/* Lignes de postes */} {shownPositions.map((position) => ( dispatch({ type: 'selectSlot', slot })} + columns={shownColumns} + selectedShiftId={selectedShiftId} + onSelect={setSelectedShiftId} /> ))}
@@ -143,8 +132,14 @@ export function GrillePage() {