From ac469f0cd116140da4eea2e3ea78f6387b5bfdc5 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Wed, 9 Sep 2026 22:53:57 +0200 Subject: [PATCH] =?UTF-8?q?Frontend=20:=20int=C3=A9gration=20du=20design?= =?UTF-8?q?=20Claude=20Design=20(6=20=C3=A9crans)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portage du projet « Bénévoles Festival.dc.html » en implémentation statique pilotée par un jeu de démonstration déterministe (portage du générateur du design : 15 postes, 64 bénévoles, ~209 affectations). - Système visuel néo-brutaliste : palette + primitives Tailwind, polices Archivo / DM Mono, primitives dans components/ui.tsx. - Domaine (aligné CLAUDE.md) : types, jours/plages, moteur de validation (mêmes codes BLOCKING/WARNING que le back-end), sélecteurs, différentiel de publication. - État applicatif : PlanningProvider (reducer) — affecter / désaffecter / publier / confirmer / éditer besoins et bénévoles / import simulé / pointage. - Écrans : 01 Grille des besoins + panneau d'affectation (candidats triés, alertes), 02 Vue par bénévole, 03 Publication, 04 Mobile bénévole, 05 Mobile référent, 06 Configuration (4 sous-onglets). - i18n : aucun texte d'interface en dur, locales/fr.json. - Vérifs : build tsc + vite, eslint (0 erreur), rendu headless des 6 écrans (npm run smoke). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2 --- frontend/README.md | 54 +- frontend/index.html | 6 + frontend/package-lock.json | 531 +++++++++++++++++- frontend/package.json | 5 +- frontend/scripts/smoke.tsx | 32 ++ frontend/src/app/AppLayout.tsx | 18 - frontend/src/app/AppShell.tsx | 81 +++ frontend/src/app/planning-store.tsx | 306 ++++++++++ frontend/src/app/router.tsx | 30 +- frontend/src/components/ui.tsx | 217 +++++++ 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 | 235 +++++++- frontend/src/index.css | 29 + frontend/src/lib/cx.ts | 4 + frontend/src/main.tsx | 16 +- frontend/src/mock/generate.ts | 205 +++++++ frontend/src/mock/positions.ts | 98 ++++ frontend/src/pages/ConfigPage.tsx | 42 ++ frontend/src/pages/DashboardPage.tsx | 12 - frontend/src/pages/GrillePage.tsx | 262 +++++++++ frontend/src/pages/MobileLeadPage.tsx | 141 +++++ frontend/src/pages/MobileVolunteerPage.tsx | 132 +++++ frontend/src/pages/PublicationPage.tsx | 135 +++++ frontend/src/pages/VolunteerViewPage.tsx | 252 +++++++++ frontend/src/pages/config/ConfigGrid.tsx | 247 ++++++++ frontend/src/pages/config/ConfigMail.tsx | 203 +++++++ frontend/src/pages/config/ConfigRules.tsx | 147 +++++ .../src/pages/config/ConfigVolunteers.tsx | 216 +++++++ frontend/src/pages/grille/AssignmentPanel.tsx | 251 +++++++++ frontend/src/pages/mobile/PhoneFrame.tsx | 29 + frontend/tailwind.config.js | 44 +- 35 files changed, 4267 insertions(+), 125 deletions(-) create mode 100644 frontend/scripts/smoke.tsx delete mode 100644 frontend/src/app/AppLayout.tsx create mode 100644 frontend/src/app/AppShell.tsx create mode 100644 frontend/src/app/planning-store.tsx create mode 100644 frontend/src/components/ui.tsx create mode 100644 frontend/src/domain/festival.ts create mode 100644 frontend/src/domain/publication.ts create mode 100644 frontend/src/domain/rules.ts create mode 100644 frontend/src/domain/selectors.ts create mode 100644 frontend/src/domain/types.ts create mode 100644 frontend/src/lib/cx.ts create mode 100644 frontend/src/mock/generate.ts create mode 100644 frontend/src/mock/positions.ts create mode 100644 frontend/src/pages/ConfigPage.tsx delete mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/GrillePage.tsx create mode 100644 frontend/src/pages/MobileLeadPage.tsx create mode 100644 frontend/src/pages/MobileVolunteerPage.tsx create mode 100644 frontend/src/pages/PublicationPage.tsx create mode 100644 frontend/src/pages/VolunteerViewPage.tsx create mode 100644 frontend/src/pages/config/ConfigGrid.tsx create mode 100644 frontend/src/pages/config/ConfigMail.tsx create mode 100644 frontend/src/pages/config/ConfigRules.tsx create mode 100644 frontend/src/pages/config/ConfigVolunteers.tsx create mode 100644 frontend/src/pages/grille/AssignmentPanel.tsx create mode 100644 frontend/src/pages/mobile/PhoneFrame.tsx diff --git a/frontend/README.md b/frontend/README.md index 9dabe21..1b55e30 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -2,10 +2,21 @@ React + Vite + TypeScript + Tailwind. Interface en français, code en anglais. -Le design fait foi : projet Claude Design « Bénévoles Festival » -(`Bénévoles Festival.dc.html`). Les écrans et composants réels sont dérivés de -ce fichier et intégrés au fil des étapes. Le contenu actuel est un squelette -(routage, i18n, client API). +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. + +## É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 | ## Commandes @@ -14,31 +25,40 @@ ce fichier et intégrés au fil des étapes. Le contenu actuel est un squelette | Installer | `npm install` | | Dev (hot reload) | `npm run dev` → http://localhost:5173 | | Build de production | `npm run build` | -| Prévisualiser le build | `npm run preview` | | 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`). ## Conventions -- **Aucun texte visible en dur.** Tout passe par `src/i18n` (`t(key, params)`) - et `src/i18n/locales/fr.json`, même en monolingue. -- Code, noms de fichiers et de variables en anglais. -- Alias d'import `@/` → `src/`. -- Vocabulaire métier : voir le glossaire FR ↔ EN de `CLAUDE.md`. +- **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. ## Arborescence ``` src/ -├── main.tsx # point d'entrée (React Query + Router) -├── index.css # directives Tailwind +├── main.tsx # point d'entrée (Router + PlanningProvider) ├── app/ -│ ├── router.tsx # routes -│ └── AppLayout.tsx # coquille de mise en page (remplacée par le design) -├── pages/ # écrans -├── api/client.ts # client HTTP + ApiError -└── i18n/ # t() + locales/fr.json +│ ├── 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 ``` diff --git a/frontend/index.html b/frontend/index.html index 1a4e755..7d0b6d6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,12 @@ Gestion des bénévoles + + +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 810d787..2cbca8c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,6 @@ "name": "benevoles-frontend", "version": "0.1.0", "dependencies": { - "@tanstack/react-query": "^5.59.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0" @@ -28,6 +27,7 @@ "globals": "^15.11.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.13", + "tsx": "^4.19.1", "typescript": "^5.6.2", "typescript-eslint": "^8.8.1", "vite": "^5.4.8" @@ -637,6 +637,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", @@ -654,6 +671,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -671,6 +705,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", @@ -1492,32 +1543,6 @@ "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", @@ -4043,6 +4068,458 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tsx": { + "version": "4.23.13", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz", + "integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 99904d2..3dbab56 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,10 +8,10 @@ "build": "tsc -b && vite build", "preview": "vite preview", "lint": "eslint .", - "typecheck": "tsc -b --noEmit" + "typecheck": "tsc -b --noEmit", + "smoke": "tsx --tsconfig tsconfig.app.json scripts/smoke.tsx" }, "dependencies": { - "@tanstack/react-query": "^5.59.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.27.0" @@ -31,6 +31,7 @@ "globals": "^15.11.0", "postcss": "^8.4.47", "tailwindcss": "^3.4.13", + "tsx": "^4.19.1", "typescript": "^5.6.2", "typescript-eslint": "^8.8.1", "vite": "^5.4.8" diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx new file mode 100644 index 0000000..6a75971 --- /dev/null +++ b/frontend/scripts/smoke.tsx @@ -0,0 +1,32 @@ +// Rendu headless de chaque écran pour vérifier qu'aucun composant ne lève. +// Lancement : npx tsx --tsconfig tsconfig.app.json scripts/smoke.tsx +import { createElement } from 'react'; +import { renderToString } from 'react-dom/server'; +import { RouterProvider, createMemoryRouter } from 'react-router-dom'; + +import { PlanningProvider } from '@/app/planning-store'; +import { routes } from '@/app/router'; + +const paths = [ + '/grille', + '/benevoles', + '/publication', + '/mobile-benevole', + '/mobile-referent', + '/configuration', +]; + +let failures = 0; +for (const path of paths) { + try { + const router = createMemoryRouter(routes, { initialEntries: [path] }); + const html = renderToString( + createElement(PlanningProvider, null, createElement(RouterProvider, { router })), + ); + console.log(`ok ${path.padEnd(20)} ${html.length} octets`); + } catch (error) { + failures += 1; + console.error(`FAIL ${path}`, error); + } +} +process.exit(failures ? 1 : 0); diff --git a/frontend/src/app/AppLayout.tsx b/frontend/src/app/AppLayout.tsx deleted file mode 100644 index 475aa87..0000000 --- a/frontend/src/app/AppLayout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Outlet } from 'react-router-dom'; - -import { t } from '@/i18n'; - -// Coquille de mise en page. Le design définira la navigation, l'en-tête et -// la palette ; ce composant sera remplacé lors de l'intégration du design. -export function AppLayout() { - return ( -
-
-

{t('app.title')}

-
-
- -
-
- ); -} diff --git a/frontend/src/app/AppShell.tsx b/frontend/src/app/AppShell.tsx new file mode 100644 index 0000000..a3fc7db --- /dev/null +++ b/frontend/src/app/AppShell.tsx @@ -0,0 +1,81 @@ +import { NavLink, Outlet } from 'react-router-dom'; + +import { usePlanning } from '@/app/planning-store'; +import { buildDiff } from '@/domain/publication'; +import { t } from '@/i18n'; +import { cx } from '@/lib/cx'; + +const TABS = [ + { to: '/grille', num: '01', labelKey: 'tab.grille' }, + { to: '/benevoles', num: '02', labelKey: 'tab.volunteers' }, + { to: '/publication', num: '03', labelKey: 'tab.publication' }, + { to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' }, + { to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' }, + { to: '/configuration', num: '06', labelKey: 'tab.config' }, +] as const; + +export function AppShell() { + const { state } = usePlanning(); + const diff = buildDiff(state); + const hasDraft = diff.length > 0; + + return ( +
+
+
+
+ {state.config.festivalName} +
+
+ {t('header.meta', { count: state.volunteers.length })} +
+
+ + + +
+ {hasDraft ? ( +
+ {t('header.draft', { count: diff.length })} +
+ ) : ( +
+ {t('header.published')} +
+ )} +
+ MB +
+
+
+ +
+ +
+
+ ); +} diff --git a/frontend/src/app/planning-store.tsx b/frontend/src/app/planning-store.tsx new file mode 100644 index 0000000..366bea1 --- /dev/null +++ b/frontend/src/app/planning-store.tsx @@ -0,0 +1,306 @@ +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/app/router.tsx b/frontend/src/app/router.tsx index 597412d..a453183 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -1,14 +1,26 @@ -import { createBrowserRouter } from 'react-router-dom'; +import { Navigate, type RouteObject } from 'react-router-dom'; -import { AppLayout } from './AppLayout'; -import { DashboardPage } from '@/pages/DashboardPage'; +import { AppShell } from './AppShell'; +import { ConfigPage } from '@/pages/ConfigPage'; +import { GrillePage } from '@/pages/GrillePage'; +import { MobileLeadPage } from '@/pages/MobileLeadPage'; +import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage'; +import { PublicationPage } from '@/pages/PublicationPage'; +import { VolunteerViewPage } from '@/pages/VolunteerViewPage'; -// Squelette de routage. Les écrans réels sont dérivés du design -// (« Bénévoles Festival.dc.html ») et ajoutés au fil des étapes. -export const router = createBrowserRouter([ +export const routes: RouteObject[] = [ { path: '/', - element: , - children: [{ index: true, element: }], + element: , + children: [ + { index: true, element: }, + { path: 'grille', element: }, + { path: 'benevoles', element: }, + { path: 'publication', element: }, + { path: 'mobile-benevole', element: }, + { path: 'mobile-referent', element: }, + { path: 'configuration', element: }, + { path: '*', element: }, + ], }, -]); +]; diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx new file mode 100644 index 0000000..c6b6db3 --- /dev/null +++ b/frontend/src/components/ui.tsx @@ -0,0 +1,217 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +import { cx } from '@/lib/cx'; + +// Primitives visuelles du design « Bénévoles Festival » : bordures franches, +// ombres portées dures, libellés capitales. Aucun texte en dur ici — les +// libellés sont passés par les écrans (via i18n). + +export function Panel({ + children, + className, + shadow = 'md', +}: { + children: ReactNode; + className?: string; + shadow?: 'none' | 'md' | 'lg'; +}) { + return ( +
+ {children} +
+ ); +} + +export function PanelHeader({ + children, + hatched = false, + className, +}: { + children: ReactNode; + hatched?: boolean; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} + +type BtnVariant = 'solid' | 'ghost' | 'danger' | 'dark'; + +export function Btn({ + variant = 'solid', + className, + type = 'button', + ...props +}: ButtonHTMLAttributes & { variant?: BtnVariant }) { + return ( + + ); +} + +type BadgeTone = 'confirmed' | 'proposed' | 'success' | 'info' | 'warn' | 'danger' | 'neutral'; + +export function Badge({ tone, children }: { tone: BadgeTone; children: ReactNode }) { + return ( + + {children} + + ); +} + +export function Stepper({ + value, + onDecrement, + onIncrement, + suffix, +}: { + value: ReactNode; + onDecrement: () => void; + onIncrement: () => void; + suffix?: string; +}) { + return ( + + + + {value} + {suffix ? ` ${suffix}` : null} + + + + ); +} + +export function StatCard({ + value, + label, + tone = 'paper', +}: { + value: ReactNode; + label: string; + tone?: 'paper' | 'yellow' | 'green'; +}) { + return ( +
+
{value}
+
+ {label} +
+
+ ); +} + +export function FieldLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +export function TextInput({ + value, + onChange, + mono = false, + className, +}: { + value: string; + onChange: (value: string) => void; + mono?: boolean; + className?: string; +}) { + return ( + onChange(e.target.value)} + className={cx( + 'w-full border-2 border-ink bg-cream p-[9px] text-[13px]', + mono ? 'font-mono' : 'font-display font-bold', + className, + )} + /> + ); +} diff --git a/frontend/src/domain/festival.ts b/frontend/src/domain/festival.ts new file mode 100644 index 0000000..60c02a4 --- /dev/null +++ b/frontend/src/domain/festival.ts @@ -0,0 +1,69 @@ +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 new file mode 100644 index 0000000..37e66dd --- /dev/null +++ b/frontend/src/domain/publication.ts @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..d66cbd9 --- /dev/null +++ b/frontend/src/domain/rules.ts @@ -0,0 +1,105 @@ +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 new file mode 100644 index 0000000..38aadbc --- /dev/null +++ b/frontend/src/domain/selectors.ts @@ -0,0 +1,71 @@ +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 new file mode 100644 index 0000000..fd18e4a --- /dev/null +++ b/frontend/src/domain/types.ts @@ -0,0 +1,117 @@ +// 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 39e3291..b96c938 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -1,46 +1,223 @@ { - "app.title": "Gestion des bénévoles", - "app.loading": "Chargement…", - "app.error.generic": "Une erreur est survenue", - "app.error.network": "Impossible de joindre le serveur", + "filter.all": "TOUS", + "legend.title": "LÉGENDE", + "action.close": "Fermer", + "action.remove": "Retirer", - "nav.dashboard": "Tableau de bord", - "nav.volunteers": "Bénévoles", - "nav.positions": "Postes", - "nav.shifts": "Créneaux", - "nav.assignments": "Affectations", - "nav.availabilities": "Disponibilités", - "nav.import": "Import", + "unit.hours": "{count} h", + "unit.days": "{count} j", + "time.now": "à l’instant", + "time.yesterday": "hier 18:42", - "role.coordinator": "Coordinateur", - "role.lead": "Référent", - "role.volunteer": "Bénévole", + "header.meta": "BÉNÉVOLAT · {count} INSCRITS", + "header.draft": "BROUILLON · {count} MODIF.", + "header.published": "✓ TOUT EST PUBLIÉ", + + "tab.grille": "Grille des besoins", + "tab.volunteers": "Vue par bénévole", + "tab.publication": "Publication", + "tab.mobileVolunteer": "Bénévole · mobile", + "tab.mobileLead": "Référent · mobile", + "tab.config": "Configuration", "assignment.status.proposed": "Proposée", "assignment.status.confirmed": "Confirmée", "assignment.status.declined": "Refusée", "assignment.status.cancelled": "Annulée", - "assignment.source.solver": "Solveur", - "assignment.source.manual": "Manuelle", - - "availability.status.available": "Disponible", - "availability.status.if_needed": "Si besoin", - "availability.status.unavailable": "Indisponible", - "position.category.bar": "Bar", "position.category.ticketing": "Billetterie", "position.category.welcome": "Accueil", "position.category.parking": "Parking", - "position.category.cleaning": "Nettoyage", - "position.category.catering": "Restauration", + "position.category.cleaning": "Propreté", + "position.category.catering": "Catering", "position.category.setup": "Montage", - "position.category.stage": "Scène", + "position.category.stage": "Régie", "position.category.other": "Autre", - "validation.SHIFT_OVERLAP": "Deux créneaux se chevauchent", - "validation.OUTSIDE_AVAILABILITY": "En dehors des disponibilités déclarées", - "validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectations sur la journée", - "validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux", - "validation.MISSING_REQUIRED_SKILL": "Compétence requise manquante" + "validation.SHIFT_OVERLAP": "Déjà affecté sur {position} au même créneau", + "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.MISSING_REQUIRED_SKILL": "Compétence requise manquante : {skill}", + + "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.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.slotDuration": "CRÉNEAU DE {hours} H", + "grille.panel.assigned": "AFFECTÉS", + "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.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.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", + + "volunteers.filter.show": "AFFICHER", + "volunteers.filter.all": "TOUS", + "volunteers.filter.overloaded": "SURCHARGÉS", + "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.", + + "publication.stat.changes": "MODIFICATIONS NON PUBLIÉ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.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.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}", + + "design.noteTitle": "CHOIX DE CONCEPTION", + + "mobileVol.statusTime": "9:41", + "mobileVol.kicker": "MON PLANNING BÉNÉVOLE", + "mobileVol.greeting": "SALUT {name}", + "mobileVol.slots": "{count} CRÉNEAUX", + "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.", + + "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.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.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.", + + "config.admin": "ADMINISTRATION", + "config.reserved": "RÉSERVÉ À LA COORDINATION", + "config.tab.grid": "GRILLE DES BESOINS", + "config.tab.volunteers": "BÉNÉVOLES & IMPORT", + "config.tab.mail": "MESSAGES & ENVOIS", + "config.tab.rules": "RÈGLES DU FESTIVAL", + + "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.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.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.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.ficheKicker": "FICHE BÉNÉVOLE", + "config.vol.fullName": "NOM COMPLET", + "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.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.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.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" } diff --git a/frontend/src/index.css b/frontend/src/index.css index b5c61c9..89b2609 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,3 +1,32 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer base { + html, + body, + #root { + height: 100%; + } + + body { + margin: 0; + background: theme('colors.cream'); + color: theme('colors.ink'); + font-family: theme('fontFamily.display'); + -webkit-font-smoothing: antialiased; + } + + /* Barres de défilement franches, dans l'esprit du design. */ + ::-webkit-scrollbar { + height: 12px; + width: 12px; + } + ::-webkit-scrollbar-track { + background: theme('colors.sand-dark'); + } + ::-webkit-scrollbar-thumb { + background: theme('colors.ink'); + border: 2px solid theme('colors.sand-dark'); + } +} diff --git a/frontend/src/lib/cx.ts b/frontend/src/lib/cx.ts new file mode 100644 index 0000000..ba7db3d --- /dev/null +++ b/frontend/src/lib/cx.ts @@ -0,0 +1,4 @@ +/** Concatène des classes conditionnelles (mini remplaçant de `clsx`). */ +export function cx(...parts: Array): string { + return parts.filter(Boolean).join(' '); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 80ec501..9562247 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,21 +1,17 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { RouterProvider } from 'react-router-dom'; +import { RouterProvider, createBrowserRouter } from 'react-router-dom'; -import { router } from '@/app/router'; +import { PlanningProvider } from '@/app/planning-store'; +import { routes } from '@/app/router'; import './index.css'; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { staleTime: 30_000, refetchOnWindowFocus: false }, - }, -}); +const router = createBrowserRouter(routes); createRoot(document.getElementById('root')!).render( - + - +
, ); diff --git a/frontend/src/mock/generate.ts b/frontend/src/mock/generate.ts new file mode 100644 index 0000000..a736885 --- /dev/null +++ b/frontend/src/mock/generate.ts @@ -0,0 +1,205 @@ +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 new file mode 100644 index 0000000..62e2913 --- /dev/null +++ b/frontend/src/mock/positions.ts @@ -0,0 +1,98 @@ +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/ConfigPage.tsx b/frontend/src/pages/ConfigPage.tsx new file mode 100644 index 0000000..2e38d26 --- /dev/null +++ b/frontend/src/pages/ConfigPage.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react'; + +import { Chip } from '@/components/ui'; +import { ConfigGrid } from '@/pages/config/ConfigGrid'; +import { ConfigMail } from '@/pages/config/ConfigMail'; +import { ConfigRules } from '@/pages/config/ConfigRules'; +import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers'; +import { t } from '@/i18n'; + +type ConfigTab = 'grid' | 'volunteers' | 'mail' | 'rules'; + +const TABS: Array<{ id: ConfigTab; labelKey: string }> = [ + { id: 'grid', labelKey: 'config.tab.grid' }, + { id: 'volunteers', labelKey: 'config.tab.volunteers' }, + { id: 'mail', labelKey: 'config.tab.mail' }, + { id: 'rules', labelKey: 'config.tab.rules' }, +]; + +export function ConfigPage() { + const [tab, setTab] = useState('grid'); + + return ( +
+
+ {t('config.admin')} + {TABS.map((entry) => ( + setTab(entry.id)}> + {t(entry.labelKey)} + + ))} + + {t('config.reserved')} + +
+ + {tab === 'grid' ? : null} + {tab === 'volunteers' ? : null} + {tab === 'mail' ? : null} + {tab === 'rules' ? : null} +
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx deleted file mode 100644 index 1847a31..0000000 --- a/frontend/src/pages/DashboardPage.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { t } from '@/i18n'; - -export function DashboardPage() { - return ( -
-

{t('nav.dashboard')}

-

- {t('app.title')} — {import.meta.env.MODE} -

-
- ); -} diff --git a/frontend/src/pages/GrillePage.tsx b/frontend/src/pages/GrillePage.tsx new file mode 100644 index 0000000..1bdbe07 --- /dev/null +++ b/frontend/src/pages/GrillePage.tsx @@ -0,0 +1,262 @@ +import { useMemo, useState } from 'react'; + +import { usePlanning } from '@/app/planning-store'; +import { Chip } from '@/components/ui'; +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 { 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 index = useMemo( + () => indexPlanning(state.assignments, state.volunteers), + [state.assignments, state.volunteers], + ); + + const shownDays = + dayFilter === 'all' ? FESTIVAL_DAYS : FESTIVAL_DAYS.filter((d) => d.id === dayFilter); + const shownPositions = + categoryFilter === 'all' + ? state.positions + : state.positions.filter((p) => p.category === categoryFilter); + + const gridTemplateColumns = `210px repeat(${shownDays.length * DAY_PARTS.length}, 66px)`; + + return ( +
+ {/* Barre de filtres + légende */} +
+
+ {t('grille.filter.day')} + setDayFilter('all')}> + {t('filter.all')} + + {FESTIVAL_DAYS.map((day) => ( + setDayFilter(day.id)}> + {day.shortLabel} + + ))} +
+ +
+ {t('grille.filter.position')} + setCategoryFilter('all')}> + {t('filter.all')} + + {DEMO_CATEGORIES.map((category) => ( + setCategoryFilter(category)} + > + {t(categoryLabelKey(category)).toUpperCase()} + + ))} +
+ +
+ {t('grille.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 })} +
+ {shownDays.map((day) => ( +
+ + {day.shortLabel} + + {day.tag} +
+ ))} + + {/* Ligne 2 : légende de colonne + libellés de plage */} +
+ {t('grille.assignedRequired')} +
+ {shownDays.map((day) => + DAY_PARTS.map((part) => ( +
+ {part.label} +
+ )), + )} + + {/* Lignes de postes */} + {shownPositions.map((position) => ( + dispatch({ type: 'selectSlot', slot })} + /> + ))} +
+
+ {t('grille.footnote')} +
+
+ + +
+
+ ); +} + +function LegendItem({ + swatchClass, + glyph, + children, +}: { + swatchClass: string; + glyph: string; + children: React.ReactNode; +}) { + return ( + + + {glyph} + + {children} + + ); +} + +function PositionRow({ + position, + shownDays, + bySlot, + selectedSlot, + onSelect, +}: { + position: import('@/domain/types').Position; + shownDays: typeof FESTIVAL_DAYS; + bySlot: Map; + selectedSlot: string | null; + onSelect: (slot: string) => void; +}) { + return ( + <> +
+ {position.name} + {position.zone} +
+ {shownDays.map((day) => + DAY_PARTS.map((part) => { + const need = position.needs.find( + (n) => n.dayId === day.id && n.dayPartCode === part.code, + ); + const key = slotKey(position.id, day.id, part.code); + if (!need) { + return ( +
+ ); + } + const list = bySlot.get(key) ?? []; + const count = list.length; + const isCrit = count < need.minStaff; + const isInc = count >= need.minStaff && count < need.idealStaff; + const pending = list.some((a) => a.pending); + const selected = selectedSlot === key; + const glyph = isCrit ? '▲' : isInc ? '◧' : '✓'; + return ( +
onSelect(key)} + className={cx( + 'relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink', + isCrit && 'bg-hatch-orange', + isInc && 'bg-yellow-soft hover:bg-yellow', + !isCrit && !isInc && 'bg-paper hover:bg-green/10', + )} + > + {count} + /{need.idealStaff} + + {glyph} + + {pending ? ( + + ) : null} + {selected ? ( + + ) : null} +
+ ); + }), + )} + + ); +} diff --git a/frontend/src/pages/MobileLeadPage.tsx b/frontend/src/pages/MobileLeadPage.tsx new file mode 100644 index 0000000..ff0caa6 --- /dev/null +++ b/frontend/src/pages/MobileLeadPage.tsx @@ -0,0 +1,141 @@ +import { useMemo } from 'react'; + +import { usePlanning } from '@/app/planning-store'; +import { slotKey } from '@/domain/festival'; +import { indexPlanning } from '@/domain/selectors'; +import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame'; +import { t } from '@/i18n'; +import { cx } from '@/lib/cx'; + +const LEAD_SLOT = slotKey('BAR', 'ven', 'S'); + +export function MobileLeadPage() { + const { state, dispatch } = usePlanning(); + const index = useMemo( + () => indexPlanning(state.assignments, state.volunteers), + [state.assignments, state.volunteers], + ); + + const position = state.positions.find((p) => p.id === 'BAR')!; + const leadDemo = state.volunteers[1]; + + const team = (index.bySlot.get(LEAD_SLOT) ?? []).map((assignment) => { + const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId)!; + const presence = state.presence[assignment.id]; + return { + assignmentId: assignment.id, + name: volunteer.fullName, + initials: volunteer.initials, + phone: volunteer.phone, + status: + assignment.status === 'CONFIRMED' + ? t('mobileLead.statusConfirmed') + : t('mobileLead.statusProposed'), + present: presence === 'present', + absent: presence === 'absent', + }; + }); + + const presentCount = team.filter((m) => m.present).length; + const absentCount = team.filter((m) => m.absent).length; + + return ( +
+ +
+
+ {t('mobileLead.kicker', { name: leadDemo.fullName })} +
+
+ {`${position.name} — ${position.zone}`.toUpperCase()} +
+
+ {position.zone} · {position.location} +
+
+
+ {t('mobileLead.inProgress', { hours: t('mobileLead.shiftHours') })} +
+
+ {t('mobileLead.present', { present: presentCount, total: team.length })} +
+
+
+ + {absentCount > 0 ? ( +
+
+ {t('mobileLead.absenceAlert', { count: absentCount })} +
+ +
+ ) : null} + +
+
+ {t('mobileLead.teamTitle')} +
+ {team.map((member) => ( +
+
+ + {member.initials} + + + + {member.name} + + + {member.status} · {member.phone} + + + {member.present ? ( + + {t('mobileLead.badgePresent')} + + ) : null} + {member.absent ? ( + + {t('mobileLead.badgeAbsent')} + + ) : null} +
+
+ + +
+
+ ))} +
+ {t('mobileLead.nextSlot')} +
+
+
+ + {t('mobileLead.designNote')} +
+ ); +} diff --git a/frontend/src/pages/MobileVolunteerPage.tsx b/frontend/src/pages/MobileVolunteerPage.tsx new file mode 100644 index 0000000..33523ad --- /dev/null +++ b/frontend/src/pages/MobileVolunteerPage.tsx @@ -0,0 +1,132 @@ +import { useMemo } from 'react'; + +import { usePlanning } from '@/app/planning-store'; +import { absoluteWindow, dayPart, festivalDay } from '@/domain/festival'; +import { indexPlanning } from '@/domain/selectors'; +import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame'; +import { t } from '@/i18n'; + +export function MobileVolunteerPage() { + const { state, dispatch } = usePlanning(); + const index = useMemo( + () => indexPlanning(state.assignments, state.volunteers), + [state.assignments, state.volunteers], + ); + + const me = state.volunteers[0]; + const slots = (index.byVolunteer.get(me.id) ?? []) + .slice() + .sort( + (a, b) => + absoluteWindow(a.dayId, a.dayPartCode).start - absoluteWindow(b.dayId, b.dayPartCode).start, + ) + .map((assignment) => { + const position = state.positions.find((p) => p.id === assignment.positionId)!; + const positionIndex = state.positions.findIndex((p) => p.id === assignment.positionId); + const lead = state.volunteers[(positionIndex * 5 + 3) % state.volunteers.length]; + return { + assignmentId: assignment.id, + day: festivalDay(assignment.dayId).longLabel, + hours: dayPart(assignment.dayPartCode).label, + position: position.name.toUpperCase(), + zone: position.zone, + location: position.location, + leadName: lead.fullName, + leadPhone: lead.phone, + leadPhoneRaw: lead.phone.replace(/\s/g, ''), + confirmed: assignment.status === 'CONFIRMED', + }; + }); + + const toConfirm = slots.filter((s) => !s.confirmed).length; + const totalHours = index.totalHours.get(me.id) ?? 0; + + return ( +
+ +
+
{t('mobileVol.kicker')}
+
+ {t('mobileVol.greeting', { name: me.firstName.toUpperCase() })} +
+
+
+ {t('mobileVol.slots', { count: slots.length })} +
+
+ {t('mobileVol.hours', { count: totalHours })} +
+
+
+ + {toConfirm > 0 ? ( +
+ {t('mobileVol.toConfirm', { count: toConfirm })} +
+ ) : null} + +
+ {slots.map((slot) => ( +
+
+ {slot.day} + + {slot.hours} + +
+
+
+ {slot.position} +
+
{slot.zone}
+
+
+ {t('mobileVol.location', { location: slot.location })} +
+
+ {t('mobileVol.lead', { name: slot.leadName })} +
+
+ + {slot.confirmed ? ( +
+ {t('mobileVol.confirmed')} +
+ ) : ( + + )} +
+
+ ))} + +
+
+ {t('mobileVol.orgMessageTitle')} +
+

+ {t('mobileVol.orgMessageBody')} +

+
+ {t('mobileVol.orgMessageMeta')} +
+
+
+
+ + {t('mobileVol.designNote')} +
+ ); +} diff --git a/frontend/src/pages/PublicationPage.tsx b/frontend/src/pages/PublicationPage.tsx new file mode 100644 index 0000000..94aed9f --- /dev/null +++ b/frontend/src/pages/PublicationPage.tsx @@ -0,0 +1,135 @@ +import { useMemo } from 'react'; + +import { usePlanning } from '@/app/planning-store'; +import { Panel, PanelHeader, StatCard } from '@/components/ui'; +import { buildDiff, countConcernedVolunteers } from '@/domain/publication'; +import { indexPlanning } from '@/domain/selectors'; +import { t } from '@/i18n'; +import { cx } from '@/lib/cx'; + +export function PublicationPage() { + const { state, dispatch } = usePlanning(); + + const diff = useMemo(() => buildDiff(state), [state]); + const concerned = countConcernedVolunteers(diff); + const index = useMemo( + () => indexPlanning(state.assignments, state.volunteers), + [state.assignments, state.volunteers], + ); + + const me = state.volunteers[0]; + const sampleVolunteer = + diff.length > 0 + ? state.volunteers.find((v) => v.fullName === diff[0].volunteerName) ?? me + : me; + const sampleFirstName = sampleVolunteer.firstName; + const sampleSlots = (index.byVolunteer.get(sampleVolunteer.id) ?? []).length; + const sampleHours = index.totalHours.get(sampleVolunteer.id) ?? 0; + const when = state.published ? t('time.now') : t('time.yesterday'); + + return ( +
+
+
+ + + +
+ + +
+ + {t('publication.draftTitle')} + + + {t('publication.diffWith', { version: state.version })} + +
+ {diff.length === 0 ? ( +
+ + ✓ + + + {t('publication.noDiff')} + +
+ ) : ( + diff.map((entry, i) => ( +
+ + {entry.kind === 'ADD' ? t('publication.add') : t('publication.remove')} + + + {entry.volunteerName} + + {entry.positionName} {entry.zone} · {entry.dayShortLabel} · {entry.dayPartLabel} + + +
+ )) + )} +
+
+ +
+ + {t('publication.mailPreviewTitle')} +
+
+ {t('publication.mailFrom', { from: state.config.mailFrom })} +
+
+ {t('publication.mailTo', { count: concerned })} +
+
+ {t('publication.mailSubjectPrefix', { subject: state.config.mailSubject })} +
+

+ {t('publication.mailGreeting', { name: sampleFirstName })} +
+
+ {t('publication.mailBody', { slots: sampleSlots, hours: sampleHours })} +
+
+ {t('publication.mailBody2')} +

+
+ {t('publication.mailCta')} +
+
+ {t('publication.magicLinkNote', { days: state.config.magicLinkDays })} +
+
+
+ + {diff.length > 0 ? ( + <> + +
+ {t('publication.publishNote', { version: state.version + 1 })} +
+ + ) : ( +
+ {t('publication.publishedState', { version: state.version, when })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/VolunteerViewPage.tsx b/frontend/src/pages/VolunteerViewPage.tsx new file mode 100644 index 0000000..be5aca1 --- /dev/null +++ b/frontend/src/pages/VolunteerViewPage.tsx @@ -0,0 +1,252 @@ +import { useMemo, useState } from 'react'; + +import { usePlanning } from '@/app/planning-store'; +import { Chip } from '@/components/ui'; +import { DAY_PARTS, FESTIVAL_DAYS } from '@/domain/festival'; +import { indexPlanning, isOverloaded } from '@/domain/selectors'; +import { t } from '@/i18n'; +import { cx } from '@/lib/cx'; + +type VolunteerFilter = 'all' | 'overloaded' | 'unassigned'; + +export function VolunteerViewPage() { + const { state } = usePlanning(); + const [filter, setFilter] = useState('all'); + + const index = useMemo( + () => indexPlanning(state.assignments, state.volunteers), + [state.assignments, state.volunteers], + ); + const positionsById = useMemo( + () => new Map(state.positions.map((p) => [p.id, p])), + [state.positions], + ); + + const rows = useMemo(() => { + return state.volunteers + .filter((volunteer) => { + if (filter === 'overloaded') return isOverloaded(volunteer, index, state.config); + if (filter === 'unassigned') return (index.byVolunteer.get(volunteer.id) ?? []).length === 0; + return true; + }) + .map((volunteer) => { + const own = index.byVolunteer.get(volunteer.id) ?? []; + const over = isOverloaded(volunteer, index, state.config); + const cells = FESTIVAL_DAYS.flatMap((day) => + DAY_PARTS.map((part) => { + const assignment = own.find( + (a) => a.dayId === day.id && a.dayPartCode === part.code, + ); + if (assignment) { + const position = positionsById.get(assignment.positionId); + return { + kind: assignment.status === 'CONFIRMED' ? ('confirmed' as const) : ('proposed' as const), + code: (position?.id ?? assignment.positionId).slice(0, 4), + title: `${position?.name ?? ''} ${position?.zone ?? ''} · ${ + assignment.status === 'CONFIRMED' + ? t('assignment.status.confirmed') + : t('assignment.status.proposed') + }`.trim(), + }; + } + if (volunteer.availability.has(`${day.id}.${part.code}`)) { + return { kind: 'free' as const, code: '', title: '' }; + } + return { kind: 'none' as const, code: '', title: '' }; + }), + ); + return { + volunteer, + cells, + total: index.totalHours.get(volunteer.id) ?? 0, + over, + empty: own.length === 0, + }; + }); + }, [state.volunteers, state.config, filter, index, positionsById]); + + return ( +
+
+
+ {t('volunteers.filter.show')} + setFilter('all')}> + {t('volunteers.filter.all')} + + setFilter('overloaded')}> + {t('volunteers.filter.overloaded')} + + setFilter('unassigned')}> + {t('volunteers.filter.unassigned')} + +
+ +
+ {t('legend.title')} + + {t('volunteers.legend.confirmed')} + + + {t('volunteers.legend.proposed')} + + + {t('volunteers.legend.availability')} + +
+
+ +
+
+ {/* Ligne 1 */} +
+ {t('volunteers.count', { count: rows.length })} +
+ {FESTIVAL_DAYS.map((day) => ( +
+ {day.shortLabel} + {day.tag} +
+ ))} +
+ {t('volunteers.totalHours')} +
+ + {/* Ligne 2 */} +
+ {t('volunteers.name')} +
+ {FESTIVAL_DAYS.map((day) => + DAY_PARTS.map((part) => ( +
+ {part.label} +
+ )), + )} +
+ {t('volunteers.cumulative')} +
+ + {/* Lignes bénévoles */} + {rows.map((row) => ( + + ))} +
+ + {rows.length === 0 ? ( +
+ + + {t('volunteers.empty')} + +
+ ) : null} +
+ {t('volunteers.footnote', { + maxHours: state.config.maxDailyHours, + restHours: state.config.minRestHours, + })} +
+
+
+ ); +} + +function LegendItem({ + swatchClass, + children, +}: { + swatchClass: string; + children: React.ReactNode; +}) { + return ( + + + {children} + + ); +} + +type Row = { + volunteer: import('@/domain/types').Volunteer; + cells: Array<{ kind: 'confirmed' | 'proposed' | 'free' | 'none'; code: string; title: string }>; + total: number; + over: boolean; + empty: boolean; +}; + +function VolunteerRow({ row }: { row: Row }) { + return ( + <> +
+ + {row.volunteer.fullName} + + {row.over ? ( + + {t('volunteers.badge.overloaded')} + + ) : null} + {row.empty ? ( + + {t('volunteers.badge.unassigned')} + + ) : null} +
+ {row.cells.map((cell, i) => { + if (cell.kind === 'confirmed') { + return ( +
+ {cell.code} +
+ ); + } + if (cell.kind === 'proposed') { + return ( +
+ + {cell.code} + +
+ ); + } + if (cell.kind === 'free') { + return ( +
+ ); + } + return
; + })} +
+ {row.total} + {row.over ? ▲ : null} +
+ + ); +} diff --git a/frontend/src/pages/config/ConfigGrid.tsx b/frontend/src/pages/config/ConfigGrid.tsx new file mode 100644 index 0000000..eb4d156 --- /dev/null +++ b/frontend/src/pages/config/ConfigGrid.tsx @@ -0,0 +1,247 @@ +import { useMemo, useState } from 'react'; + +import { usePlanning } from '@/app/planning-store'; +import { DAY_PARTS, FESTIVAL_DAYS, dayPartHours } from '@/domain/festival'; +import { t } from '@/i18n'; +import { cx } from '@/lib/cx'; + +export function ConfigGrid() { + const { state, dispatch } = usePlanning(); + const [positionId, setPositionId] = useState(state.positions[0]?.id ?? ''); + const position = state.positions.find((p) => p.id === positionId) ?? state.positions[0]; + + const totals = useMemo(() => { + let slots = 0; + let min = 0; + let ideal = 0; + state.positions.forEach((p) => + p.needs.forEach((need) => { + slots += 1; + min += need.minStaff * dayPartHours(need.dayPartCode); + ideal += need.idealStaff * dayPartHours(need.dayPartCode); + }), + ); + return { slots, min, ideal }; + }, [state.positions]); + + const positionMin = position.needs.reduce( + (sum, need) => sum + need.minStaff * dayPartHours(need.dayPartCode), + 0, + ); + const positionIdeal = position.needs.reduce( + (sum, need) => sum + need.idealStaff * dayPartHours(need.dayPartCode), + 0, + ); + + return ( +
+
+
+ {t('config.grid.positionsHeader', { count: totals.slots })} +
+ {state.positions.map((entry) => { + const active = entry.id === position.id; + return ( + + ); + })} + +
+ +
+
+
+
+ {position.name} — {position.zone} +
+
+ {t(`position.category.${position.category.toLowerCase()}`)} · {position.location} +
+
+
+
+
+ {t('unit.hours', { count: positionMin })} +
+
+ {t('config.grid.minTotal')} +
+
+
+
+ {t('unit.hours', { count: positionIdeal })} +
+
+ {t('config.grid.idealTotal')} +
+
+
+
+ +
+ {FESTIVAL_DAYS.map((day) => ( +
+
+ {day.shortLabel} + {day.tag} +
+ {DAY_PARTS.map((part) => { + const need = position.needs.find( + (n) => n.dayId === day.id && n.dayPartCode === part.code, + ); + if (!need) { + return ( +
+ + {part.label} + + + {t('config.grid.closed')} + + +
+ ); + } + return ( +
+ {part.label} + + dispatch({ + type: 'setNeed', + positionId: position.id, + dayId: day.id, + dayPartCode: part.code, + which: 'min', + delta, + }) + } + /> + + dispatch({ + type: 'setNeed', + positionId: position.id, + dayId: day.id, + dayPartCode: part.code, + which: 'ideal', + delta, + }) + } + /> + +
+ ); + })} +
+ ))} +
+ +
+ {t('config.grid.note')} +
+
+
+ ); +} + +function NeedStepper({ + label, + value, + accent = false, + onChange, +}: { + label: string; + value: number; + accent?: boolean; + onChange: (delta: number) => void; +}) { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/frontend/src/pages/config/ConfigMail.tsx b/frontend/src/pages/config/ConfigMail.tsx new file mode 100644 index 0000000..f458872 --- /dev/null +++ b/frontend/src/pages/config/ConfigMail.tsx @@ -0,0 +1,203 @@ +import { usePlanning } from '@/app/planning-store'; +import { FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui'; +import { t } from '@/i18n'; +import { cx } from '@/lib/cx'; + +const MAIL_VARIABLES = [ + '{prenom}', + '{nom}', + '{nb_creneaux}', + '{nb_heures}', + '{poste}', + '{referent}', + '{lien_magique}', +]; + +export function ConfigMail() { + const { state, dispatch } = usePlanning(); + const { config } = state; + const setConfig = (patch: Partial) => dispatch({ type: 'setConfig', patch }); + + return ( +
+ + {t('config.mail.templateTitle')} +
+ + + +