Frontend : intégration du design Claude Design (6 écrans)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5154a3c61b
commit
ac469f0cd1
+36
-16
@@ -2,10 +2,21 @@
|
|||||||
|
|
||||||
React + Vite + TypeScript + Tailwind. Interface en français, code en anglais.
|
React + Vite + TypeScript + Tailwind. Interface en français, code en anglais.
|
||||||
|
|
||||||
Le design fait foi : projet Claude Design « Bénévoles Festival »
|
Intégration du design Claude Design « Bénévoles Festival »
|
||||||
(`Bénévoles Festival.dc.html`). Les écrans et composants réels sont dérivés de
|
(`Bénévoles Festival.dc.html`) : les 6 écrans du projet sont implémentés en
|
||||||
ce fichier et intégrés au fil des étapes. Le contenu actuel est un squelette
|
statique, pilotés par un **jeu de démonstration déterministe** (portage du
|
||||||
(routage, i18n, client API).
|
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
|
## Commandes
|
||||||
|
|
||||||
@@ -14,31 +25,40 @@ ce fichier et intégrés au fil des étapes. Le contenu actuel est un squelette
|
|||||||
| Installer | `npm install` |
|
| Installer | `npm install` |
|
||||||
| Dev (hot reload) | `npm run dev` → http://localhost:5173 |
|
| Dev (hot reload) | `npm run dev` → http://localhost:5173 |
|
||||||
| Build de production | `npm run build` |
|
| Build de production | `npm run build` |
|
||||||
| Prévisualiser le build | `npm run preview` |
|
|
||||||
| Lint | `npm run lint` |
|
| Lint | `npm run lint` |
|
||||||
| Vérif. de types | `npm run typecheck` |
|
| 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
|
En dev, Vite proxifie `/api` vers le backend (`VITE_API_TARGET`, défaut
|
||||||
`http://localhost:8000`).
|
`http://localhost:8000`).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Aucun texte visible en dur.** Tout passe par `src/i18n` (`t(key, params)`)
|
- **Aucun texte d'interface en dur.** Tout passe par `src/i18n` (`t(key, params)`)
|
||||||
et `src/i18n/locales/fr.json`, même en monolingue.
|
et `src/i18n/locales/fr.json`. Les libellés *de données* (nom d'un poste,
|
||||||
- Code, noms de fichiers et de variables en anglais.
|
d'une plage, d'une journée) sont portés par l'entité — ils viendront de l'API.
|
||||||
- Alias d'import `@/` → `src/`.
|
- Code, noms de fichiers et de variables en anglais. Alias `@/` → `src/`.
|
||||||
- Vocabulaire métier : voir le glossaire FR ↔ EN de `CLAUDE.md`.
|
- 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
|
## Arborescence
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── main.tsx # point d'entrée (React Query + Router)
|
├── main.tsx # point d'entrée (Router + PlanningProvider)
|
||||||
├── index.css # directives Tailwind
|
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── router.tsx # routes
|
│ ├── AppShell.tsx # en-tête + onglets + badge brouillon
|
||||||
│ └── AppLayout.tsx # coquille de mise en page (remplacée par le design)
|
│ ├── router.tsx # routes (tableau réutilisable)
|
||||||
├── pages/ # écrans
|
│ └── planning-store.tsx # état + actions (reducer) sur le jeu de démo
|
||||||
├── api/client.ts # client HTTP + ApiError
|
├── 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
|
└── i18n/ # t() + locales/fr.json
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Gestion des bénévoles</title>
|
<title>Gestion des bénévoles</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800;900&family=DM+Mono:wght@400;500&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+504
-27
@@ -8,7 +8,6 @@
|
|||||||
"name": "benevoles-frontend",
|
"name": "benevoles-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.59.0",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.27.0"
|
"react-router-dom": "^6.27.0"
|
||||||
@@ -28,6 +27,7 @@
|
|||||||
"globals": "^15.11.0",
|
"globals": "^15.11.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"typescript-eslint": "^8.8.1",
|
"typescript-eslint": "^8.8.1",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8"
|
||||||
@@ -637,6 +637,23 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||||
@@ -654,6 +671,23 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||||
@@ -671,6 +705,23 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||||
@@ -1492,32 +1543,6 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -4043,6 +4068,458 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc -b --noEmit"
|
"typecheck": "tsc -b --noEmit",
|
||||||
|
"smoke": "tsx --tsconfig tsconfig.app.json scripts/smoke.tsx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.59.0",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.27.0"
|
"react-router-dom": "^6.27.0"
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"globals": "^15.11.0",
|
"globals": "^15.11.0",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
|
"tsx": "^4.19.1",
|
||||||
"typescript": "^5.6.2",
|
"typescript": "^5.6.2",
|
||||||
"typescript-eslint": "^8.8.1",
|
"typescript-eslint": "^8.8.1",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8"
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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 (
|
|
||||||
<div className="min-h-screen bg-slate-50 text-slate-900">
|
|
||||||
<header className="border-b border-slate-200 bg-white px-6 py-4">
|
|
||||||
<h1 className="text-lg font-semibold">{t('app.title')}</h1>
|
|
||||||
</header>
|
|
||||||
<main className="p-6">
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<header className="sticky top-0 z-20 flex items-stretch border-b-[3px] border-ink bg-yellow">
|
||||||
|
<div className="flex flex-col justify-center border-r-[3px] border-ink px-4 py-2">
|
||||||
|
<div className="font-display text-[14px] font-black tracking-tight">
|
||||||
|
{state.config.festivalName}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
||||||
|
{t('header.meta', { count: state.volunteers.length })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex overflow-x-auto">
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<NavLink
|
||||||
|
key={tab.to}
|
||||||
|
to={tab.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cx(
|
||||||
|
'relative flex min-w-[112px] flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4',
|
||||||
|
'font-display text-[11.5px] font-bold uppercase tracking-wide',
|
||||||
|
isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
|
||||||
|
<span>{t(tab.labelKey)}</span>
|
||||||
|
{isActive ? (
|
||||||
|
<span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]">
|
||||||
|
{hasDraft ? (
|
||||||
|
<div className="flex items-center gap-2 border-2 border-ink bg-hatch-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-yellow">
|
||||||
|
{t('header.draft', { count: diff.length })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border-2 border-ink bg-green px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-white">
|
||||||
|
{t('header.published')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold">
|
||||||
|
MB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex flex-1 flex-col">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, 'present' | 'absent'>;
|
||||||
|
importedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| { type: 'selectSlot'; slot: string | null }
|
||||||
|
| { type: 'assign'; volunteerId: string }
|
||||||
|
| { type: 'unassign'; assignmentId: string }
|
||||||
|
| { type: 'publish' }
|
||||||
|
| { type: 'confirmSlot'; assignmentId: string }
|
||||||
|
| { type: 'setConfig'; patch: Partial<EditionConfig> }
|
||||||
|
| { type: 'setNeed'; positionId: string; dayId: string; dayPartCode: string; which: 'min' | 'ideal'; delta: number }
|
||||||
|
| { type: 'toggleSlot'; positionId: string; dayId: string; dayPartCode: string }
|
||||||
|
| { type: 'editVolunteer'; id: string; field: 'fullName' | 'phone'; value: string }
|
||||||
|
| { type: 'togglePreference'; id: string; category: Position['category'] }
|
||||||
|
| { type: 'runImport' }
|
||||||
|
| { type: 'setPresence'; assignmentId: string; value: 'present' | 'absent' };
|
||||||
|
|
||||||
|
function initState(): PlanningState {
|
||||||
|
const demo = buildDemoDataset();
|
||||||
|
return {
|
||||||
|
positions: demo.positions,
|
||||||
|
volunteers: demo.volunteers,
|
||||||
|
assignments: demo.assignments,
|
||||||
|
removed: demo.removed,
|
||||||
|
config: DEFAULT_CONFIG,
|
||||||
|
version: 6,
|
||||||
|
published: false,
|
||||||
|
nextSeq: demo.nextAssignmentSeq,
|
||||||
|
selectedSlot: slotKey('PARN', 'ven', 'S'),
|
||||||
|
presence: {},
|
||||||
|
importedCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function withNeeds(position: Position, needs: StaffingNeed[]): Position {
|
||||||
|
return { ...position, needs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reducer(state: PlanningState, action: Action): PlanningState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'selectSlot':
|
||||||
|
return { ...state, selectedSlot: action.slot };
|
||||||
|
|
||||||
|
case 'assign': {
|
||||||
|
if (!state.selectedSlot) return state;
|
||||||
|
const [positionId, dayId, dayPartCode] = state.selectedSlot.split('|');
|
||||||
|
const assignment: Assignment = {
|
||||||
|
id: `a${state.nextSeq}`,
|
||||||
|
volunteerId: action.volunteerId,
|
||||||
|
positionId,
|
||||||
|
dayId,
|
||||||
|
dayPartCode,
|
||||||
|
status: 'PROPOSED',
|
||||||
|
source: 'MANUAL',
|
||||||
|
locked: false,
|
||||||
|
pending: 'ADD',
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
assignments: [...state.assignments, assignment],
|
||||||
|
nextSeq: state.nextSeq + 1,
|
||||||
|
published: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'unassign': {
|
||||||
|
const target = state.assignments.find((a) => a.id === action.assignmentId);
|
||||||
|
if (!target) return state;
|
||||||
|
const volunteer = state.volunteers.find((v) => v.id === target.volunteerId);
|
||||||
|
const position = state.positions.find((p) => p.id === target.positionId);
|
||||||
|
const day = FESTIVAL_DAYS.find((d) => d.id === target.dayId);
|
||||||
|
const part = DAY_PARTS.find((d) => d.code === target.dayPartCode);
|
||||||
|
const record: RemovedAssignment = {
|
||||||
|
volunteerName: volunteer?.fullName ?? target.volunteerId,
|
||||||
|
positionName: position?.name ?? target.positionId,
|
||||||
|
zone: position?.zone ?? '',
|
||||||
|
dayShortLabel: day?.shortLabel ?? target.dayId,
|
||||||
|
dayPartLabel: part?.label ?? target.dayPartCode,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
assignments: state.assignments.filter((a) => a.id !== action.assignmentId),
|
||||||
|
removed: [...state.removed, record],
|
||||||
|
published: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'publish':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
assignments: state.assignments.map((a) => (a.pending ? { ...a, pending: null } : a)),
|
||||||
|
removed: [],
|
||||||
|
version: state.version + 1,
|
||||||
|
published: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'confirmSlot':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
assignments: state.assignments.map((a) =>
|
||||||
|
a.id === action.assignmentId ? { ...a, status: 'CONFIRMED' } : a,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'setConfig':
|
||||||
|
return { ...state, config: { ...state.config, ...action.patch } };
|
||||||
|
|
||||||
|
case 'setNeed':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
positions: state.positions.map((position) => {
|
||||||
|
if (position.id !== action.positionId) return position;
|
||||||
|
const needs = position.needs.map((need) => {
|
||||||
|
if (need.dayId !== action.dayId || need.dayPartCode !== action.dayPartCode) return need;
|
||||||
|
let { minStaff, idealStaff } = need;
|
||||||
|
if (action.which === 'min') {
|
||||||
|
minStaff = Math.max(0, Math.min(12, minStaff + action.delta));
|
||||||
|
} else {
|
||||||
|
idealStaff = Math.max(0, Math.min(14, idealStaff + action.delta));
|
||||||
|
}
|
||||||
|
if (idealStaff < minStaff) {
|
||||||
|
if (action.which === 'min') idealStaff = minStaff;
|
||||||
|
else minStaff = idealStaff;
|
||||||
|
}
|
||||||
|
return { ...need, minStaff, idealStaff };
|
||||||
|
});
|
||||||
|
return withNeeds(position, needs);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'toggleSlot':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
positions: state.positions.map((position) => {
|
||||||
|
if (position.id !== action.positionId) return position;
|
||||||
|
const exists = position.needs.some(
|
||||||
|
(n) => n.dayId === action.dayId && n.dayPartCode === action.dayPartCode,
|
||||||
|
);
|
||||||
|
let needs: StaffingNeed[];
|
||||||
|
if (exists) {
|
||||||
|
needs = position.needs.filter(
|
||||||
|
(n) => !(n.dayId === action.dayId && n.dayPartCode === action.dayPartCode),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
needs = [
|
||||||
|
...position.needs,
|
||||||
|
{ dayId: action.dayId, dayPartCode: action.dayPartCode, minStaff: 2, idealStaff: 3 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const order = DAY_PARTS.map((d) => d.code);
|
||||||
|
needs = [...needs].sort((a, b) => {
|
||||||
|
if (a.dayId !== b.dayId) {
|
||||||
|
return (
|
||||||
|
FESTIVAL_DAYS.findIndex((d) => d.id === a.dayId) -
|
||||||
|
FESTIVAL_DAYS.findIndex((d) => d.id === b.dayId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return order.indexOf(a.dayPartCode) - order.indexOf(b.dayPartCode);
|
||||||
|
});
|
||||||
|
return withNeeds(position, needs);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'editVolunteer':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
volunteers: state.volunteers.map((v) => {
|
||||||
|
if (v.id !== action.id) return v;
|
||||||
|
if (action.field === 'phone') return { ...v, phone: action.value };
|
||||||
|
const parts = action.value.trim().split(/\s+/);
|
||||||
|
const firstName = parts[0] ?? '';
|
||||||
|
const lastName = parts.slice(1).join(' ');
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
fullName: action.value,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
initials: (firstName[0] ?? '') + (lastName[0] ?? ''),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'togglePreference':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
volunteers: state.volunteers.map((v) => {
|
||||||
|
if (v.id !== action.id) return v;
|
||||||
|
const has = v.preferredCategories.includes(action.category);
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
preferredCategories: has
|
||||||
|
? v.preferredCategories.filter((c) => c !== action.category)
|
||||||
|
: [...v.preferredCategories, action.category],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'runImport': {
|
||||||
|
const base = state.volunteers.length;
|
||||||
|
const names: Array<[string, string]> = [
|
||||||
|
['Océane', 'Thibault'],
|
||||||
|
['Samir', 'Amrani'],
|
||||||
|
['Lucie', 'Charpentier'],
|
||||||
|
['Noé', 'Delaunay'],
|
||||||
|
];
|
||||||
|
const added: Volunteer[] = names.map(([firstName, lastName], i) => {
|
||||||
|
const availability = new Set<string>();
|
||||||
|
FESTIVAL_DAYS.forEach((day) =>
|
||||||
|
DAY_PARTS.forEach((part) => {
|
||||||
|
if ((i + day.id.length + part.startHour) % 3) {
|
||||||
|
availability.add(availabilityKey(day.id, part.code));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const categories = ['BAR', 'TICKETING', 'WELCOME', 'PARKING', 'CLEANING', 'CATERING', 'SETUP', 'STAGE'] as const;
|
||||||
|
return {
|
||||||
|
id: `v${base + i}`,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
fullName: `${firstName} ${lastName}`,
|
||||||
|
initials: firstName[0] + lastName[0],
|
||||||
|
phone: `06 74 12 0${i} ${30 + i}`,
|
||||||
|
availability,
|
||||||
|
preferredCategories: [categories[i % categories.length]],
|
||||||
|
imported: true,
|
||||||
|
neverAssigned: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
volunteers: [...state.volunteers, ...added],
|
||||||
|
importedCount: state.importedCount + added.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'setPresence':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
presence: { ...state.presence, [action.assignmentId]: action.value },
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlanningContextValue {
|
||||||
|
state: PlanningState;
|
||||||
|
dispatch: React.Dispatch<Action>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlanningContext = createContext<PlanningContextValue | null>(null);
|
||||||
|
|
||||||
|
export function PlanningProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [state, dispatch] = useReducer(reducer, undefined, initState);
|
||||||
|
const value = useMemo(() => ({ state, dispatch }), [state]);
|
||||||
|
return <PlanningContext.Provider value={value}>{children}</PlanningContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- hook colocalisé avec son provider
|
||||||
|
export function usePlanning(): PlanningContextValue {
|
||||||
|
const ctx = useContext(PlanningContext);
|
||||||
|
if (!ctx) throw new Error('usePlanning doit être utilisé dans <PlanningProvider>');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,14 +1,26 @@
|
|||||||
import { createBrowserRouter } from 'react-router-dom';
|
import { Navigate, type RouteObject } from 'react-router-dom';
|
||||||
|
|
||||||
import { AppLayout } from './AppLayout';
|
import { AppShell } from './AppShell';
|
||||||
import { DashboardPage } from '@/pages/DashboardPage';
|
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
|
export const routes: RouteObject[] = [
|
||||||
// (« Bénévoles Festival.dc.html ») et ajoutés au fil des étapes.
|
|
||||||
export const router = createBrowserRouter([
|
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <AppLayout />,
|
element: <AppShell />,
|
||||||
children: [{ index: true, element: <DashboardPage /> }],
|
children: [
|
||||||
|
{ index: true, element: <Navigate to="/grille" replace /> },
|
||||||
|
{ path: 'grille', element: <GrillePage /> },
|
||||||
|
{ path: 'benevoles', element: <VolunteerViewPage /> },
|
||||||
|
{ path: 'publication', element: <PublicationPage /> },
|
||||||
|
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
||||||
|
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
||||||
|
{ path: 'configuration', element: <ConfigPage /> },
|
||||||
|
{ path: '*', element: <Navigate to="/grille" replace /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'border-[3px] border-ink bg-paper',
|
||||||
|
shadow === 'md' && 'shadow-brutal-md',
|
||||||
|
shadow === 'lg' && 'shadow-brutal-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PanelHeader({
|
||||||
|
children,
|
||||||
|
hatched = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
hatched?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'px-3 py-2 font-display text-[12px] font-black uppercase tracking-wide text-cream',
|
||||||
|
hatched ? 'bg-hatch-ink text-yellow' : 'bg-ink',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BtnVariant = 'solid' | 'ghost' | 'danger' | 'dark';
|
||||||
|
|
||||||
|
export function Btn({
|
||||||
|
variant = 'solid',
|
||||||
|
className,
|
||||||
|
type = 'button',
|
||||||
|
...props
|
||||||
|
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: BtnVariant }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
className={cx(
|
||||||
|
'inline-flex cursor-pointer items-center justify-center gap-2 border-2 border-ink px-3 py-2',
|
||||||
|
'font-display text-[11px] font-extrabold uppercase tracking-wide transition-colors',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
variant === 'solid' && 'bg-yellow text-ink shadow-brutal-sm hover:bg-green hover:text-white',
|
||||||
|
variant === 'ghost' && 'bg-cream text-ink hover:bg-yellow',
|
||||||
|
variant === 'danger' && 'bg-cream text-ink hover:bg-orange',
|
||||||
|
variant === 'dark' && 'bg-ink text-cream hover:bg-yellow hover:text-ink',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chip({
|
||||||
|
active,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cx(
|
||||||
|
'cursor-pointer border-2 border-ink px-[9px] py-[6px] font-display text-[10.5px] tracking-wide transition-colors',
|
||||||
|
active
|
||||||
|
? 'bg-ink font-extrabold text-cream'
|
||||||
|
: 'bg-cream font-semibold text-ink hover:bg-yellow',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type BadgeTone = 'confirmed' | 'proposed' | 'success' | 'info' | 'warn' | 'danger' | 'neutral';
|
||||||
|
|
||||||
|
export function Badge({ tone, children }: { tone: BadgeTone; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'inline-flex items-center border-2 px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide',
|
||||||
|
tone === 'confirmed' && 'border-ink bg-green text-white',
|
||||||
|
tone === 'proposed' && 'border-ink border-dashed bg-paper text-ink',
|
||||||
|
tone === 'success' && 'border-ink bg-green text-white',
|
||||||
|
tone === 'info' && 'border-ink bg-blue text-ink',
|
||||||
|
tone === 'warn' && 'border-ink bg-yellow-soft text-ink',
|
||||||
|
tone === 'danger' && 'border-ink bg-orange text-ink',
|
||||||
|
tone === 'neutral' && 'border-ink bg-cream text-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stepper({
|
||||||
|
value,
|
||||||
|
onDecrement,
|
||||||
|
onIncrement,
|
||||||
|
suffix,
|
||||||
|
}: {
|
||||||
|
value: ReactNode;
|
||||||
|
onDecrement: () => void;
|
||||||
|
onIncrement: () => void;
|
||||||
|
suffix?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDecrement}
|
||||||
|
className="h-7 w-7 cursor-pointer border-2 border-ink bg-cream font-display text-[14px] font-extrabold hover:bg-orange"
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[46px] text-center font-mono text-[16px] font-medium">
|
||||||
|
{value}
|
||||||
|
{suffix ? ` ${suffix}` : null}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onIncrement}
|
||||||
|
className="h-7 w-7 cursor-pointer border-2 border-ink bg-yellow font-display text-[14px] font-extrabold hover:bg-green hover:text-white"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
value,
|
||||||
|
label,
|
||||||
|
tone = 'paper',
|
||||||
|
}: {
|
||||||
|
value: ReactNode;
|
||||||
|
label: string;
|
||||||
|
tone?: 'paper' | 'yellow' | 'green';
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'border-[3px] border-ink px-3 py-[11px] shadow-brutal-lg',
|
||||||
|
tone === 'paper' && 'bg-paper',
|
||||||
|
tone === 'yellow' && 'bg-yellow',
|
||||||
|
tone === 'green' && 'bg-green text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-[34px] font-black leading-none">{value}</div>
|
||||||
|
<div className="mt-1 font-display text-[9.5px] font-extrabold uppercase leading-tight tracking-wide">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldLabel({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="mb-[5px] block font-display text-[9px] font-extrabold uppercase tracking-widest">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextInput({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
mono = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
mono?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.<CODE>`).
|
||||||
|
|
||||||
|
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<string, string | number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<string, Position>;
|
||||||
|
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');
|
||||||
|
}
|
||||||
@@ -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<string, Assignment[]>;
|
||||||
|
/** Affectations actives par bénévole. */
|
||||||
|
byVolunteer: Map<string, Assignment[]>;
|
||||||
|
/** Heures actives cumulées par bénévole. */
|
||||||
|
totalHours: Map<string, number>;
|
||||||
|
/** Heures actives par (bénévole, journée) — clé `volunteerId|dayId`. */
|
||||||
|
dailyHours: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function indexPlanning(assignments: Assignment[], volunteers: Volunteer[]): PlanningIndex {
|
||||||
|
const bySlot = new Map<string, Assignment[]>();
|
||||||
|
const byVolunteer = new Map<string, Assignment[]>();
|
||||||
|
const totalHours = new Map<string, number>();
|
||||||
|
const dailyHours = new Map<string, number>();
|
||||||
|
|
||||||
|
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<EditionConfig, 'maxDailyHours' | 'minRestHours'>,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
@@ -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<string>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,46 +1,223 @@
|
|||||||
{
|
{
|
||||||
"app.title": "Gestion des bénévoles",
|
"filter.all": "TOUS",
|
||||||
"app.loading": "Chargement…",
|
"legend.title": "LÉGENDE",
|
||||||
"app.error.generic": "Une erreur est survenue",
|
"action.close": "Fermer",
|
||||||
"app.error.network": "Impossible de joindre le serveur",
|
"action.remove": "Retirer",
|
||||||
|
|
||||||
"nav.dashboard": "Tableau de bord",
|
"unit.hours": "{count} h",
|
||||||
"nav.volunteers": "Bénévoles",
|
"unit.days": "{count} j",
|
||||||
"nav.positions": "Postes",
|
"time.now": "à l’instant",
|
||||||
"nav.shifts": "Créneaux",
|
"time.yesterday": "hier 18:42",
|
||||||
"nav.assignments": "Affectations",
|
|
||||||
"nav.availabilities": "Disponibilités",
|
|
||||||
"nav.import": "Import",
|
|
||||||
|
|
||||||
"role.coordinator": "Coordinateur",
|
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
||||||
"role.lead": "Référent",
|
"header.draft": "BROUILLON · {count} MODIF.",
|
||||||
"role.volunteer": "Bénévole",
|
"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.proposed": "Proposée",
|
||||||
"assignment.status.confirmed": "Confirmée",
|
"assignment.status.confirmed": "Confirmée",
|
||||||
"assignment.status.declined": "Refusée",
|
"assignment.status.declined": "Refusée",
|
||||||
"assignment.status.cancelled": "Annulé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.bar": "Bar",
|
||||||
"position.category.ticketing": "Billetterie",
|
"position.category.ticketing": "Billetterie",
|
||||||
"position.category.welcome": "Accueil",
|
"position.category.welcome": "Accueil",
|
||||||
"position.category.parking": "Parking",
|
"position.category.parking": "Parking",
|
||||||
"position.category.cleaning": "Nettoyage",
|
"position.category.cleaning": "Propreté",
|
||||||
"position.category.catering": "Restauration",
|
"position.category.catering": "Catering",
|
||||||
"position.category.setup": "Montage",
|
"position.category.setup": "Montage",
|
||||||
"position.category.stage": "Scène",
|
"position.category.stage": "Régie",
|
||||||
"position.category.other": "Autre",
|
"position.category.other": "Autre",
|
||||||
|
|
||||||
"validation.SHIFT_OVERLAP": "Deux créneaux se chevauchent",
|
"validation.SHIFT_OVERLAP": "Déjà affecté sur {position} au même créneau",
|
||||||
"validation.OUTSIDE_AVAILABILITY": "En dehors des disponibilités déclarées",
|
"validation.OUTSIDE_AVAILABILITY": "Hors disponibilités déclarées",
|
||||||
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectations sur la journée",
|
"validation.DAILY_HOURS_EXCEEDED": "Plus de {max} h le {day} ({total} h)",
|
||||||
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux",
|
"validation.INSUFFICIENT_REST": "Moins de {min} h entre deux créneaux ({rest} h)",
|
||||||
"validation.MISSING_REQUIRED_SKILL": "Compétence requise manquante"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,32 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Concatène des classes conditionnelles (mini remplaçant de `clsx`). */
|
||||||
|
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||||
|
return parts.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
+6
-10
@@ -1,21 +1,17 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { router } from '@/app/router';
|
import { PlanningProvider } from '@/app/planning-store';
|
||||||
|
import { routes } from '@/app/router';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const router = createBrowserRouter(routes);
|
||||||
defaultOptions: {
|
|
||||||
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PlanningProvider>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
</QueryClientProvider>
|
</PlanningProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
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<string, boolean> = {};
|
||||||
|
const dailyHours: Record<string, number> = {};
|
||||||
|
const totalHours: Record<string, number> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -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<string, Array<[string, number, number]>>;
|
||||||
|
|
||||||
|
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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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<ConfigTab>('grid');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b-2 border-ink bg-sand px-4 py-[9px]">
|
||||||
|
<span className="mr-1 font-mono text-[9px] opacity-60">{t('config.admin')}</span>
|
||||||
|
{TABS.map((entry) => (
|
||||||
|
<Chip key={entry.id} active={tab === entry.id} onClick={() => setTab(entry.id)}>
|
||||||
|
{t(entry.labelKey)}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
<span className="ml-auto border-2 border-ink bg-blue px-2 py-[5px] font-display text-[9.5px] font-extrabold uppercase">
|
||||||
|
{t('config.reserved')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'grid' ? <ConfigGrid /> : null}
|
||||||
|
{tab === 'volunteers' ? <ConfigVolunteers /> : null}
|
||||||
|
{tab === 'mail' ? <ConfigMail /> : null}
|
||||||
|
{tab === 'rules' ? <ConfigRules /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { t } from '@/i18n';
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h2 className="text-base font-medium text-slate-700">{t('nav.dashboard')}</h2>
|
|
||||||
<p className="mt-2 text-sm text-slate-500">
|
|
||||||
{t('app.title')} — {import.meta.env.MODE}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<DayFilter>('all');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('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 (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
{/* Barre de filtres + légende */}
|
||||||
|
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
|
||||||
|
<div className="flex items-center gap-[6px]">
|
||||||
|
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
|
||||||
|
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
|
||||||
|
{t('filter.all')}
|
||||||
|
</Chip>
|
||||||
|
{FESTIVAL_DAYS.map((day) => (
|
||||||
|
<Chip key={day.id} active={dayFilter === day.id} onClick={() => setDayFilter(day.id)}>
|
||||||
|
{day.shortLabel}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-[6px]">
|
||||||
|
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.position')}</span>
|
||||||
|
<Chip active={categoryFilter === 'all'} onClick={() => setCategoryFilter('all')}>
|
||||||
|
{t('filter.all')}
|
||||||
|
</Chip>
|
||||||
|
{DEMO_CATEGORIES.map((category) => (
|
||||||
|
<Chip
|
||||||
|
key={category}
|
||||||
|
active={categoryFilter === category}
|
||||||
|
onClick={() => setCategoryFilter(category)}
|
||||||
|
>
|
||||||
|
{t(categoryLabelKey(category)).toUpperCase()}
|
||||||
|
</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
||||||
|
<span className="font-mono text-[8.5px] opacity-60">{t('grille.legend.title')}</span>
|
||||||
|
<LegendItem swatchClass="bg-hatch-orange" glyph="▲">
|
||||||
|
{t('grille.legend.critical')}
|
||||||
|
</LegendItem>
|
||||||
|
<LegendItem swatchClass="bg-yellow-soft" glyph="◧">
|
||||||
|
{t('grille.legend.incomplete')}
|
||||||
|
</LegendItem>
|
||||||
|
<LegendItem swatchClass="bg-paper" glyph="✓">
|
||||||
|
{t('grille.legend.complete')}
|
||||||
|
</LegendItem>
|
||||||
|
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
|
||||||
|
<span className="h-0 w-0 border-l-[9px] border-t-[9px] border-l-transparent border-t-blue-strong" />
|
||||||
|
{t('grille.legend.unpublished')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 items-stretch">
|
||||||
|
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
||||||
|
<div
|
||||||
|
className="grid w-max border-b-[3px] border-ink"
|
||||||
|
style={{ gridTemplateColumns }}
|
||||||
|
>
|
||||||
|
{/* Ligne 1 : coin + entêtes de journée */}
|
||||||
|
<div className="sticky left-0 top-0 z-[6] flex items-center border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
|
||||||
|
{t('grille.positionsCount', { count: state.positions.length })}
|
||||||
|
</div>
|
||||||
|
{shownDays.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day.id}
|
||||||
|
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
|
||||||
|
style={{ gridColumn: 'span 4' }}
|
||||||
|
>
|
||||||
|
<span className="font-display text-[12px] font-black tracking-tight">
|
||||||
|
{day.shortLabel}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[8.5px] font-medium text-yellow">{day.tag}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Ligne 2 : légende de colonne + libellés de plage */}
|
||||||
|
<div className="sticky left-0 top-[27px] z-[6] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
|
||||||
|
{t('grille.assignedRequired')}
|
||||||
|
</div>
|
||||||
|
{shownDays.map((day) =>
|
||||||
|
DAY_PARTS.map((part) => (
|
||||||
|
<div
|
||||||
|
key={`${day.id}-${part.code}`}
|
||||||
|
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
|
||||||
|
>
|
||||||
|
{part.label}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lignes de postes */}
|
||||||
|
{shownPositions.map((position) => (
|
||||||
|
<PositionRow
|
||||||
|
key={position.id}
|
||||||
|
position={position}
|
||||||
|
shownDays={shownDays}
|
||||||
|
bySlot={index.bySlot}
|
||||||
|
selectedSlot={state.selectedSlot}
|
||||||
|
onSelect={(slot) => dispatch({ type: 'selectSlot', slot })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-[14px] py-[10px] font-mono text-[9.5px] leading-relaxed opacity-60">
|
||||||
|
{t('grille.footnote')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
||||||
|
{state.selectedSlot ? (
|
||||||
|
<AssignmentPanel slot={state.selectedSlot} index={index} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3 px-[18px] py-[26px]">
|
||||||
|
<div className="h-11 w-11 border-[3px] border-ink bg-yellow shadow-brutal-md" />
|
||||||
|
<div className="font-display text-[14px] font-extrabold leading-tight">
|
||||||
|
{t('grille.panel.empty.title')}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10.5px] leading-relaxed opacity-65">
|
||||||
|
{t('grille.panel.empty.body')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegendItem({
|
||||||
|
swatchClass,
|
||||||
|
glyph,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
swatchClass: string;
|
||||||
|
glyph: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'flex h-4 w-[22px] items-center justify-center border-[1.5px] border-ink font-mono text-[9px] font-bold',
|
||||||
|
swatchClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PositionRow({
|
||||||
|
position,
|
||||||
|
shownDays,
|
||||||
|
bySlot,
|
||||||
|
selectedSlot,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
position: import('@/domain/types').Position;
|
||||||
|
shownDays: typeof FESTIVAL_DAYS;
|
||||||
|
bySlot: Map<string, import('@/domain/types').Assignment[]>;
|
||||||
|
selectedSlot: string | null;
|
||||||
|
onSelect: (slot: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="sticky left-0 z-[2] flex h-9 flex-col justify-center gap-[1px] border-b border-r-[3px] border-ink bg-cream px-2">
|
||||||
|
<span className="font-display text-[12px] font-bold tracking-tight">{position.name}</span>
|
||||||
|
<span className="font-mono text-[8.5px] opacity-55">{position.zone}</span>
|
||||||
|
</div>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="h-9 border-b border-r border-ink bg-hatch-sand"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
onClick={() => 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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[14px] font-bold">{count}</span>
|
||||||
|
<span className="font-mono text-[10px] opacity-60">/{need.idealStaff}</span>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'font-mono text-[10px] font-bold',
|
||||||
|
!isCrit && !isInc && 'text-green',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{glyph}
|
||||||
|
</span>
|
||||||
|
{pending ? (
|
||||||
|
<span className="absolute right-0 top-0 h-0 w-0 border-l-[9px] border-t-[9px] border-l-transparent border-t-blue-strong" />
|
||||||
|
) : null}
|
||||||
|
{selected ? (
|
||||||
|
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-wrap items-start gap-[26px] p-6">
|
||||||
|
<PhoneFrame time={t('mobileLead.statusTime')}>
|
||||||
|
<div className="bg-ink px-4 pb-[15px] pt-3 text-cream">
|
||||||
|
<div className="font-mono text-[10px] text-yellow">
|
||||||
|
{t('mobileLead.kicker', { name: leadDemo.fullName })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[5px] font-display text-[27px] font-black leading-tight tracking-tight">
|
||||||
|
{`${position.name} — ${position.zone}`.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="font-display text-[13px] font-semibold opacity-80">
|
||||||
|
{position.zone} · {position.location}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex items-stretch gap-2">
|
||||||
|
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||||
|
{t('mobileLead.inProgress', { hours: t('mobileLead.shiftHours') })}
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
||||||
|
{t('mobileLead.present', { present: presentCount, total: team.length })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{absentCount > 0 ? (
|
||||||
|
<div className="border-b-[3px] border-ink bg-hatch-orange px-[14px] py-[11px]">
|
||||||
|
<div className="font-display text-[15px] font-black leading-tight">
|
||||||
|
{t('mobileLead.absenceAlert', { count: absentCount })}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-[9px] min-h-[52px] w-full cursor-pointer border-[3px] border-ink bg-ink font-display text-[14px] font-black text-cream hover:bg-yellow hover:text-ink"
|
||||||
|
>
|
||||||
|
{t('mobileLead.requestBackup')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-[11px] px-[14px] pb-[26px] pt-[13px]">
|
||||||
|
<div className="font-display text-[12px] font-black uppercase tracking-widest">
|
||||||
|
{t('mobileLead.teamTitle')}
|
||||||
|
</div>
|
||||||
|
{team.map((member) => (
|
||||||
|
<div key={member.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-md">
|
||||||
|
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
|
||||||
|
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold">
|
||||||
|
{member.initials}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-display text-[17px] font-extrabold leading-tight">
|
||||||
|
{member.name}
|
||||||
|
</span>
|
||||||
|
<span className="block font-mono text-[10.5px] opacity-65">
|
||||||
|
{member.status} · {member.phone}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{member.present ? (
|
||||||
|
<span className="flex-none border-2 border-ink bg-green px-[7px] py-[5px] font-display text-[11px] font-extrabold text-white">
|
||||||
|
{t('mobileLead.badgePresent')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{member.absent ? (
|
||||||
|
<span className="flex-none border-2 border-ink bg-orange px-[7px] py-[5px] font-display text-[11px] font-extrabold">
|
||||||
|
{t('mobileLead.badgeAbsent')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 border-t-[3px] border-ink">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'present' })
|
||||||
|
}
|
||||||
|
className={cx(
|
||||||
|
'min-h-[56px] cursor-pointer border-r-[3px] border-ink bg-cream font-display text-[15px] font-black hover:bg-green hover:text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('mobileLead.markPresent')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'absent' })
|
||||||
|
}
|
||||||
|
className="min-h-[56px] cursor-pointer bg-cream font-display text-[15px] font-black hover:bg-orange"
|
||||||
|
>
|
||||||
|
{t('mobileLead.markAbsent')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70">
|
||||||
|
{t('mobileLead.nextSlot')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PhoneFrame>
|
||||||
|
|
||||||
|
<DesignNote>{t('mobileLead.designNote')}</DesignNote>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-wrap items-start gap-[26px] p-6">
|
||||||
|
<PhoneFrame time={t('mobileVol.statusTime')}>
|
||||||
|
<div className="bg-ink px-4 pb-4 pt-3 text-cream">
|
||||||
|
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
||||||
|
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
|
||||||
|
{t('mobileVol.greeting', { name: me.firstName.toUpperCase() })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||||
|
{t('mobileVol.slots', { count: slots.length })}
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
||||||
|
{t('mobileVol.hours', { count: totalHours })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{toConfirm > 0 ? (
|
||||||
|
<div className="border-b-[3px] border-ink bg-orange px-4 py-[11px] font-display text-[14px] font-black leading-snug">
|
||||||
|
{t('mobileVol.toConfirm', { count: toConfirm })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<div key={slot.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||||
|
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
|
||||||
|
<span className="font-display text-[15px] font-black">{slot.day}</span>
|
||||||
|
<span className="ml-auto font-mono text-[17px] font-bold text-yellow">
|
||||||
|
{slot.hours}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-[11px]">
|
||||||
|
<div className="font-display text-[25px] font-black leading-tight tracking-tight">
|
||||||
|
{slot.position}
|
||||||
|
</div>
|
||||||
|
<div className="font-display text-[14px] font-semibold opacity-75">{slot.zone}</div>
|
||||||
|
<div className="mt-[9px] flex flex-col gap-[5px] border-t-2 border-ink pt-[9px]">
|
||||||
|
<div className="font-mono text-[12px]">
|
||||||
|
{t('mobileVol.location', { location: slot.location })}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[12px]">
|
||||||
|
{t('mobileVol.lead', { name: slot.leadName })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-[11px] flex gap-[9px]">
|
||||||
|
<a
|
||||||
|
href={`tel:${slot.leadPhoneRaw}`}
|
||||||
|
className="flex min-h-[52px] flex-none items-center justify-center border-[3px] border-ink bg-cream px-[14px] font-display text-[13px] font-extrabold no-underline"
|
||||||
|
>
|
||||||
|
{t('mobileVol.call', { phone: slot.leadPhone })}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{slot.confirmed ? (
|
||||||
|
<div className="mt-[11px] flex min-h-[62px] items-center justify-center gap-[9px] border-[3px] border-ink bg-green font-display text-[17px] font-black text-white">
|
||||||
|
{t('mobileVol.confirmed')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: 'confirmSlot', assignmentId: slot.assignmentId })}
|
||||||
|
className="mt-[11px] min-h-[62px] w-full cursor-pointer border-[3px] border-ink bg-yellow font-display text-[19px] font-black shadow-brutal-md hover:bg-green hover:text-white"
|
||||||
|
>
|
||||||
|
{t('mobileVol.confirm')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="border-[3px] border-ink bg-blue p-3 shadow-brutal-lg">
|
||||||
|
<div className="font-display text-[12px] font-black uppercase tracking-widest">
|
||||||
|
{t('mobileVol.orgMessageTitle')}
|
||||||
|
</div>
|
||||||
|
<p className="mt-[7px] font-display text-[14px] font-semibold leading-relaxed">
|
||||||
|
{t('mobileVol.orgMessageBody')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 font-mono text-[10px] opacity-70">
|
||||||
|
{t('mobileVol.orgMessageMeta')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PhoneFrame>
|
||||||
|
|
||||||
|
<DesignNote>{t('mobileVol.designNote')}</DesignNote>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="grid items-start gap-[18px] p-[18px] lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
|
||||||
|
<div>
|
||||||
|
<div className="mb-[14px] grid grid-cols-3 gap-[10px]">
|
||||||
|
<StatCard value={diff.length} label={t('publication.stat.changes')} tone="yellow" />
|
||||||
|
<StatCard value={concerned} label={t('publication.stat.concerned')} />
|
||||||
|
<StatCard value={`v${state.version}`} label={t('publication.stat.version', { when })} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Panel shadow="lg">
|
||||||
|
<div className="flex items-center gap-[10px] bg-hatch-ink px-3 py-[9px] text-yellow">
|
||||||
|
<span className="font-display text-[13px] font-black tracking-wide">
|
||||||
|
{t('publication.draftTitle')}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto font-mono text-[9.5px] opacity-90">
|
||||||
|
{t('publication.diffWith', { version: state.version })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{diff.length === 0 ? (
|
||||||
|
<div className="flex items-center gap-3 px-[14px] py-[22px]">
|
||||||
|
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-[3px] border-ink bg-green font-display text-[15px] font-extrabold text-white">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[13px] font-extrabold leading-snug">
|
||||||
|
{t('publication.noDiff')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
diff.map((entry, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-[10px] border-b border-sand-line px-3 py-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'flex-none border-2 border-ink px-[6px] py-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide',
|
||||||
|
entry.kind === 'ADD' ? 'bg-green text-white' : 'bg-orange text-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{entry.kind === 'ADD' ? t('publication.add') : t('publication.remove')}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 font-display text-[12.5px] font-bold">
|
||||||
|
{entry.volunteerName}
|
||||||
|
<span className="block font-mono text-[9.5px] opacity-65">
|
||||||
|
{entry.positionName} {entry.zone} · {entry.dayShortLabel} · {entry.dayPartLabel}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Panel shadow="lg" className="mb-[14px]">
|
||||||
|
<PanelHeader>{t('publication.mailPreviewTitle')}</PanelHeader>
|
||||||
|
<div className="px-[14px] py-[13px]">
|
||||||
|
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
|
||||||
|
{t('publication.mailFrom', { from: state.config.mailFrom })}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
|
||||||
|
{t('publication.mailTo', { count: concerned })}
|
||||||
|
</div>
|
||||||
|
<div className="my-2 border-t-2 border-ink pt-[9px] font-display text-[13.5px] font-extrabold leading-snug">
|
||||||
|
{t('publication.mailSubjectPrefix', { subject: state.config.mailSubject })}
|
||||||
|
</div>
|
||||||
|
<p className="font-display text-[12.5px] leading-relaxed">
|
||||||
|
{t('publication.mailGreeting', { name: sampleFirstName })}
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
{t('publication.mailBody', { slots: sampleSlots, hours: sampleHours })}
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
{t('publication.mailBody2')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-[11px] inline-block border-2 border-ink bg-yellow px-3 py-[9px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal">
|
||||||
|
{t('publication.mailCta')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[11px] font-mono text-[9px] leading-relaxed opacity-55">
|
||||||
|
{t('publication.magicLinkNote', { days: state.config.magicLinkDays })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{diff.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: 'publish' })}
|
||||||
|
className="w-full cursor-pointer border-[3px] border-ink bg-orange p-4 font-display text-[16px] font-black uppercase tracking-wide shadow-brutal-lg hover:bg-yellow"
|
||||||
|
>
|
||||||
|
{t('publication.publishButton', { count: concerned })}
|
||||||
|
</button>
|
||||||
|
<div className="mt-[9px] font-mono text-[9.5px] leading-relaxed opacity-60">
|
||||||
|
{t('publication.publishNote', { version: state.version + 1 })}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="border-[3px] border-ink bg-green p-4 font-display text-[15px] font-black leading-snug text-white shadow-brutal-lg">
|
||||||
|
{t('publication.publishedState', { version: state.version, when })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<VolunteerFilter>('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 (
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
|
||||||
|
<div className="flex items-center gap-[6px]">
|
||||||
|
<span className="font-mono text-[9px] opacity-60">{t('volunteers.filter.show')}</span>
|
||||||
|
<Chip active={filter === 'all'} onClick={() => setFilter('all')}>
|
||||||
|
{t('volunteers.filter.all')}
|
||||||
|
</Chip>
|
||||||
|
<Chip active={filter === 'overloaded'} onClick={() => setFilter('overloaded')}>
|
||||||
|
{t('volunteers.filter.overloaded')}
|
||||||
|
</Chip>
|
||||||
|
<Chip active={filter === 'unassigned'} onClick={() => setFilter('unassigned')}>
|
||||||
|
{t('volunteers.filter.unassigned')}
|
||||||
|
</Chip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
||||||
|
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
|
||||||
|
<LegendItem swatchClass="bg-blue border-ink">
|
||||||
|
{t('volunteers.legend.confirmed')}
|
||||||
|
</LegendItem>
|
||||||
|
<LegendItem swatchClass="bg-paper border-ink border-dashed">
|
||||||
|
{t('volunteers.legend.proposed')}
|
||||||
|
</LegendItem>
|
||||||
|
<LegendItem swatchClass="bg-dots-sand border-sand-line">
|
||||||
|
{t('volunteers.legend.availability')}
|
||||||
|
</LegendItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
||||||
|
<div
|
||||||
|
className="grid w-max border-b-[3px] border-ink"
|
||||||
|
style={{ gridTemplateColumns: '210px repeat(16, 62px) 84px' }}
|
||||||
|
>
|
||||||
|
{/* Ligne 1 */}
|
||||||
|
<div className="sticky left-0 top-0 z-[7] flex items-center border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
|
||||||
|
{t('volunteers.count', { count: rows.length })}
|
||||||
|
</div>
|
||||||
|
{FESTIVAL_DAYS.map((day) => (
|
||||||
|
<div
|
||||||
|
key={day.id}
|
||||||
|
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
|
||||||
|
style={{ gridColumn: 'span 4' }}
|
||||||
|
>
|
||||||
|
<span className="font-display text-[12px] font-black">{day.shortLabel}</span>
|
||||||
|
<span className="font-mono text-[8.5px] font-medium text-yellow">{day.tag}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="sticky right-0 top-0 z-[7] flex items-center border-b-2 border-l-[3px] border-ink bg-yellow px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider">
|
||||||
|
{t('volunteers.totalHours')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ligne 2 */}
|
||||||
|
<div className="sticky left-0 top-[27px] z-[7] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
|
||||||
|
{t('volunteers.name')}
|
||||||
|
</div>
|
||||||
|
{FESTIVAL_DAYS.map((day) =>
|
||||||
|
DAY_PARTS.map((part) => (
|
||||||
|
<div
|
||||||
|
key={`${day.id}-${part.code}`}
|
||||||
|
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
|
||||||
|
>
|
||||||
|
{part.label}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
)}
|
||||||
|
<div className="sticky right-0 top-[27px] z-[7] flex items-center justify-center border-b-[3px] border-l-[3px] border-ink bg-yellow-soft text-center font-mono text-[8.5px]">
|
||||||
|
{t('volunteers.cumulative')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lignes bénévoles */}
|
||||||
|
{rows.map((row) => (
|
||||||
|
<VolunteerRow key={row.volunteer.id} row={row} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="m-[18px] flex max-w-[640px] items-center gap-[14px] border-[3px] border-dashed border-ink bg-paper p-[22px]">
|
||||||
|
<span className="h-9 w-9 flex-none border-[3px] border-ink bg-yellow" />
|
||||||
|
<span className="font-display text-[14px] font-extrabold leading-snug">
|
||||||
|
{t('volunteers.empty')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="px-[14px] py-[10px] font-mono text-[9.5px] leading-relaxed opacity-60">
|
||||||
|
{t('volunteers.footnote', {
|
||||||
|
maxHours: state.config.maxDailyHours,
|
||||||
|
restHours: state.config.minRestHours,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegendItem({
|
||||||
|
swatchClass,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
swatchClass: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
|
||||||
|
<span className={cx('h-4 w-[30px] border-[1.5px]', swatchClass)} />
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="sticky left-0 z-[2] flex h-8 items-center gap-[6px] border-b border-r-[3px] border-ink bg-cream px-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
|
||||||
|
{row.volunteer.fullName}
|
||||||
|
</span>
|
||||||
|
{row.over ? (
|
||||||
|
<span className="flex-none border-[1.5px] border-ink bg-orange px-1 py-[2px] font-display text-[8px] font-extrabold">
|
||||||
|
{t('volunteers.badge.overloaded')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{row.empty ? (
|
||||||
|
<span className="flex-none border-[1.5px] border-ink bg-blue px-1 py-[2px] font-display text-[8px] font-extrabold">
|
||||||
|
{t('volunteers.badge.unassigned')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{row.cells.map((cell, i) => {
|
||||||
|
if (cell.kind === 'confirmed') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
title={cell.title}
|
||||||
|
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold"
|
||||||
|
>
|
||||||
|
{cell.code}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cell.kind === 'proposed') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
title={cell.title}
|
||||||
|
className="flex h-8 items-center justify-center border-b border-r border-ink bg-paper"
|
||||||
|
>
|
||||||
|
<span className="border-[1.5px] border-dashed border-ink px-[3px] py-[1px] font-mono text-[8.5px] font-bold">
|
||||||
|
{cell.code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (cell.kind === 'free') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-8 border-b border-r border-ink bg-dots-sand"
|
||||||
|
style={{ backgroundSize: '5px 5px' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div key={i} className="h-8 border-b border-r border-ink bg-sand" />;
|
||||||
|
})}
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'sticky right-0 z-[2] flex items-center justify-center gap-[3px] border-b border-l-[3px] border-ink font-mono text-[13px] font-bold',
|
||||||
|
row.over ? 'bg-orange' : 'bg-paper',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.total}
|
||||||
|
{row.over ? <span className="font-display text-[9px] font-bold">▲</span> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex min-h-0 flex-1 items-stretch">
|
||||||
|
<div className="max-h-[calc(100vh-118px)] w-[250px] flex-none overflow-auto border-r-[3px] border-ink">
|
||||||
|
<div className="sticky top-0 z-[2] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
|
||||||
|
{t('config.grid.positionsHeader', { count: totals.slots })}
|
||||||
|
</div>
|
||||||
|
{state.positions.map((entry) => {
|
||||||
|
const active = entry.id === position.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPositionId(entry.id)}
|
||||||
|
className={cx(
|
||||||
|
'block w-full cursor-pointer border-b border-ink px-[9px] py-[7px] text-left',
|
||||||
|
active
|
||||||
|
? 'border-l-[6px] bg-yellow'
|
||||||
|
: 'border-l-[6px] border-l-transparent bg-paper hover:bg-sand',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-display text-[12.5px] font-bold leading-tight">
|
||||||
|
{entry.name} <span className="opacity-60">{entry.zone}</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[9px] opacity-60">
|
||||||
|
{t('config.grid.slotsCount', { count: entry.needs.length })}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full cursor-pointer border-t-[3px] border-ink bg-cream p-3 font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-yellow"
|
||||||
|
>
|
||||||
|
{t('config.grid.addPosition')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
|
||||||
|
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
|
||||||
|
<div>
|
||||||
|
<div className="font-display text-[26px] font-black tracking-tight">
|
||||||
|
{position.name} — {position.zone}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10.5px] opacity-65">
|
||||||
|
{t(`position.category.${position.category.toLowerCase()}`)} · {position.location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex gap-2">
|
||||||
|
<div className="border-[3px] border-ink bg-paper px-[11px] py-2 shadow-brutal-md">
|
||||||
|
<div className="font-mono text-[20px] font-bold">
|
||||||
|
{t('unit.hours', { count: positionMin })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t('config.grid.minTotal')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-[3px] border-ink bg-yellow px-[11px] py-2 shadow-brutal-md">
|
||||||
|
<div className="font-mono text-[20px] font-bold">
|
||||||
|
{t('unit.hours', { count: positionIdeal })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t('config.grid.idealTotal')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(280px,1fr))]">
|
||||||
|
{FESTIVAL_DAYS.map((day) => (
|
||||||
|
<div key={day.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||||
|
<div className="flex items-baseline gap-[7px] bg-ink px-[10px] py-[7px] text-cream">
|
||||||
|
<span className="font-display text-[13px] font-black">{day.shortLabel}</span>
|
||||||
|
<span className="font-mono text-[8.5px] text-yellow">{day.tag}</span>
|
||||||
|
</div>
|
||||||
|
{DAY_PARTS.map((part) => {
|
||||||
|
const need = position.needs.find(
|
||||||
|
(n) => n.dayId === day.id && n.dayPartCode === part.code,
|
||||||
|
);
|
||||||
|
if (!need) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={part.code}
|
||||||
|
className="flex items-center gap-2 border-b border-sand-line bg-hatch-sand px-[9px] py-[7px]"
|
||||||
|
>
|
||||||
|
<span className="w-[52px] flex-none font-mono text-[11px] opacity-50">
|
||||||
|
{part.label}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[10px] font-semibold opacity-55">
|
||||||
|
{t('config.grid.closed')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
dispatch({
|
||||||
|
type: 'toggleSlot',
|
||||||
|
positionId: position.id,
|
||||||
|
dayId: day.id,
|
||||||
|
dayPartCode: part.code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ml-auto cursor-pointer border-2 border-ink bg-cream px-2 py-1 font-display text-[9.5px] font-extrabold hover:bg-yellow"
|
||||||
|
>
|
||||||
|
{t('config.grid.open')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={part.code}
|
||||||
|
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
|
||||||
|
>
|
||||||
|
<span className="w-[52px] flex-none font-mono text-[11px]">{part.label}</span>
|
||||||
|
<NeedStepper
|
||||||
|
label={t('config.grid.min')}
|
||||||
|
value={need.minStaff}
|
||||||
|
onChange={(delta) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'setNeed',
|
||||||
|
positionId: position.id,
|
||||||
|
dayId: day.id,
|
||||||
|
dayPartCode: part.code,
|
||||||
|
which: 'min',
|
||||||
|
delta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<NeedStepper
|
||||||
|
label={t('config.grid.ideal')}
|
||||||
|
value={need.idealStaff}
|
||||||
|
accent
|
||||||
|
onChange={(delta) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'setNeed',
|
||||||
|
positionId: position.id,
|
||||||
|
dayId: day.id,
|
||||||
|
dayPartCode: part.code,
|
||||||
|
which: 'ideal',
|
||||||
|
delta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
dispatch({
|
||||||
|
type: 'toggleSlot',
|
||||||
|
positionId: position.id,
|
||||||
|
dayId: day.id,
|
||||||
|
dayPartCode: part.code,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
|
||||||
|
aria-label={t('action.close')}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-[14px] max-w-[760px] border-[3px] border-dashed border-ink p-[11px] font-mono text-[10.5px] leading-relaxed opacity-75">
|
||||||
|
{t('config.grid.note')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NeedStepper({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
accent = false,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
accent?: boolean;
|
||||||
|
onChange: (delta: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="font-display text-[8.5px] font-extrabold uppercase tracking-wide opacity-60">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(-1)}
|
||||||
|
className={cx(
|
||||||
|
'h-[22px] w-[22px] cursor-pointer border-2 border-ink font-display text-[12px] font-extrabold hover:bg-orange',
|
||||||
|
accent ? 'bg-yellow-soft' : 'bg-cream',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[18px] text-center font-mono text-[14px] font-bold">{value}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(1)}
|
||||||
|
className={cx(
|
||||||
|
'h-[22px] w-[22px] cursor-pointer border-2 border-ink font-display text-[12px] font-extrabold hover:bg-green hover:text-white',
|
||||||
|
accent ? 'bg-yellow-soft' : 'bg-cream',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof config>) => dispatch({ type: 'setConfig', patch });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid items-start gap-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
|
||||||
|
<Panel shadow="lg">
|
||||||
|
<PanelHeader>{t('config.mail.templateTitle')}</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-3 p-[13px]">
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.mail.from')}</FieldLabel>
|
||||||
|
<TextInput mono value={config.mailFrom} onChange={(v) => setConfig({ mailFrom: v })} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.mail.replyTo')}</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
mono
|
||||||
|
value={config.mailReplyTo}
|
||||||
|
onChange={(v) => setConfig({ mailReplyTo: v })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.mail.subject')}</FieldLabel>
|
||||||
|
<input
|
||||||
|
value={config.mailSubject}
|
||||||
|
onChange={(e) => setConfig({ mailSubject: e.target.value })}
|
||||||
|
className="w-full border-2 border-ink bg-yellow p-[9px] font-display text-[13px] font-extrabold"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.mail.body')}</FieldLabel>
|
||||||
|
<textarea
|
||||||
|
rows={7}
|
||||||
|
value={config.mailBody}
|
||||||
|
onChange={(e) => setConfig({ mailBody: e.target.value })}
|
||||||
|
className="w-full resize-y border-2 border-ink bg-cream p-[9px] font-display text-[12.5px] leading-relaxed"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('config.mail.variables')}</FieldLabel>
|
||||||
|
<div className="flex flex-wrap gap-[6px]">
|
||||||
|
{MAIL_VARIABLES.map((variable) => (
|
||||||
|
<span
|
||||||
|
key={variable}
|
||||||
|
className="border-2 border-ink bg-blue px-[7px] py-[5px] font-mono text-[10.5px]"
|
||||||
|
>
|
||||||
|
{variable}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Panel shadow="lg">
|
||||||
|
<PanelHeader>{t('config.mail.sendsTitle')}</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-[11px] p-[13px]">
|
||||||
|
<ToggleRow
|
||||||
|
label={t('config.mail.reminderToggle')}
|
||||||
|
on={config.reminderEnabled}
|
||||||
|
onLabel={t('config.mail.on')}
|
||||||
|
offLabel={t('config.mail.off')}
|
||||||
|
onToggle={() => setConfig({ reminderEnabled: !config.reminderEnabled })}
|
||||||
|
/>
|
||||||
|
<StepperRow
|
||||||
|
label={t('config.mail.reminderDelay')}
|
||||||
|
value={t('unit.hours', { count: config.reminderHours })}
|
||||||
|
onDecrement={() =>
|
||||||
|
setConfig({ reminderHours: Math.max(12, config.reminderHours - 12) })
|
||||||
|
}
|
||||||
|
onIncrement={() =>
|
||||||
|
setConfig({ reminderHours: Math.min(120, config.reminderHours + 12) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StepperRow
|
||||||
|
label={t('config.mail.magicLinkValidity')}
|
||||||
|
value={t('unit.days', { count: config.magicLinkDays })}
|
||||||
|
onDecrement={() =>
|
||||||
|
setConfig({ magicLinkDays: Math.max(15, config.magicLinkDays - 15) })
|
||||||
|
}
|
||||||
|
onIncrement={() =>
|
||||||
|
setConfig({ magicLinkDays: Math.min(90, config.magicLinkDays + 15) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ToggleRow
|
||||||
|
label={t('config.mail.smsToggle')}
|
||||||
|
hint={t('config.mail.smsCost')}
|
||||||
|
on={config.smsEnabled}
|
||||||
|
onLabel={t('config.mail.onM')}
|
||||||
|
offLabel={t('config.mail.offM')}
|
||||||
|
onToggle={() => setConfig({ smsEnabled: !config.smsEnabled })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel shadow="lg">
|
||||||
|
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
|
||||||
|
<div className="p-[13px]">
|
||||||
|
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
|
||||||
|
{t('config.mail.previewFrom', { from: config.mailFrom })}
|
||||||
|
</div>
|
||||||
|
<div className="my-2 border-t-2 border-ink pt-2 font-display text-[13.5px] font-extrabold leading-snug">
|
||||||
|
{config.mailSubject}
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-wrap font-display text-[12.5px] leading-relaxed">
|
||||||
|
{config.mailBody}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[11px] inline-block border-2 border-ink bg-yellow px-3 py-[9px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal">
|
||||||
|
{t('config.mail.cta')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleRow({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
on,
|
||||||
|
onLabel,
|
||||||
|
offLabel,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
on: boolean;
|
||||||
|
onLabel: string;
|
||||||
|
offLabel: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px] first:border-t-0 first:pt-0">
|
||||||
|
<span className="flex-1 font-display text-[13px] font-bold leading-snug">
|
||||||
|
{label}
|
||||||
|
{hint ? (
|
||||||
|
<span className="block font-mono text-[9.5px] font-normal opacity-60">{hint}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cx(
|
||||||
|
'flex-none cursor-pointer border-2 border-ink px-[11px] py-2 font-display text-[11px] font-extrabold uppercase',
|
||||||
|
on ? 'bg-green text-white' : 'bg-cream',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{on ? onLabel : offLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepperRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onDecrement,
|
||||||
|
onIncrement,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onDecrement: () => void;
|
||||||
|
onIncrement: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px]">
|
||||||
|
<span className="flex-1 font-display text-[13px] font-bold leading-snug">{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDecrement}
|
||||||
|
className="h-7 w-7 cursor-pointer border-2 border-ink bg-cream font-display text-[14px] font-extrabold"
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[54px] text-center font-mono text-[16px] font-bold">{value}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onIncrement}
|
||||||
|
className="h-7 w-7 cursor-pointer border-2 border-ink bg-yellow font-display text-[14px] font-extrabold"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { usePlanning } from '@/app/planning-store';
|
||||||
|
import { FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
|
||||||
|
import { indexPlanning, isOverloaded } from '@/domain/selectors';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
export function ConfigRules() {
|
||||||
|
const { state, dispatch } = usePlanning();
|
||||||
|
const { config } = state;
|
||||||
|
const setConfig = (patch: Partial<typeof config>) => dispatch({ type: 'setConfig', patch });
|
||||||
|
|
||||||
|
const overCount = useMemo(() => {
|
||||||
|
const index = indexPlanning(state.assignments, state.volunteers);
|
||||||
|
return state.volunteers.filter((v) => isOverloaded(v, index, config)).length;
|
||||||
|
}, [state.assignments, state.volunteers, config]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid items-start gap-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
|
||||||
|
<Panel shadow="lg">
|
||||||
|
<PanelHeader>{t('config.rules.editionTitle')}</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-3 p-[13px]">
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.rules.festivalName')}</FieldLabel>
|
||||||
|
<input
|
||||||
|
value={config.festivalName}
|
||||||
|
onChange={(e) => setConfig({ festivalName: e.target.value })}
|
||||||
|
className="w-full border-2 border-ink bg-yellow p-[9px] font-display text-[15px] font-black"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.rules.dates')}</FieldLabel>
|
||||||
|
<TextInput value={config.datesLabel} onChange={(v) => setConfig({ datesLabel: v })} />
|
||||||
|
</label>
|
||||||
|
<div className="border-t-2 border-ink pt-[11px] font-mono text-[10px] leading-relaxed opacity-70">
|
||||||
|
{t('config.rules.datesNote')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel shadow="lg">
|
||||||
|
<PanelHeader>{t('config.rules.thresholdsTitle')}</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-[11px] p-[13px]">
|
||||||
|
<ThresholdRow
|
||||||
|
label={t('config.rules.maxDaily')}
|
||||||
|
hint={t('config.rules.maxDailyNote')}
|
||||||
|
value={t('unit.hours', { count: config.maxDailyHours })}
|
||||||
|
onDecrement={() => setConfig({ maxDailyHours: Math.max(4, config.maxDailyHours - 1) })}
|
||||||
|
onIncrement={() => setConfig({ maxDailyHours: Math.min(14, config.maxDailyHours + 1) })}
|
||||||
|
/>
|
||||||
|
<ThresholdRow
|
||||||
|
label={t('config.rules.minRest')}
|
||||||
|
hint={t('config.rules.minRestNote')}
|
||||||
|
value={t('unit.hours', { count: config.minRestHours })}
|
||||||
|
onDecrement={() => setConfig({ minRestHours: Math.max(0, config.minRestHours - 1) })}
|
||||||
|
onIncrement={() => setConfig({ minRestHours: Math.min(14, config.minRestHours + 1) })}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-[11px] border-t-2 border-ink pt-[11px]">
|
||||||
|
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-orange font-display text-[15px] font-extrabold">
|
||||||
|
▲
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[13px] font-bold leading-snug">
|
||||||
|
{t('config.rules.overCount', { count: overCount })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] leading-relaxed opacity-70">
|
||||||
|
{t('config.rules.overlapNote')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel shadow="lg">
|
||||||
|
<PanelHeader>{t('config.rules.rolesTitle')}</PanelHeader>
|
||||||
|
<div className="flex flex-col gap-[9px] p-[13px]">
|
||||||
|
<RoleCard
|
||||||
|
tone="bg-yellow"
|
||||||
|
title={t('config.rules.roleCoordinator')}
|
||||||
|
desc={t('config.rules.roleCoordinatorDesc')}
|
||||||
|
/>
|
||||||
|
<RoleCard
|
||||||
|
tone="bg-blue"
|
||||||
|
title={t('config.rules.roleLead')}
|
||||||
|
desc={t('config.rules.roleLeadDesc')}
|
||||||
|
/>
|
||||||
|
<RoleCard
|
||||||
|
tone="bg-cream"
|
||||||
|
title={t('config.rules.roleVolunteer', { count: state.volunteers.length })}
|
||||||
|
desc={t('config.rules.roleVolunteerDesc')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mt-[3px] cursor-pointer border-[3px] border-ink bg-cream p-[11px] font-display text-[11.5px] font-extrabold uppercase tracking-wide shadow-brutal hover:bg-yellow"
|
||||||
|
>
|
||||||
|
{t('config.rules.designateLead')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThresholdRow({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
value,
|
||||||
|
onDecrement,
|
||||||
|
onIncrement,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
value: string;
|
||||||
|
onDecrement: () => void;
|
||||||
|
onIncrement: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px] first:border-t-0 first:pt-0">
|
||||||
|
<span className="flex-1 font-display text-[13px] font-bold leading-snug">
|
||||||
|
{label}
|
||||||
|
<span className="block font-mono text-[9.5px] font-normal opacity-60">{hint}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDecrement}
|
||||||
|
className="h-7 w-7 cursor-pointer border-2 border-ink bg-cream font-display text-[14px] font-extrabold"
|
||||||
|
>
|
||||||
|
–
|
||||||
|
</button>
|
||||||
|
<span className="min-w-[48px] text-center font-mono text-[18px] font-bold">{value}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onIncrement}
|
||||||
|
className="h-7 w-7 cursor-pointer border-2 border-ink bg-yellow font-display text-[14px] font-extrabold"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleCard({ tone, title, desc }: { tone: string; title: string; desc: string }) {
|
||||||
|
return (
|
||||||
|
<div className={`border-2 border-ink p-[9px] ${tone}`}>
|
||||||
|
<div className="font-display text-[13px] font-extrabold leading-tight">{title}</div>
|
||||||
|
<div className="font-mono text-[10px] leading-relaxed">{desc}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { usePlanning } from '@/app/planning-store';
|
||||||
|
import { FieldLabel, TextInput } from '@/components/ui';
|
||||||
|
import { DEMO_CATEGORIES, categoryLabelKey } from '@/domain/festival';
|
||||||
|
import { indexPlanning } from '@/domain/selectors';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
|
const TABLE_COLUMNS = 'minmax(0,2fr) minmax(0,1.3fr) minmax(0,1.6fr) minmax(0,1fr)';
|
||||||
|
|
||||||
|
function demoEmail(firstName: string, lastName: string): string {
|
||||||
|
const normalize = (s: string) =>
|
||||||
|
s
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[^a-z]/g, '');
|
||||||
|
return `${normalize(firstName)}.${normalize(lastName)}@mail.fr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigVolunteers() {
|
||||||
|
const { state, dispatch } = usePlanning();
|
||||||
|
const [selectedId, setSelectedId] = useState(state.volunteers[0]?.id ?? '');
|
||||||
|
const selected = state.volunteers.find((v) => v.id === selectedId) ?? state.volunteers[0];
|
||||||
|
|
||||||
|
const index = useMemo(
|
||||||
|
() => indexPlanning(state.assignments, state.volunteers),
|
||||||
|
[state.assignments, state.volunteers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = state.volunteers.slice(0, 26);
|
||||||
|
const assignedHours = index.totalHours.get(selected.id) ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1 items-stretch">
|
||||||
|
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
|
||||||
|
<div className="mb-[14px] flex flex-wrap items-stretch gap-3">
|
||||||
|
<div className="flex flex-1 items-center gap-[14px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
|
||||||
|
<span className="flex h-10 w-10 flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[16px] font-extrabold">
|
||||||
|
↓
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block font-display text-[15px] font-black">
|
||||||
|
{t('config.vol.importTitle')}
|
||||||
|
</span>
|
||||||
|
<span className="block font-mono text-[10px] leading-relaxed opacity-70">
|
||||||
|
{t('config.vol.importHint')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: 'runImport' })}
|
||||||
|
className="flex-none cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white"
|
||||||
|
>
|
||||||
|
{t('config.vol.importButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-none border-[3px] border-ink bg-paper px-[14px] py-3 shadow-brutal-lg">
|
||||||
|
<div className="font-mono text-[24px] font-bold">{state.volunteers.length}</div>
|
||||||
|
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t('config.vol.profiles')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{state.importedCount > 0 ? (
|
||||||
|
<div className="flex-none border-[3px] border-ink bg-green px-[14px] py-3 text-white shadow-brutal-lg">
|
||||||
|
<div className="font-mono text-[24px] font-bold">+{state.importedCount}</div>
|
||||||
|
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t('config.vol.imported')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||||
|
<div
|
||||||
|
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
|
||||||
|
style={{ gridTemplateColumns: TABLE_COLUMNS }}
|
||||||
|
>
|
||||||
|
<span>{t('config.vol.colName')}</span>
|
||||||
|
<span>{t('config.vol.colPhone')}</span>
|
||||||
|
<span>{t('config.vol.colWanted')}</span>
|
||||||
|
<span>{t('config.vol.colAvailability')}</span>
|
||||||
|
</div>
|
||||||
|
{rows.map((volunteer) => {
|
||||||
|
const active = volunteer.id === selected.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={volunteer.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedId(volunteer.id)}
|
||||||
|
className={cx(
|
||||||
|
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
|
||||||
|
active ? 'bg-yellow' : 'hover:bg-cream',
|
||||||
|
)}
|
||||||
|
style={{ gridTemplateColumns: TABLE_COLUMNS }}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-[7px]">
|
||||||
|
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
|
||||||
|
{volunteer.initials}
|
||||||
|
</span>
|
||||||
|
<span className="truncate font-display text-[12.5px] font-bold">
|
||||||
|
{volunteer.fullName}
|
||||||
|
</span>
|
||||||
|
{volunteer.imported ? (
|
||||||
|
<span className="flex-none border-[1.5px] border-ink bg-green px-1 py-[2px] font-display text-[8px] font-extrabold text-white">
|
||||||
|
{t('config.vol.new')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[11px] opacity-80">{volunteer.phone}</span>
|
||||||
|
<span className="truncate font-display text-[11px] font-semibold opacity-80">
|
||||||
|
{volunteer.preferredCategories
|
||||||
|
.map((c) => t(categoryLabelKey(c)))
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10.5px] opacity-65">
|
||||||
|
{t('config.vol.slotsDeclared', { count: volunteer.availability.size })}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
||||||
|
<div className="bg-ink px-[13px] py-[11px] text-cream">
|
||||||
|
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
|
||||||
|
<div className="mt-[7px] flex items-center gap-[9px]">
|
||||||
|
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
|
||||||
|
{selected.initials}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[19px] font-black tracking-tight">
|
||||||
|
{selected.fullName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 p-[13px]">
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.fullName')}</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
value={selected.fullName}
|
||||||
|
onChange={(value) =>
|
||||||
|
dispatch({ type: 'editVolunteer', id: selected.id, field: 'fullName', value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>{t('config.vol.phone')}</FieldLabel>
|
||||||
|
<TextInput
|
||||||
|
mono
|
||||||
|
value={selected.phone}
|
||||||
|
onChange={(value) =>
|
||||||
|
dispatch({ type: 'editVolunteer', id: selected.id, field: 'phone', value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('config.vol.email')}</FieldLabel>
|
||||||
|
<div className="truncate border-2 border-ink bg-sand p-[9px] font-mono text-[12px]">
|
||||||
|
{demoEmail(selected.firstName, selected.lastName)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('config.vol.wanted')}</FieldLabel>
|
||||||
|
<div className="flex flex-wrap gap-[6px]">
|
||||||
|
{DEMO_CATEGORIES.map((category) => {
|
||||||
|
const on = selected.preferredCategories.includes(category);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
dispatch({ type: 'togglePreference', id: selected.id, category })
|
||||||
|
}
|
||||||
|
className={cx(
|
||||||
|
'cursor-pointer border-2 border-ink px-2 py-[6px] font-display text-[10.5px]',
|
||||||
|
on ? 'bg-blue font-extrabold' : 'bg-cream font-semibold hover:bg-yellow',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{on ? '★ ' : ''}
|
||||||
|
{t(categoryLabelKey(category))}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-[9px] border-t-2 border-ink pt-[11px]">
|
||||||
|
<div className="flex-1 border-2 border-ink bg-cream p-2">
|
||||||
|
<div className="font-mono text-[17px] font-bold">
|
||||||
|
{t('unit.hours', { count: assignedHours })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t('config.vol.hoursAssigned')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex-1 border-2 border-ink bg-dots-sand p-2"
|
||||||
|
style={{ backgroundSize: '5px 5px' }}
|
||||||
|
>
|
||||||
|
<div className="font-mono text-[11px] leading-tight">
|
||||||
|
{t('config.vol.availabilityValue', { count: selected.availability.size })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||||
|
{t('config.vol.availabilityDeclared')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] leading-relaxed opacity-65">
|
||||||
|
{t('config.vol.note')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { usePlanning } from '@/app/planning-store';
|
||||||
|
import { Badge, Btn } from '@/components/ui';
|
||||||
|
import { dayPartHours, festivalDay, dayPart } from '@/domain/festival';
|
||||||
|
import { evaluateAssignment, hasBlocking, type Violation } from '@/domain/rules';
|
||||||
|
import type { PlanningIndex } from '@/domain/selectors';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
function violationText(violation: Violation): string {
|
||||||
|
return t(`validation.${violation.code}`, violation.params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssignmentPanel({ slot, index }: { slot: string; index: PlanningIndex }) {
|
||||||
|
const { state, dispatch } = usePlanning();
|
||||||
|
const [positionId, dayId, dayPartCode] = slot.split('|');
|
||||||
|
|
||||||
|
const positionsById = useMemo(
|
||||||
|
() => new Map(state.positions.map((p) => [p.id, p])),
|
||||||
|
[state.positions],
|
||||||
|
);
|
||||||
|
const position = positionsById.get(positionId);
|
||||||
|
const need = position?.needs.find((n) => n.dayId === dayId && n.dayPartCode === dayPartCode);
|
||||||
|
|
||||||
|
const assignedList = useMemo(() => index.bySlot.get(slot) ?? [], [index, slot]);
|
||||||
|
const durationHours = dayPartHours(dayPartCode);
|
||||||
|
|
||||||
|
const candidates = useMemo(() => {
|
||||||
|
if (!position || !need) return [];
|
||||||
|
return state.volunteers
|
||||||
|
.filter((v) => !assignedList.some((a) => a.volunteerId === v.id))
|
||||||
|
.map((volunteer) => {
|
||||||
|
const currentAssignments = index.byVolunteer.get(volunteer.id) ?? [];
|
||||||
|
const violations = evaluateAssignment({
|
||||||
|
volunteer,
|
||||||
|
target: { position, dayId, dayPartCode },
|
||||||
|
currentAssignments,
|
||||||
|
positionsById,
|
||||||
|
thresholds: {
|
||||||
|
maxDailyHours: state.config.maxDailyHours,
|
||||||
|
minRestHours: state.config.minRestHours,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const blocked = hasBlocking(violations);
|
||||||
|
const blocking = violations.find((v) => v.severity === 'BLOCKING') ?? null;
|
||||||
|
const warnings = violations.filter((v) => v.severity === 'WARNING');
|
||||||
|
const available = volunteer.availability.has(`${dayId}.${dayPartCode}`);
|
||||||
|
const wanted = volunteer.preferredCategories.includes(position.category);
|
||||||
|
const hours = index.totalHours.get(volunteer.id) ?? 0;
|
||||||
|
const score =
|
||||||
|
(available ? 100 : 0) +
|
||||||
|
(wanted ? 40 : 0) -
|
||||||
|
warnings.length * 12 -
|
||||||
|
hours * 2 -
|
||||||
|
(blocked ? 1000 : 0);
|
||||||
|
return {
|
||||||
|
volunteer,
|
||||||
|
blocked,
|
||||||
|
blocking,
|
||||||
|
warnings,
|
||||||
|
available,
|
||||||
|
wanted,
|
||||||
|
hours,
|
||||||
|
slots: currentAssignments.length,
|
||||||
|
score,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, 9);
|
||||||
|
}, [
|
||||||
|
position,
|
||||||
|
need,
|
||||||
|
state.volunteers,
|
||||||
|
state.config.maxDailyHours,
|
||||||
|
state.config.minRestHours,
|
||||||
|
assignedList,
|
||||||
|
index,
|
||||||
|
positionsById,
|
||||||
|
dayId,
|
||||||
|
dayPartCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!position || !need) {
|
||||||
|
return (
|
||||||
|
<div className="px-[18px] py-[26px] font-mono text-[10.5px] opacity-65">
|
||||||
|
{t('grille.panel.empty.body')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = festivalDay(dayId);
|
||||||
|
const part = dayPart(dayPartCode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="sticky top-0 z-[5] bg-ink px-[13px] py-[11px] text-cream">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-display text-[16px] font-black tracking-tight">{position.name}</div>
|
||||||
|
<div className="font-mono text-[10px] text-yellow">
|
||||||
|
{position.zone} · {position.location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: 'selectSlot', slot: null })}
|
||||||
|
className="h-6 w-6 cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream"
|
||||||
|
aria-label={t('action.close')}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-[9px] flex items-stretch gap-2">
|
||||||
|
<div className="bg-yellow px-2 py-[5px] font-display text-[11px] font-extrabold text-ink">
|
||||||
|
{day.shortLabel} · {part.label}
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-cream px-2 py-1 font-mono text-[10px]">
|
||||||
|
{t('grille.panel.slotDuration', { hours: durationHours })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-[9px] flex gap-[14px] font-mono text-[10px]">
|
||||||
|
<span>
|
||||||
|
{t('grille.panel.assigned')}{' '}
|
||||||
|
<b className="font-mono text-[15px] font-bold">{assignedList.length}</b>
|
||||||
|
</span>
|
||||||
|
<span className="opacity-70">{t('grille.panel.min', { count: need.minStaff })}</span>
|
||||||
|
<span className="opacity-70">{t('grille.panel.ideal', { count: need.idealStaff })}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-b-2 border-ink px-[13px] py-3">
|
||||||
|
<div className="mb-2 font-display text-[10px] font-extrabold uppercase tracking-widest">
|
||||||
|
{t('grille.panel.assignmentsTitle', { count: assignedList.length })}
|
||||||
|
</div>
|
||||||
|
{assignedList.length === 0 ? (
|
||||||
|
<div className="font-mono text-[10.5px] opacity-60">{t('grille.panel.noneAssigned')}</div>
|
||||||
|
) : (
|
||||||
|
assignedList.map((assignment) => {
|
||||||
|
const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId);
|
||||||
|
if (!volunteer) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={assignment.id}
|
||||||
|
className="mb-[6px] flex items-center gap-2 border-2 border-ink bg-cream px-[7px] py-[6px]"
|
||||||
|
>
|
||||||
|
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-blue font-display text-[9.5px] font-extrabold">
|
||||||
|
{volunteer.initials}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 font-display text-[12px] font-bold">
|
||||||
|
{volunteer.fullName}
|
||||||
|
<span className="block font-mono text-[9px] opacity-60">
|
||||||
|
{t('grille.panel.hoursTotal', {
|
||||||
|
hours: index.totalHours.get(volunteer.id) ?? 0,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{assignment.status === 'CONFIRMED' ? (
|
||||||
|
<Badge tone="confirmed">{t('assignment.status.confirmed')}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge tone="proposed">{t('assignment.status.proposed')}</Badge>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dispatch({ type: 'unassign', assignmentId: assignment.id })}
|
||||||
|
className="h-6 w-6 flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
|
||||||
|
aria-label={t('action.remove')}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-[13px] py-3">
|
||||||
|
<div className="mb-[9px] flex items-baseline gap-2">
|
||||||
|
<span className="font-display text-[10px] font-extrabold uppercase tracking-widest">
|
||||||
|
{t('grille.panel.candidates')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[9px] opacity-60">
|
||||||
|
{t('grille.panel.candidatesHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{candidates.map(({ volunteer, blocked, blocking, warnings, available, wanted, hours, slots }) => (
|
||||||
|
<div
|
||||||
|
key={volunteer.id}
|
||||||
|
className="mb-[10px] border-2 border-ink bg-cream shadow-brutal"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 p-[7px]">
|
||||||
|
<span className="flex h-7 w-7 flex-none items-center justify-center border-2 border-ink bg-yellow font-display text-[10px] font-extrabold">
|
||||||
|
{volunteer.initials}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
|
||||||
|
{volunteer.fullName}
|
||||||
|
<span className="block font-mono text-[9px] opacity-60">
|
||||||
|
{t('grille.panel.hoursAssigned', { hours, slots })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{blocked ? (
|
||||||
|
<span className="flex-none border-2 border-ink bg-sand-dark px-[10px] py-2 font-display text-[10.5px] font-extrabold uppercase tracking-wide opacity-60">
|
||||||
|
{t('grille.panel.blocked')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Btn onClick={() => dispatch({ type: 'assign', volunteerId: volunteer.id })}>
|
||||||
|
{t('grille.panel.assign')}
|
||||||
|
</Btn>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(available || wanted) && (
|
||||||
|
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
|
||||||
|
{available ? (
|
||||||
|
<span className="border-[1.5px] border-ink bg-green/25 px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
|
||||||
|
{t('grille.panel.availableTag')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{wanted ? (
|
||||||
|
<span className="border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
|
||||||
|
{t('grille.panel.wantedTag')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{blocked && blocking ? (
|
||||||
|
<div className="flex items-stretch border-t-2 border-ink">
|
||||||
|
<span className="flex w-[26px] flex-none items-center justify-center border-r-2 border-ink bg-hatch-orange font-display text-[12px] font-bold">
|
||||||
|
⛔
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
|
||||||
|
{t('grille.panel.blockingPrefix', { reason: violationText(blocking) })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!blocked && warnings.length > 0 ? (
|
||||||
|
<div className="border-t-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px]">
|
||||||
|
<div className="mb-1 font-display text-[8.5px] font-extrabold uppercase tracking-widest">
|
||||||
|
{t('grille.panel.warningsTitle')}
|
||||||
|
</div>
|
||||||
|
{warnings.map((warning, i) => (
|
||||||
|
<div key={i} className="font-display text-[10px] font-semibold leading-snug">
|
||||||
|
· {violationText(warning)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
/** Maquette de téléphone (mêmes proportions que le design). */
|
||||||
|
export function PhoneFrame({ time, children }: { time: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex-none rounded-[36px] border-[3px] border-ink bg-ink p-[9px] shadow-[9px_9px_0_rgba(20,17,14,0.22)]">
|
||||||
|
<div className="h-[772px] w-[376px] overflow-auto rounded-[27px] bg-cream">
|
||||||
|
<div className="flex justify-between bg-ink px-5 pt-[7px] font-mono text-[10px] font-medium text-cream">
|
||||||
|
<span>{time}</span>
|
||||||
|
<span>◗ ▮▮▮</span>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesignNote({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-[250px] max-w-[420px] flex-1 border-[3px] border-ink bg-paper p-[15px] shadow-brutal-lg">
|
||||||
|
<div className="mb-[9px] font-display text-[13px] font-black uppercase tracking-widest">
|
||||||
|
{t('design.noteTitle')}
|
||||||
|
</div>
|
||||||
|
<p className="font-display text-[12.5px] leading-relaxed">{children}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,50 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
// Palette et primitives issues du design Claude Design « Bénévoles Festival »
|
||||||
|
// (style néo-brutaliste : bordures franches, ombres portées dures, capitales).
|
||||||
export default {
|
export default {
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
colors: {
|
||||||
|
cream: '#F5EFE1', // fond général
|
||||||
|
ink: '#14110E', // encre (quasi-noir)
|
||||||
|
paper: '#FFFDF7', // panneaux / cartes
|
||||||
|
sand: '#EFE8D6', // barres secondaires
|
||||||
|
'sand-line': '#E0D8C4', // séparateurs sur paper
|
||||||
|
'sand-dark': '#E7E0CE',
|
||||||
|
yellow: '#FFD23F',
|
||||||
|
'yellow-soft': '#FFE68A', // état « incomplet »
|
||||||
|
'yellow-hover': '#FFDF77',
|
||||||
|
orange: '#FF5C39',
|
||||||
|
'orange-dark': '#E0421F',
|
||||||
|
green: '#0E9F6E',
|
||||||
|
blue: '#A8C4FF',
|
||||||
|
'blue-strong': '#2A6BFF', // « non publié »
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
display: ['Archivo', 'Helvetica', 'Arial', 'sans-serif'],
|
||||||
|
mono: ['"DM Mono"', 'ui-monospace', 'SFMono-Regular', 'monospace'],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
brutal: '3px 3px 0 #14110E',
|
||||||
|
'brutal-sm': '2px 2px 0 #14110E',
|
||||||
|
'brutal-md': '4px 4px 0 #14110E',
|
||||||
|
'brutal-lg': '6px 6px 0 #14110E',
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
// Hachures « sous-effectif critique »
|
||||||
|
'hatch-orange':
|
||||||
|
'repeating-linear-gradient(45deg,#FF5C39 0 6px,#E0421F 6px 12px)',
|
||||||
|
// Hachures « brouillon »
|
||||||
|
'hatch-ink':
|
||||||
|
'repeating-linear-gradient(45deg,#14110E 0 8px,#2A2520 8px 16px)',
|
||||||
|
// Trame « poste fermé »
|
||||||
|
'hatch-sand':
|
||||||
|
'repeating-linear-gradient(45deg,#E9E2D0 0 5px,#DFD7C2 5px 10px)',
|
||||||
|
// Pointillés « disponibilité déclarée »
|
||||||
|
'dots-sand': 'radial-gradient(#CFC7B4 1px,transparent 1.2px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user