Compare commits

..
23 Commits
Author SHA1 Message Date
AdamThomasandClaude Opus 5.5 3754cfc69a Planches de badges centrées, verso aligné sur le recto
ci-cd / build-backend (push) Successful in 57s
ci-cd / build-frontend (push) Successful in 9s
ci-cd / deploy (push) Successful in 16s
Espace libre réparti à parts égales (mêmes écarts à gauche, entre les
badges et à droite ; en haut, entre les rangées et en bas) : grille
symétrique, le verso imprimé en miroir tombe exactement derrière le recto
au lieu d'être décalé de l'espace restant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:50:59 +01:00
AdamThomasandClaude Opus 5.5 05cdb0276b Lien de connexion directe permanent dans le récapitulatif de planning
ci-cd / build-backend (push) Successful in 45s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 11s
{lien_connexion} devient un lien permanent (réutilisable, sans expiration,
signé HMAC, rien de stocké) au lieu d'un lien magique de 30 min à usage
unique ou d'un simple lien vers le site. Compte bénévole créé à l'envoi
s'il manque ; révocation par « Renouveler le lien de connexion » sur la
fiche (Account.login_link_version, migration 0031) ; jamais pour la
coordination.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:01:03 +01:00
AdamThomasandClaude Opus 5.5 8d6781e6ce Récapitulatif de planning : créneaux en tableau avec le référent
ci-cd / build-backend (push) Successful in 48s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 7s
{creneaux} devient un tableau par jour de festival (horaire, poste,
référent avec son téléphone), en HTML dans l'email et en lignes lisibles
dans la version texte — envoi réel et email de test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:52:06 +01:00
AdamThomasandClaude Opus 5.5 440466ccf9 Email de test : récapitulatif de planning compilé
ci-cd / build-backend (push) Successful in 46s
ci-cd / deploy (push) Successful in 12s
ci-cd / build-frontend (push) Successful in 21s
L'email de test reprend le modèle en cours d'édition, balises remplies
avec le planning d'un bénévole au choix (premier avec un créneau par
défaut), option lien de connexion sans vrai jeton, sujet « [Test] … » —
même fonction de rendu que l'envoi réel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:44:14 +01:00
AdamThomasandClaude Opus 5.5 461742eb78 Email de test et ajout manuel sans champ obligatoire
ci-cd / build-backend (push) Successful in 1m25s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 13s
Étape 67 : adresse + bouton dans l'onglet « Messages » pour envoyer tout
de suite un email de test, résultat réel affiché (envoyé, désactivé,
connexion ou message refusé, réponse du serveur SMTP) —
POST .../notifications/test.

Étape 66 : prénom et nom facultatifs à l'ajout manuel d'un bénévole ;
un bénévole sans nom s'affiche « Sans nom » / « ? » partout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:34:24 +01:00
AdamThomasandClaude Opus 5.5 5c2cc0a6c3 Feuilles de poste compactées : une page par poste et par jour
ci-cd / build-backend (push) Failing after 1m2s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
Bénévoles deux par ligne (vignette 10 mm, nom et téléphone superposés),
titres, en-têtes de créneau et marges resserrés — 6 feuilles réalistes :
11 pages ⇒ 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:22:46 +01:00
AdamThomasandClaude Opus 5.5 749c7e0917 Vue par bénévole : recherche sur le bénévole ou le poste
ci-cd / build-backend (push) Successful in 6s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 11s
Barre de recherche (sans accents ni casse) sur le nom du bénévole ou d'un
de ses postes, postes trouvés cernés d'orange, cumulable avec les filtres
et le tri.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:15:14 +01:00
AdamThomasandClaude Opus 5.5 cc8c2e61ab Fiches de poste : message de l'organisation en bas de chaque page
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 7s
ci-cd / deploy (push) Successful in 11s
Edition.volunteer_message imprimé en pied de chaque page (suite d'une
feuille comprise), la marge du bas agrandie de sa hauteur ; rien sans
message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:11:33 +01:00
AdamThomasandClaude Opus 5.5 c6ea7378c7 Fiches de poste rapides, badges sans initiales, vue par bénévole sur l'axe des temps
ci-cd / build-backend (push) Successful in 34s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 11s
Étape 62 : les fiches de poste intégraient la photo d'origine de chaque
bénévole pour chaque créneau (CPU à 300 %, puis erreur) — photos réduites
à la vignette une fois par PDF ; badges : photo recadrée une fois par
génération et décodée à taille réduite.

Étape 61 : sans photo, le cadre de la photo reste vide sur le badge.

Étape 60 : vue par bénévole en tranches de 30 min, affectations étirées
sur toute leur durée, postes simultanés empilés, ligne de l'heure actuelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:01:48 +01:00
AdamThomasandClaude Opus 5.5 c57c70569c Pointage enregistré et statistiques de présence
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Étape 58 : le présent / absent du référent est enregistré sur
l'affectation (Assignment.attendance + heure, migration 0030,
PUT .../assignments/{id}/attendance) au lieu de disparaître au
rechargement.

Étape 59 : section « Présence » de l'écran Statistiques — créneaux en
cours et créneaux terminés, par poste, rafraîchie toutes les 30 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:06:32 +01:00
AdamThomasandClaude Opus 5.5 a37f6c2c41 Référent aussi bénévole : onglets Mon équipe / Mon planning
ci-cd / build-backend (push) Successful in 7s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 12s
Un référent rattaché à une fiche bénévole voit aussi ses propres créneaux
(vue bénévole) dans un second onglet de son écran, ouvert d'office quand il
tient un créneau de bénévole hors de ses postes ; « TOI » dans l'équipe
pointée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:42:26 +01:00
AdamThomasandClaude Opus 5.5 16d8fb44a7 Écrans bénévole et référent redessinés, messages aux bénévoles
ci-cd / build-backend (push) Successful in 29s
ci-cd / build-frontend (push) Successful in 21s
ci-cd / deploy (push) Successful in 12s
Étape 55 : écrans /mobile-benevole et /mobile-referent d'après la maquette
(design/Planning bénévole mobile) — créneau en cours ou prochain mis en
avant, planning par journée de festival avec frise horaire, compteurs et
pointage du référent par créneau.

Étape 56 : message de l'organisation (Edition.volunteer_message, onglet
« Message aux bénévoles ») et message de chaque poste (Position.message,
saisi par le référent), migration 0029, affichés sur l'écran bénévole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:17:14 +01:00
AdamThomasandClaude Opus 5.5 4a3f93e137 Relance de la chaîne CI/CD
ci-cd / build-backend (push) Successful in 24s
ci-cd / build-frontend (push) Successful in 27s
ci-cd / deploy (push) Successful in 15s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:53:34 +01:00
AdamThomasandClaude Opus 5.5 a5d975baa0 Relance de la chaîne CI/CD
ci-cd / build-backend (push) Failing after 57s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:36:15 +01:00
AdamThomasandClaude Opus 5.5 1026d9b9e1 Badges : ordre de superposition des champs
ci-cd / build-backend (push) Failing after 1m5s
ci-cd / build-frontend (push) Skipped
ci-cd / deploy (push) Skipped
La photo recouvrait le nom et le texte libre (champs dessinés dans l'ordre
alphabétique de leur type). BadgeField.z_order (migration 0028, champs
existants renumérotés photo dessous) fixe l'ordre de dessin du PDF et de
l'aperçu ; boutons « Premier plan / Avancer / Reculer / Arrière-plan »
dans l'inspecteur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:33:10 +01:00
AdamThomasandClaude Opus 5.5 4f86398ccb Vue par bénévole : tri par total d'heures
ci-cd / build-backend (push) Successful in 6s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Tri des lignes par nom ou par total d'heures affectées, croissant ou
décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») ;
à total égal, l'ordre par nom est conservé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:23:22 +01:00
AdamThomasandClaude Opus 5.5 ced0855d47 Refonte du créateur de badges et nombre de badges par bénévole
ci-cd / build-backend (push) Successful in 30s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 22s
Créateur de badges refait d'après la maquette : champs sur le recto et le
verso (BadgeField.face, verso placé en miroir dans le PDF pour le
recto-verso), fond de zone, réduction automatique du texte, photo ovale,
initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en
trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles,
inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de
découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant
génération.

Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut,
migration 0027) : champ sur la fiche, autant d'exemplaires sur les
planches, total affiché à la génération, colonne dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:20:42 +01:00
AdamThomasandClaude Opus 5.5 1a23881e81 Badges : retour à la ligne et créneaux en tableau par jour
ci-cd / build-backend (push) Successful in 37s
ci-cd / build-frontend (push) Successful in 22s
ci-cd / deploy (push) Successful in 13s
Le texte des champs passe à la ligne pour tenir dans la largeur de son
cadre (wrap_text) au lieu de déborder. Les créneaux affectés s'affichent
en tableau : le jour de festival en en-tête, puis une colonne horaire et
une colonne poste (plus de référent sur le badge) ; même tableau dans
l'aperçu de l'éditeur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:44:55 +01:00
AdamThomasandClaude Opus 5.5 cfed76c239 Badges : texte libre saisi dans l'éditeur, champs ramenés dans le badge
Texte libre commun à tous les badges, saisi dans l'éditeur
(BadgeField.text, migration 0025) ; le « Texte du badge » de la fiche le
remplace pour un bénévole. Champs ramenés à l'intérieur du badge et champ
sélectionné au premier plan : la photo restait bloquée hors d'un badge
plus étroit que le CR80 (bornes de glisser-déposer négatives).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:30:34 +01:00
AdamThomasandClaude Opus 5.5 6892e0307d Photo du bénévole ajoutée par la coordination
ci-cd / build-backend (push) Successful in 33s
ci-cd / build-frontend (push) Successful in 20s
ci-cd / deploy (push) Successful in 14s
Section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer),
PUT/DELETE /editions/{id}/volunteers/{vid}/photo (COORDINATION),
validation de l'image partagée avec le formulaire public (read_photo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:17:25 +01:00
AdamThomasandClaude Opus 5.5 54cba467fc Badges : photo du bénévole et texte libre
Champ « Photo du bénévole » (PHOTO) : photo du formulaire recadrée au
cadre, orientation EXIF appliquée, réduite à 300 dpi ; cadre vide sans
photo. Champ « Texte libre » (FREE_TEXT) alimenté par le nouveau « Texte
du badge » de la fiche bénévole (Volunteer.badge_text, migration 0024),
repris dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:12:09 +01:00
AdamThomasandClaude Opus 5.5 5b493c9737 Ajout de BLUEPRINT.md : socle réutilisable pour une nouvelle appli
Philosophie, stack, architecture, style et conventions de l'appli,
débarrassés du domaine métier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:05:16 +01:00
AdamThomasandClaude Opus 5.5 85e841ae4a Responsive mobile / tablette / desktop
En-tête (onglets sur une seconde ligne en tablette, menu déroulant en
mobile), panneau d'affectation et fiche bénévole en tiroir par-dessus sous
lg: (plein écran en mobile), colonne des postes de la grille rétrécie,
liste des bénévoles réduite en mobile, écran « Postes & créneaux » empilé,
aperçu de l'éditeur de badges mis à l'échelle (glisser-déposer tactile),
ligne des types de paiement repliée, marges réduites — desktop inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:05:16 +01:00
82 changed files with 9305 additions and 1470 deletions
+362
View File
@@ -0,0 +1,362 @@
# BLUEPRINT — Socle réutilisable
Ce fichier capture la **philosophie, la stack, l'architecture, le style et
les conventions** de l'application « Gestion des bénévoles », débarrassés de
son domaine métier (festival, créneaux, cotisations…). Il sert de point de
départ pour une nouvelle application construite sur les mêmes bases.
**Mode d'emploi pour une nouvelle appli :**
1. Copier ce fichier dans le nouveau dépôt.
2. Le donner à Claude avec le brief métier : « Crée l'appli X sur les bases
de `BLUEPRINT.md` ».
3. La première étape produit un `CLAUDE.md` propre au nouveau projet (même
plan que celui de `benevoles` : glossaire, modèle, règles, périmètre,
arborescence, commandes, conventions, déploiement, journal des étapes),
qui devient ensuite le contrat de travail.
Les passages marqués **⟨à adapter⟩** dépendent du métier ; tout le reste est
le socle à reprendre tel quel.
---
## 1. Philosophie
- **Un outil pour une petite organisation réelle** (association, 50–200
personnes), pas une plateforme générique. On résout le besoin concret,
sans sur-ingénierie, mais avec un vrai niveau de qualité (tests, sécurité,
RGPD, déploiement automatisé).
- **Le métier parle français, le code parle anglais.** Un glossaire
FR ↔ EN fige les noms une fois pour toutes ; aucun nom métier français
dans le code, aucun texte d'interface en dur nulle part.
- **`CLAUDE.md` est le contrat de travail.** Il décrit le modèle, les règles,
les choix et leurs *raisons*. Il est mis à jour **à chaque étape**, en même
temps que le code. Quand un bug est corrigé, la cause et la règle qui
l'empêche de revenir y sont notées (« bug corrigé une fois — faire Y
recréerait la même classe de bug »).
- **Étapes courtes et validées.** Une étape = une fonctionnalité cohérente,
montrée à l'utilisateur, puis validée avant de continuer. Un commit atomique
par étape, message en français. Le journal des étapes (§ 10 du `CLAUDE.md`)
garde l'historique des décisions.
- **Le cœur métier est pur et testé d'abord.** Les règles critiques vivent
dans un module isolé, sans base de données ni HTTP (fonction pure :
entrée typée → résultat typé), écrit en TDD : un cas passant + un cas
déclenchant par règle. Un `loader.py` est le seul point de contact avec la
base.
- **L'outil assiste, l'humain décide.** Les calculs « intelligents »
(solveur, suggestions) proposent ; ils ne persistent jamais rien
d'eux-mêmes. La validation reste seule juge au moment de l'écriture.
- **Erreurs = codes, pas phrases.** Le backend renvoie
`{"detail": {"code": "domaine.raison", ...params}}` ; la traduction se
fait côté locales. Les violations de règles sont typées (`BLOCKING` /
`WARNING`) avec des paramètres structurés.
- **Simplicité assumée et documentée.** Quand on simplifie (ex. « vérifié
seulement contre l'existant, pas entre deux suggestions »), on l'écrit
explicitement comme simplification assumée.
- **Retirer est une fonctionnalité.** On n'hésite pas à supprimer un concept
devenu inutile (table, règle, écran) — migration, API, front et doc
nettoyés ensemble, plutôt que de laisser du code mort.
- **Aucune dépendance locale hors Docker.** Tout se lance, se teste et se
migre via `docker compose`.
---
## 2. Stack technique
### Backend
| Rôle | Choix |
|------|-------|
| Langage | Python 3.12 |
| Gestion des dépendances | `uv` (`pyproject.toml`, groupe `dev`) |
| API | FastAPI + Uvicorn |
| ORM / schémas | SQLModel (SQLAlchemy + Pydantic) |
| Base | PostgreSQL 16 (driver `psycopg[binary]` v3) |
| Migrations | Alembic (autogenerate + `alembic check` pour vérifier modèle ↔ migrations) |
| Configuration | `pydantic-settings` (`app/config.py`, valeurs réelles dans `.env` non versionné) |
| Auth | `pyjwt` (HS256) + `bcrypt` |
| Tests | `pytest` + `pytest-cov`, **vraie base PostgreSQL de test** |
| Lint / format | `ruff` (line-length 100, règles `E F I UP B`) |
| Outils ponctuels | `openpyxl` (import/export Excel), `reportlab` + `pypdf` (PDF), `pillow` (validation d'images), `httpx` (API tierces), `ortools` (optimisation) — **⟨à adapter⟩** selon le besoin |
### Frontend
| Rôle | Choix |
|------|-------|
| Framework | React 18 + TypeScript (strict) |
| Build | Vite 5 |
| Style | Tailwind CSS 3 (thème étendu, aucune lib de composants) |
| Données serveur | TanStack React Query 5 (hooks centralisés dans `src/api/hooks.ts`) |
| Routing | React Router 6 |
| Graphiques | SVG maison (`components/charts.tsx`), aucune bibliothèque |
| Qualité | ESLint 9 + `tsc --noEmit` + script `smoke` (rendu headless via `tsx`) |
Dépendances runtime volontairement minimales : `react`, `react-dom`,
`react-router-dom`, `@tanstack/react-query`. Rien d'autre sans bonne raison.
### Infra
- Docker Compose en dev (db + backend en hot reload + frontend Vite).
- Production : images `backend` (uvicorn) et `frontend` (build statique
servi par nginx), ports exposés uniquement sur `127.0.0.1`, Caddy du VPS en
reverse proxy HTTPS (`/api/*` → backend avec préfixe retiré, le reste →
frontend).
- Dépôt Gitea auto-hébergé + Gitea Actions (`act_runner`) : tests → build des
images (registre Gitea) → SSH sur le VPS → `pull` → `alembic upgrade head`
→ `up -d`.
- Fuseau horaire : `TZ=Europe/Paris` + `tzdata` dans le conteneur.
---
## 3. Architecture
### Arborescence type
```
.
├── CLAUDE.md # contrat de travail (tenu à jour à chaque étape)
├── README.md
├── Makefile # make demo / make blank / make down / make logs
├── docker-compose.yml # dev
├── .env.example # toutes les variables, sans vraies valeurs
├── .gitea/workflows/ci-cd.yml
├── deploy/ # docker-compose.prod.yml, bloc Caddyfile, README de mise en service
├── docker/postgres-init/ # crée la base de test au premier démarrage
├── backend/
│ ├── pyproject.toml · Dockerfile · alembic.ini
│ ├── app/
│ │ ├── main.py # instance FastAPI, montage des routers
│ │ ├── config.py # Settings (pydantic-settings), get_settings() en cache
│ │ ├── db.py # engine + get_session
│ │ ├── logging_config.py # logs sans PII + identifiant de requête
│ │ ├── request_logging.py # middleware : une ligne par requête, X-Request-ID, 500 tracées
│ │ ├── api/ # un router par ressource + schemas.py + deps.py
│ │ ├── auth/ # passwords, magic_link, tokens (JWT), deps (rôles), rate_limit
│ │ ├── email/ # sender (SMTP), templates (texte), html (rendu thémé), check (diagnostic)
│ │ ├── models/ # enums.py, base.py (TimestampMixin, local_now), domain.py
│ │ ├── validation/ # cœur métier pur : models.py, engine.py, loader.py
│ │ ├── <module_métier>/ # même découpage pour chaque calcul isolé ⟨à adapter⟩
│ │ ├── i18n/ # t(key, **params) + locales/fr.json
│ │ ├── bootstrap.py # instance vierge : 1re entité racine + 1er compte admin
│ │ ├── seed.py # jeu de démonstration réaliste et déterministe
│ │ └── migrations/
│ └── tests/ # conftest.py (vraie base), factories.py, test_<module>.py
└── frontend/
├── Dockerfile · Dockerfile.prod · nginx.conf
├── tailwind.config.js # palette et primitives du design
├── scripts/smoke.tsx # rendu headless + vérifications structurelles
└── src/
├── api/ # types.ts (interface Api), schema.ts (miroir des schémas backend),
│ # http-client.ts, mock/ (même surface, en mémoire), hooks.ts, session.ts
├── app/ # AppShell.tsx, router.tsx
├── auth/ # auth-context, RequireAuth, roles (homePath par rôle)
├── components/ # ui.tsx (primitives), state.tsx (chargement/erreur), charts.tsx
├── i18n/ # t(key, params) + locales/fr.json
├── lib/ # logique pure testable (calculs, dates, env, cx)
└── pages/ # un écran par fichier, sous-dossiers si gros écran
```
### Principes d'architecture backend
- **Multi-tenant par une entité racine** (ici `Edition`) : toute entité
métier porte son `<racine>_id`, toutes les routes sont préfixées
`/<racines>/{id}/...` et la dépendance `get_<racine>` renvoie 404
`error.not_found` si elle n'existe pas. **⟨à adapter⟩** (organisation,
projet, saison…).
- **Clés primaires UUID**, champs d'audit `created_at`/`updated_at` sur
toutes les tables sauf associations (`TimestampMixin`).
- **Énumérations en `VARCHAR` + `CHECK`** (`native_enum=False`), jamais
d'ENUM natif PostgreSQL (migrations plus simples).
- **`ON DELETE CASCADE` au niveau base** sur les liens vers la racine et
entre entités : la suppression RGPD d'une personne est un vrai *hard
delete* qui emporte tout ce qui la concerne.
- **Dates naïves en heure locale** (`local_now()`), interprétées
Europe/Paris. Pas de mélange UTC/local.
- **Modules isolés pour la logique non triviale** : `models.py` (types
purs, dataclasses), `engine.py` (fonction pure), `loader.py` (seul accès
base). Testés sans HTTP.
- **Un router par ressource**, schémas Pydantic `XCreate` / `XRead` /
`XUpdate` dans `api/schemas.py`. En `PATCH` : `None`/absent = inchangé,
`[]` = vider une liste.
- **Secrets jamais renvoyés** par l'API (`xxx_set: bool` à la place ;
chaîne vide en écriture = effacer).
- **Tâches de fond** (`BackgroundTasks`) pour les emails, en *best effort* :
un échec SMTP est journalisé mais ne fait jamais échouer l'appel HTTP.
- **Nettoyage paresseux** plutôt que tâches planifiées (purge des données
périmées au passage d'une requête), tant que le volume le permet.
- **Instance vierge et démo** : `bootstrap.py` (CLI, crée la racine + le
1er compte) et `seed.py` (jeu réaliste avec des cas intéressants :
trous, conflits, données de chaque fonctionnalité).
### Principes d'architecture frontend
- **Une interface `Api` unique, deux implémentations** : client HTTP réel
et **mock en mémoire de même surface** (`VITE_API_MODE=mock|http`). Le
front se développe et se démontre sans backend ; passer de l'un à l'autre
ne change aucun écran. Le mock calcule les mêmes résultats que le backend.
- **`schema.ts` = miroir de `backend/app/api/schemas.py`**, gardé
synchrone à la main.
- **Toutes les données serveur passent par React Query** (`hooks.ts`), jamais
de `fetch` dans les pages.
- **Logique pure dans `src/lib/`**, souvent miroir d'une fonction backend
(ex. `festivalDay` ↔ `day_boundary`) : la règle est écrite à l'identique
des deux côtés et le `CLAUDE.md` le signale.
- **Auth** : JWT en `localStorage`, `Authorization: Bearer`, une 401
déconnecte ; `<RequireAuth>` sur toutes les pages métier ; page d'accueil
et onglets visibles selon le rôle.
- **Variables Vite figées au build** (`VITE_*`) : l'image de prod est
construite pour un environnement donné.
- **Téléchargements** (PDF, Excel) : blob → `<a download>` généré côté
client.
- **Chaque erreur affiche sa référence** (`X-Request-ID`) sous le message,
pour retrouver la ligne de log correspondante.
---
## 4. Sécurité et RGPD (non négociables)
- **Deux systèmes d'authentification, une même session JWT** :
- mot de passe (bcrypt, ≥ 10 caractères) pour les administrateurs ;
- **lien magique** (jeton aléatoire haché SHA-256 en base, usage unique,
TTL court) pour les autres comptes ; réponse `202` constante, sans jamais
révéler si un compte existe.
- **Rôles** appliqués côté backend par endpoint, documentés dans une
**matrice ressource × rôle** dans le `CLAUDE.md`. **⟨à adapter⟩**
(ici `COORDINATOR` / `LEAD` / `VOLUNTEER`).
- **Limiteurs de tentatives** en mémoire (connexion, lien magique,
formulaires publics, sondages vers API tierces) — `429
error.too_many_requests`.
- **Démarrage refusé en production** avec un `JWT_SECRET` par défaut, vide
ou trop court.
- **Pages publiques protégées par jeton** dans l'URL (`secrets.token_urlsafe`,
comparaison `secrets.compare_digest`, 404 générique), régénérable pour
révoquer un lien qui a fuité ; hCaptcha optionnel (désactivé si la clé est
absente).
- **Fichiers importés revalidés** (décodage réel d'image avec PIL, pas
seulement le `Content-Type`) ; exports Excel protégés contre l'injection de
formule (CWE-1236).
- **Aucune donnée nominative dans les logs** (ni nom, ni email, ni
téléphone, ni sujet d'email personnalisable) : on logue des identifiants.
Journal d'accès d'uvicorn **désactivé** (il écrivait les query strings,
donc les jetons).
- **Logs de diagnostic** : une ligne par requête (méthode, route *modèle*
+ identifiants, statut, durée, code d'erreur métier, compte) ; WARNING en
4xx, ERROR + trace complète en 5xx ; `X-Request-ID` propagé jusque dans les
tâches de fond. Erreur 422 : emplacement + type, jamais la valeur saisie.
- **Rétention paramétrable** des données nominatives sur l'entité racine ;
endpoint de suppression complète d'une personne.
- **En-têtes de sécurité** dans nginx et Caddy ; backend et frontend
n'écoutent que sur `127.0.0.1`.
- **Emails** : SMTP auto-hébergé, `EMAIL_ENABLED=false` en dev/tests (simple
journalisation), `multipart/alternative` (texte brut = source de vérité
des tests + HTML au thème de l'appli, tableaux et styles en ligne, logo en
pièce jointe inline par Content-ID plutôt qu'une URL qui fuiterait un
jeton). Commande de diagnostic SMTP dédiée.
---
## 5. Style visuel (design « néo-brutaliste »)
Identité forte, lisible, sans bibliothèque de composants : **bordures
franches noires, ombres portées dures sans flou, libellés en capitales, fond
crème, accents saturés**.
### Palette (`tailwind.config.js`)
| Jeton | Valeur | Usage |
|-------|--------|-------|
| `cream` | `#F5EFE1` | fond général |
| `ink` | `#14110E` | encre, bordures, ombres |
| `paper` | `#FFFDF7` | panneaux, cartes |
| `sand` / `sand-line` / `sand-dark` | `#EFE8D6` / `#E0D8C4` / `#E7E0CE` | barres secondaires, séparateurs |
| `yellow` / `yellow-soft` / `yellow-hover` | `#FFD23F` / `#FFE68A` / `#FFDF77` | action principale, état incomplet |
| `orange` / `orange-dark` | `#FF5C39` / `#E0421F` | urgence, danger |
| `green` | `#0E9F6E` | succès, survol de l'action principale |
| `blue` / `blue-strong` | `#A8C4FF` / `#2A6BFF` | information |
| `mauve` / `mauve-hover` | `#CBA6E3` / `#BE93D9` | dépassement / surplus |
### Typographie
- `font-display` : **Archivo** (400 → 900), titres et boutons en
`font-black`/`font-extrabold`, `uppercase`, `tracking-wide`, petites
tailles (11–12 px).
- `font-mono` : **DM Mono**, pour les heures, chiffres, codes.
- Chargées depuis Google Fonts dans `index.html`.
### Primitives (`components/ui.tsx`)
- **Ombres** : `shadow-brutal-sm` (2 px), `brutal` (3 px), `brutal-md`
(4 px), `brutal-lg` (6 px) — toutes `Npx Npx 0 #14110E`.
- **`Panel`** : `border-[3px] border-ink bg-paper` + ombre portée.
- **`PanelHeader`** : bandeau `bg-ink text-cream` en capitales (variante
hachurée `bg-hatch-ink text-yellow`).
- **`Btn`** : `border-2 border-ink`, capitales, variantes `solid` (jaune →
vert au survol), `ghost` (crème → jaune), `danger` (crème → orange), `dark`
(encre → jaune).
- **Trames d'état** en `backgroundImage` : hachures orange (critique), mauve
(surplus), encre (brouillon), sable (fermé), pointillés.
- Infobulles (`HelpTip`) en `position: fixed` positionnées par JS
(`useTooltipPosition`), bornées à l'écran — jamais en `absolute` dans un
conteneur qui défile.
- Graphiques SVG maison, palette vérifiée daltonisme/contraste sur `paper`,
**chaque graphique doublé d'une vue tableau**.
- Écrans mobiles dédiés aux rôles « terrain » (consultation simple), écrans
denses pour l'administration.
Aucun texte dans les primitives : les libellés viennent toujours de l'i18n.
---
## 6. Conventions de code
- **Code, base, fichiers en anglais** ; français uniquement dans les
locales, les commentaires de contexte métier et les commits.
- **i18n partout, même en monolingue** : backend `app.i18n.t(key,
**params)` (`str.format`), frontend `t(key, params)` (`{nom}`).
- **Commentaires qui expliquent le *pourquoi*** (contraintes, bug évité,
choix non évident), pas le *quoi*.
- **Tests backend** : vraie base PostgreSQL (créée par
`docker/postgres-init`), chaque test dans une transaction annulée
(savepoints, `join_transaction_mode="create_savepoint"`), fixtures
`autouse` qui coupent le SMTP et réinitialisent les limiteurs,
`factories.py` pour les données. Un fichier `test_<module>.py` par module
ou router ; tests d'autorisation dédiés (`test_authz.py`).
- **Tests frontend** : `typecheck` + `lint` + `smoke` (rendu SSR des écrans
avec le mock, et vérifications structurelles quand un bug visuel a déjà eu
lieu — ex. positions de grille rendues).
- **Migrations** numérotées (`0001`, `0002`…), une par étape qui touche le
modèle, vérifiées par `alembic check`.
- **Pas de nouvelle dépendance sans raison écrite** (commentaire dans
`pyproject.toml` si contrainte de version).
---
## 7. Commandes types
| Action | Commande |
|--------|----------|
| Tout lancer avec la démo | `make demo` |
| Instance vierge | `make blank EMAIL=… PASSWORD=… NAME=…` |
| Dev | `docker compose up --build` (API : http://localhost:8000/docs, front : http://localhost:5173) |
| Tests backend | `docker compose run --rm backend uv run pytest` |
| Lint / format | `docker compose run --rm backend uv run ruff check .` / `ruff format .` |
| Migration | `docker compose run --rm backend uv run alembic revision --autogenerate -m "…"` |
| Appliquer / vérifier | `… alembic upgrade head` puis `… alembic check` |
| Front | `npm run typecheck && npm run lint && npm run smoke` |
---
## 8. Méthode de travail avec Claude
1. **Étape 1 — squelette** : monorepo, Docker Compose, backend qui démarre
(`/health`), frontend vide, CI/CD, `CLAUDE.md` initial avec glossaire.
2. **Étape 2 — modèle de données complet** + migration initiale.
3. **Ensuite** : import des données existantes (analyse puis exécution,
mode simulation, rapport ligne par ligne, détection de doublons), CRUD,
cœur métier en TDD, seed de démo, authentification, puis fonctionnalités
une par une.
4. **À chaque étape** : code + tests + migration + mise à jour du
`CLAUDE.md` (modèle, règles, journal) + front (mock compris) → montrer le
résultat → attendre la validation → commit atomique en français.
5. **Revues de sécurité régulières** avant toute ouverture au public, notées
comme étapes à part entière.
+246 -36
View File
@@ -57,7 +57,14 @@ les tables sauf les tables d'association.
- **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`, - **`Edition`** : `year`, `name` (opt.), `start_date`, `end_date`,
`retention_days` (durée de rétention des données nominatives en jours, `retention_days` (durée de rétention des données nominatives en jours,
`NULL` = pas de purge). `NULL` = pas de purge), `volunteer_message` (**message de
l'organisation**, le même pour tous les bénévoles, affiché en bas de
l'écran bénévole — saisi par la coordination, onglet « Message aux
bénévoles » de la configuration, `ConfigMessage.tsx`, via `PATCH
/editions/{id}`) et `volunteer_message_updated_at` (date affichée sous le
message). Même règle que `Position.message` : `app/api/_common.py:
set_message` — bords retirés, vide ⇒ `NULL`, date posée seulement si le
texte change, 2 000 caractères au plus.
- **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition - **`DayPart`** (« créneau type ») : découpage d'une journée propre à l'édition
(nombre et bornes libres). `code`, `label`, `start_time`, `end_time` (nombre et bornes libres). `code`, `label`, `start_time`, `end_time`
(`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`, (`end_time < start_time` ⇒ passe minuit), `order`. Unique (`edition_id`,
@@ -68,13 +75,20 @@ les tables sauf les tables d'association.
- **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles - **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles
peuvent partager une même adresse, ex. un couple ou une famille ; c'est le peuvent partager une même adresse, ex. un couple ou une famille ; c'est le
couple (email, nom + prénom) qui identifie un bénévole, pas l'email seul), couple (email, nom + prénom) qui identifie un bénévole, pas l'email seul),
`first_name`, `last_name`, `phone`, `comment`. Pas de date de naissance, `first_name`, `last_name`, `phone`, `comment`. **Aucun champ obligatoire
à l'ajout manuel** par la coordination (`POST .../volunteers`, prénom et
nom vides par défaut — fiche complétée plus tard) ; le formulaire public
et l'import exigent toujours prénom et nom. Un bénévole sans nom
s'affiche « Sans nom », initiales « ? » (`lib/volunteer.ts`). Pas de date de naissance,
taille de t-shirt, régime, ni compétences. Porte trois listes de vœux, taille de t-shirt, régime, ni compétences. Porte trois listes de vœux,
exposées par la fiche (`artist_ids`, `wished_position_ids`, exposées par la fiche (`artist_ids`, `wished_position_ids`,
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider). `wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
Renseignables aussi via le formulaire public (§ 9) : `photo`/ Renseignables aussi via le formulaire public (§ 9) : `photo`/
`photo_content_type` (image, servie séparément par `photo_content_type` (image, servie séparément par
`GET .../volunteers/{id}/photo`, COORD/LEAD), `companion_wish_note` (« je `GET .../volunteers/{id}/photo`, COORD/LEAD — la coordination peut aussi
l'ajouter, la remplacer ou la retirer depuis la fiche, section « Photo » :
`PUT`/`DELETE .../volunteers/{id}/photo`, COORDINATION, même validation
que le formulaire, `app/api/_common.py:read_photo`), `companion_wish_note` (« je
voudrais être avec » en texte libre — la personne visée n'existe pas voudrais être avec » en texte libre — la personne visée n'existe pas
forcément encore en base au moment de l'inscription, donc pas de lien forcément encore en base au moment de l'inscription, donc pas de lien
direct vers un `VolunteerCompanionWish` ; la coordination la résout à la direct vers un `VolunteerCompanionWish` ; la coordination la résout à la
@@ -83,7 +97,13 @@ les tables sauf les tables d'association.
effet sur les affectations) et `willing_to_pay_fee` (« je participe à la effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/ cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le `fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
paiement SumUp plus bas). paiement SumUp plus bas). `badge_text` : texte du badge propre à ce bénévole
(champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la
fiche (« Texte du badge ») — jamais par le formulaire public — et qui
remplace pour lui le texte commun de l'éditeur. `badge_count` : nombre
d'exemplaires de son badge imprimés sur les planches (1 par défaut, 1 à
100 — `CHECK >= 1` en base, borne haute à la saisie contre une faute de
frappe), « Nombre de badges » sur la fiche.
- **`Artist`** : programmation de l'édition (`name`, `performance_date`, - **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La lendemain). Unique (`edition_id`, `name`, `performance_date`). La
@@ -116,7 +136,21 @@ les tables sauf les tables d'association.
compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc compte `LEAD` qui ne référence plus aucun poste (tous décochés) reste donc
`LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le `LEAD`, mais `/mobile-referent` détecte ce cas et affiche alors, si le
compte est rattaché à une fiche bénévole, sa vue de bénévole classique compte est rattaché à une fiche bénévole, sa vue de bénévole classique
(`VolunteerHomeView`) plutôt qu'un écran référent vide. (`VolunteerHomeView`) plutôt qu'un écran référent vide. Un référent peut
aussi être affecté comme bénévole (même soirée comprise) : si son compte
est rattaché à une fiche bénévole, `/mobile-referent` a deux onglets,
« Mon équipe » et « Mon planning » (sa vue bénévole), ouvert sur « Mon
planning » quand il tient en ce moment un créneau de bénévole hors de ses
postes sans qu'aucun de ses postes ait un créneau en cours ; dans « Mon
équipe », il est marqué « TOI » s'il fait partie de l'équipe pointée.
- **`Position.message`** / `message_updated_at` : **message du poste**
(consignes : tenue, matériel, rendez-vous…), saisi par le référent depuis
son écran (`/mobile-referent`, « Message à ton équipe ») ou par la
coordination (`ConfigMessage.tsx`), via `PATCH .../positions/{id}` (même
contrôle d'écriture que le reste du poste : COORDINATION ou son
référent). Montré à chaque bénévole affecté au poste : repris dans
`GET .../volunteers/{vid}/planning` (`position_message`,
`position_message_updated_at`).
- **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont - **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont
il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at` il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at`
(peut finir le lendemain), `min_staff`, `ideal_staff`. **Aucun créneau (peut finir le lendemain), `min_staff`, `ideal_staff`. **Aucun créneau
@@ -140,6 +174,17 @@ les tables sauf les tables d'association.
jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/ jamais donner à un compte VOLUNTEER l'accès en lecture à `positions`/
`shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`) `shifts` de toute l'édition — c'est l'écran bénévole (`/mobile-benevole`)
qui en a besoin. qui en a besoin.
**Pointage** : `attendance` (`AttendanceStatus` `PRESENT` / `ABSENT`,
`NULL` = pas pointé) et `attendance_marked_at` (heure du dernier
pointage), posés par `PUT .../assignments/{id}/attendance`
(`{attendance}`, `null` annule — même contrôle d'écriture que
l'affectation : COORDINATION ou référent du poste). Écran référent : un
second appui sur le même bouton annule, mise à jour immédiate à l'écran
puis enregistrée (`hooks.ts:useSetAttendance`, retour arrière et message
si l'enregistrement échoue). Ce n'est pas le statut d'une affectation
(toujours valide dès sa création), juste la présence constatée sur
place. Avant l'étape 58, ce pointage n'était gardé que dans le
navigateur et se perdait au rechargement.
- **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`, - **`Account`** : `email` (unique par édition), `role` (`Role`), `edition_id`,
`volunteer_id` (nullable, unique). `volunteer_id` (nullable, unique).
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`, - **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
@@ -156,25 +201,88 @@ les tables sauf les tables d'association.
et la génération retombant alors sur le format CR80 par défaut (85,6 × et la génération retombant alors sur le format CR80 par défaut (85,6 ×
54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans 54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans
déformation, `preserveAspectRatio=True`) à ce même format. déformation, `preserveAspectRatio=True`) à ce même format.
- **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈ - **`BadgeField`** : un champ positionné sur le **recto ou le verso**
`FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE`, au plus un par édition — (`face` ∈ `FRONT` / `BACK` ; `field_type` ∈ `FULL_NAME` / `POSITIONS` /
`PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/ `CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`, au plus un par face et par
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge, édition — unique (`edition_id`, `face`, `field_type`), 422
`font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer) `badge.duplicate_field_type` sinon ; `PUT .../badges/template/fields`
depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`), remplace tout, les deux faces dans la même liste), coordonnées `x_mm`/
qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel `y_mm`/`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du
du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`). badge, `font_size`, `align`, `bold`, `color`, `bg_color` (fond plein de la
`POSITIONS` résout **une ligne par créneau affecté**, triée zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`),
chronologiquement : nom du poste + horaire (`HH:MM–HH:MM`), suivi du `auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que
référent du poste (`Position.lead_account_id` → son bénévole) s'il y en a tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne
un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes, sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`),
`\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ; `z_order` (ordre d'empilement sur la face : les champs sont dessinés du
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent »). plus petit au plus grand, à égalité la photo dessous — avant, l'ordre
alphabétique des types faisait passer la photo par-dessus le nom et le
texte libre ; l'éditeur renvoie toujours des rangs distincts 0…n-1,
`badges/model.ts:stackOrder`/`restack`, boutons « Superposition » de
l'inspecteur, un champ activé arrive au-dessus, la photo dessous ; l'aperçu
suit le même ordre, le cadre du champ sélectionné restant par-dessus pour
qu'un champ recouvert se saisisse encore).
`FREE_TEXT` et `ROLE` sont centrés verticalement dans leur cadre (règle
dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la
ligne. Édité visuellement depuis l'écran « Badges »
(`frontend/src/pages/badges/` — refonte d'après `design/Créateur de
badges.dc.html` : liste des champs recto/verso, aperçu des deux faces avec
les données d'un vrai bénévole, inspecteur, formats prédéfinis via
`PUT .../template/size`, annuler, flèches, zoom, repères de découpe
1,5 mm, contrôle de tous les badges avant génération — `issues.ts`).
L'aperçu reprend la mise en page du PDF à l'identique
(`badges/layout.ts`, avec les métriques Helvetica de ReportLab recopiées
dans `helveticaMetrics.ts`, pas une mesure du navigateur) : toute
règle de mise en page changée dans `app/badges/pdf.py` doit l'être aussi
dans `layout.ts`, sinon le contrôle avant génération ment.
`POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`,
dessiné par `app/badges/pdf.py:_draw_table`) des créneaux affectés,
**regroupés par jour de festival** (§ 3 — une soirée qui passe minuit
reste sur le jour où elle a commencé) : le jour en en-tête sur toute la
largeur (« Samedi 11 »), puis une ligne par créneau, colonne horaire
(`HH:MM–HH:MM`, à sa largeur naturelle) et colonne poste (le reste,
repliée si besoin), dans l'ordre chronologique. Pas de référent sur le
badge (choix de la coordination). Une ligne qui ne tient plus dans la
hauteur du cadre n'est pas imprimée ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ;
`FREE_TEXT` imprime `Volunteer.badge_text` s'il est renseigné, sinon le
texte commun saisi dans l'éditeur (`BadgeField.text`, ignoré pour les
autres types), sinon rien ; `PHOTO` imprime
`Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée
au ratio du cadre sans déformation, orientation EXIF appliquée et réduite
à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de
plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou
photo illisible, **rien n'est imprimé** (cadre vide, choix de la
coordination — avant l'étape 61, les initiales sur fond beige), sans
faire échouer la planche ; l'éditeur n'y montre qu'un repère pointillé
« sans photo », non imprimé ; `OVAL` découpe la photo en ellipse. Le texte d'un champ passe à la ligne pour tenir dans la largeur
de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au
milieu d'un mot trop long seul ; les `\n` restent des retours à la
ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme
l'aperçu de l'éditeur, qui fait de même côté navigateur. Avant, chaque
ligne s'imprimait d'un bloc et débordait du cadre. L'éditeur ramène tout champ à l'intérieur du badge
(`badges/model.ts:fitToBadge`) et met le champ sélectionné au premier
plan : les positions par défaut visent un CR80 paysage, et sur un badge
plus étroit un champ pouvait tomber hors du badge, avec des bornes de
glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo
impossible à déplacer ni redimensionner).
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` — `POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` —
absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est absent = tous ; chaque bénévole y figure `badge_count` fois, à la suite)
génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
par planche, de la page de verso correspondante (même image répétée à par planche, de la page de verso correspondante (image de verso puis
chaque emplacement) — impression recto-verso classique. champs `BACK` de chaque bénévole) — impression recto-verso classique
(retournement bord long) : sur la page de verso, chaque badge est placé
**en miroir horizontal** (colonne `cols-1-col`, `_cell_origin(mirrored=
True)`) pour tomber derrière son recto. La grille est **centrée et symétrique** sur la
page (`app/badges/pdf.py:_spacing`) : l'espace libre est réparti à parts
égales — même écart à gauche, entre les badges et à droite, et de même en
haut, entre les rangées et en bas (CR80 sur A4 : 12,9 mm et 16,2 mm),
marge d'impression de 10 mm au moins ; une fois la feuille retournée,
chaque verso tombe exactement derrière son recto. Avant l'étape 71, la
grille partait du coin haut-gauche et l'espace restant tombait à droite
et en bas, d'où un verso décalé (bug corrigé une fois). Indispensable depuis que le verso
est personnalisé ; avant, un verso identique pour tous masquait le
décalage.
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) : - **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/ aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
`Volunteer` existants. Complément des badges (un badge identifie un `Volunteer` existants. Complément des badges (un badge identifie un
@@ -183,8 +291,24 @@ les tables sauf les tables d'association.
téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour téléphone — « à désigner » si aucun) en en-tête, puis les créneaux du jour
avec les bénévoles affectés et leur téléphone, pour affichage sur place. avec les bénévoles affectés et leur téléphone, pour affichage sur place.
`POST /editions/{id}/position-sheets/generate` (COORDINATION, `POST /editions/{id}/position-sheets/generate` (COORDINATION,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune `{position_ids?}` — absent = tous). Mise en page compacte, pour tenir
affectation n'apparaît pas (rien à imprimer). sur une page par poste et par jour autant que possible : bénévoles **deux
par ligne** (vignette de 10 mm, nom en gras et téléphone dessous),
en-têtes et marges resserrés ; un créneau à besoin 0/0 sans aucune
affectation n'apparaît pas (rien à imprimer). Le **message de
l'organisation** (`Edition.volunteer_message`, s'il y en a un) est
imprimé en pied de **chaque** page, suite d'une feuille comprise
(`SheetFooter`, marge du bas agrandie de sa hauteur pour que le contenu
ne passe jamais dessous). Photo de chaque bénévole en
vignette (10 mm) : réduite à 300 dpi (`app/sheets/pdf.py:_thumbnail`,
décodage JPEG à taille réduite via `draft`) une seule fois par PDF, une
photo illisible laissant la case vide. Avant l'étape 62, la photo
d'origine (plusieurs Mo, prise au téléphone) était intégrée telle quelle
pour chaque bénévole de chaque créneau : génération de plusieurs minutes,
serveur à 300 % de CPU, PDF énorme et « erreur inattendue » (bug corrigé
une fois). Même principe pour les badges (`_fit_photo` + `PhotoCache` :
une photo recadrée une fois par génération, malgré recto, verso et
exemplaires multiples).
- **Statistiques** (`GET /editions/{id}/stats`, COORDINATION, - **Statistiques** (`GET /editions/{id}/stats`, COORDINATION,
`app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune `app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune
nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total, nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total,
@@ -201,7 +325,13 @@ les tables sauf les tables d'association.
aucune bibliothèque), palette validée (daltonisme/contraste) sur le fond aucune bibliothèque), palette validée (daltonisme/contraste) sur le fond
`paper`, chaque graphique doublé d'une vue tableau. Le mock calcule les `paper`, chaque graphique doublé d'une vue tableau. Le mock calcule les
mêmes chiffres (`buildStats`) et étale les dates d'inscription du jeu mêmes chiffres (`buildStats`) et étale les dates d'inscription du jeu
de démo (`registeredAt`). de démo (`registeredAt`). **Présence** (`attendance`,
`_attendance` / mock `buildAttendance`) : présents, absents et non
pointés (`Assignment.attendance`) par poste, sur les créneaux terminés
et sur ceux en cours à `as_of` (créneaux à venir ignorés), avec le taux
de présence parmi les bénévoles pointés ; la section passe en tête de
l'écran tant qu'un créneau est en cours, et l'écran se rafraîchit
toutes les 30 s (`useStats`, `refetchInterval`).
- **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET - **Export bénévoles** (Excel, `app/exporting/xlsx.py` + `GET
/editions/{id}/volunteers/export`, COORDINATION) : une ligne par /editions/{id}/volunteers/export`, COORDINATION) : une ligne par
bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation, bénévole, mêmes champs que la fiche (coordonnées, vœux, cotisation,
@@ -456,7 +586,26 @@ compte** : c'est un champ de la fiche, pas une inscription. Tant qu'aucun
personne à qui écrire pour cet email et ne fait rien (202 quoi qu'il arrive, personne à qui écrire pour cet email et ne fait rien (202 quoi qu'il arrive,
pour ne jamais révéler si un compte existe). Front : bouton « Donner un accès pour ne jamais révéler si un compte existe). Front : bouton « Donner un accès
de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle de connexion » sur la fiche du bénévole (`ConfigVolunteers.tsx`), qui appelle
cet endpoint avec `role=VOLUNTEER`. cet endpoint avec `role=VOLUNTEER`. Seule exception : envoyer un
récapitulatif de planning contenant `{lien_connexion}` vaut aussi « donner
un accès » (voir plus bas).
*Lien de connexion permanent* — celui du récapitulatif de planning
(`{lien_connexion}`, `app/auth/permanent_link.py`) : connexion directe en
un clic, **réutilisable et sans expiration** (contrairement au lien magique
de la page de connexion, 30 min et usage unique), pour que le bénévole
retrouve son planning depuis n'importe quel email reçu. Rien n'est stocké :
jeton `pl.<id du compte>.<HMAC(JWT_SECRET, id + login_link_version)>`,
vérifié par le même `POST .../auth/verify` (même page `/connexion?token=`
côté front). Bénévoles et référents seulement — jamais un compte de la
coordination ni à mot de passe. Révocation : bouton « Renouveler le lien
de connexion » sur la fiche (`POST .../accounts/{id}/reset-login-link`,
COORDINATION, incrémente `Account.login_link_version`, migration `0031`) —
tous les liens déjà envoyés à ce compte cessent de fonctionner ; changer
`JWT_SECRET` les révoque tous. Un bénévole sans compte en reçoit un
(`VOLUNTEER`, à son email) à l'envoi d'un récapitulatif avec
`{lien_connexion}` ; si son email est déjà celui d'un autre compte (couple,
famille), pas de compte possible : lien vers la page de connexion seule.
`PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et `PATCH /editions/{id}/accounts/{account_id}` change uniquement le `role`, et
seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422 seulement entre `LEAD` et `VOLUNTEER` (lien magique dans les deux cas) — 422
@@ -482,6 +631,22 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
alias validé. alias validé.
- Diagnostic : `docker compose run --rm backend uv run python -m app.email.check - Diagnostic : `docker compose run --rm backend uv run python -m app.email.check
<destinataire>` — affiche la config effective et la cause exacte d'un échec. <destinataire>` — affiche la config effective et la cause exacte d'un échec.
- **Email de test depuis l'appli** : bloc « Tester l'envoi d'emails » de
l'onglet « Messages » de la configuration (`ConfigMail.tsx`) — une
adresse et un bouton. `POST /editions/{id}/notifications/test` (`{email}`,
COORDINATION) envoie **tout de suite** (pas en tâche de fond) le
**récapitulatif de planning tel que les bénévoles le recevront** : le
modèle en cours d'édition (`subject`/`body`, case « avec le lien de
connexion » = même corps que le bouton du même nom), variables remplies
avec le planning d'un vrai bénévole (`volunteer_id`, à défaut le premier
par nom qui a un créneau, à défaut un exemple fictif), sujet préfixé
« [Test] », même mise en forme et même logo — code partagé avec l'envoi
réel (`notifications.py:_planning_email`). `{lien_connexion}` y mène à la
page de connexion **sans jeton** (jamais de vrai lien magique vers la
boîte de test). Renvoie le résultat réel (`app/email/sender.py:deliver_test`) : `sent`, `disabled`
(`EMAIL_ENABLED=false`), `connection_failed`, `rejected`, avec la réponse
du serveur SMTP (jamais l'adresse du destinataire), le serveur et
l'expéditeur configurés — sans accès au serveur ni à ses logs.
- **Mise en forme** : chaque email part en `multipart/alternative` — texte - **Mise en forme** : chaque email part en `multipart/alternative` — texte
brut (source de vérité, utilisé par les tests) **et** une version HTML au brut (source de vérité, utilisé par les tests) **et** une version HTML au
thème de l'appli (`app/email/html.py:render_html` — couleurs/typographie thème de l'appli (`app/email/html.py:render_html` — couleurs/typographie
@@ -503,11 +668,20 @@ ni corps**). Config : `SMTP_HOST/PORT/USER/PASSWORD/STARTTLS/SSL/FROM`,
- **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning` - **Récapitulatif de planning** : `POST /editions/{id}/notifications/planning`
(COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body` (COORDINATION), corps `{ subject?, body?, volunteer_ids? }`. `subject`/`body`
sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`, sont des **modèles** avec `{prenom}`, `{nom}`, `{nb_creneaux}`, `{nb_heures}`,
`{creneaux}`, `{lien_connexion}` (si présent ⇒ un lien magique personnel est `{creneaux}`, `{lien_connexion}` (lien de connexion **permanent** du
émis pour le compte du bénévole). `volunteer_ids` absent ⇒ tous les bénévoles bénévole, voir « Lien de connexion permanent » plus haut — compte créé
s'il n'en a pas). `volunteer_ids` absent ⇒ tous les bénévoles
de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`, de l'édition qui ont un email. Envoi en tâche de fond (`BackgroundTasks`,
une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email, une connexion SMTP réutilisée). Réponse : `{ queued, skipped_no_email,
recipients }`. recipients }`. `{creneaux}` est un **tableau**, comme sur les badges : le jour de
festival en en-tête (une nuit reste sur le jour où elle a commencé),
puis une ligne par créneau — horaire, poste, référent du poste avec son
téléphone (« à désigner » sinon). Vrai tableau HTML dans la version HTML
(`app/email/html.py:render_planning_table`, inséré à la place d'un repère
via `build_message(html_body=…, html_blocks=…)`), et version texte une
ligne par créneau sous chaque jour (`email.planning.table.text_line`).
Composition partagée par l'envoi réel et l'email de test
(`notifications.py:_planning_email`, `_load_context`).
- **Confirmation d'inscription** (`app/api/forms.py`) : dès qu'un `Volunteer` - **Confirmation d'inscription** (`app/api/forms.py`) : dès qu'un `Volunteer`
est effectivement créé et a un email — que ce soit tout de suite (pas de est effectivement créé et a un email — que ce soit tout de suite (pas de
paiement en ligne) ou à la promotion d'une `PendingFormSubmission` paiement en ligne) ou à la promotion d'une `PendingFormSubmission`
@@ -684,10 +858,18 @@ bug. Largeur adaptative par jour (`columnWidthPx`,
en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges, en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges,
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
étroites par défaut. étroites par défaut.
La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle, La « Vue par bénévole » (`VolunteerViewPage.tsx`) utilise le même axe des
distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`) temps (`buildTimeGrid` / `placeInTimeGrid`, même largeur de tranche par
plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus jour) : chaque affectation est une barre étirée sur toute la durée de son
adapté à son tableau bénévole × créneau. créneau, nom du poste dedans. Deux postes tenus en même temps s'empilent
sur deux lignes dans la rangée du bénévole (qui s'agrandit) et passent en
jaune. Ligne verticale orange à l'heure actuelle, avec l'heure dans
l'en-tête des tranches. Barre de recherche (nom du bénévole ou d'un de
ses postes, sans accents ni casse) : ne garde que les bénévoles qui
correspondent, et cerne d'orange les postes trouvés. Avant l'étape 60, une colonne par créneau distinct
(`buildLattice`, supprimé) : une affectation n'apparaissait que dans la
colonne de son propre créneau, jamais sur les autres colonnes qu'elle
couvrait, et deux postes simultanés se masquaient.
--- ---
@@ -717,10 +899,11 @@ adapté à son tableau bénévole × créneau.
### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte) ### Hors périmètre (le modèle doit juste pouvoir les accueillir sans refonte)
SMS · pointage sur site · fonctions d'IA. *(L'envoi d'emails par SMTP est en SMS · fonctions d'IA. *(L'envoi d'emails par SMTP est en
place — voir § 2. Le formulaire public d'inscription est en place — voir § 2 place — voir § 2. Le formulaire public d'inscription est en place — voir § 2
et § 9 ; ce n'était pas prévu à l'origine, retiré de cette liste à et § 9 ; ce n'était pas prévu à l'origine, retiré de cette liste à
l'étape 21.)* l'étape 21. Le pointage présent / absent par le référent est en place depuis
l'étape 58 — `Assignment.attendance`, § 2.)*
--- ---
@@ -917,7 +1100,34 @@ Aucune dépendance locale requise hors Docker.
| 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider | | 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider |
| 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à valider | | 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à valider |
| 44 | Un bénévole peut être affecté à deux postes en même temps : `SHIFT_OVERLAP` passe de `BLOCKING` à `WARNING` (affectation enregistrée, avertissement affiché) ; le solveur ne l'exclut plus mais lui applique un fort malus (`_ALREADY_BUSY_PENALTY`) — proposé seulement pour un créneau sous son minimum, faute de mieux — repris dans le classement par défaut du panneau et dans le mock | à valider | | 44 | Un bénévole peut être affecté à deux postes en même temps : `SHIFT_OVERLAP` passe de `BLOCKING` à `WARNING` (affectation enregistrée, avertissement affiché) ; le solveur ne l'exclut plus mais lui applique un fort malus (`_ALREADY_BUSY_PENALTY`) — proposé seulement pour un créneau sous son minimum, faute de mieux — repris dans le classement par défaut du panneau et dans le mock | à valider |
| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
| 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider |
| 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider |
| 48 | Badges : texte libre saisi dans l'éditeur (`BadgeField.text`, migration `0025`), commun à tous les badges, le « Texte du badge » de la fiche le remplaçant pour un bénévole ; champs ramenés dans le badge et champ sélectionné au premier plan (la photo restait bloquée hors d'un badge plus étroit que le CR80) | à valider |
| 49 | Badges : le texte des champs (nom, postes et horaires…) passe à la ligne pour tenir dans la largeur de son cadre au lieu de déborder (`app/badges/pdf.py:wrap_text`) | à valider |
| 50 | Badges : les créneaux affectés s'affichent en tableau — le jour de festival en en-tête, puis une colonne horaire et une colonne poste (le référent n'est plus imprimé sur le badge), filets à la couleur du champ, cellules repliées dans leur colonne ; même tableau dans l'aperçu de l'éditeur | à valider |
| 51 | Refonte du créateur de badges d'après la maquette (`design/`) : champs sur le recto **et le verso** (`BadgeField.face`, verso en miroir dans le PDF), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration `0026`) ; éditeur en 3 colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler/flèches/zoom/repères, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération | à valider |
| 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider |
| 53 | Vue par bénévole : tri des lignes par nom ou par total d'heures affectées, croissant / décroissant (boutons « Trier par » et clic sur l'en-tête « TOTAL H ») — à total égal, l'ordre par nom est conservé | à valider |
| 54 | Badges : ordre de superposition des champs (`BadgeField.z_order`, migration `0028` — champs existants renumérotés photo dessous) : boutons « Premier plan / Avancer / Reculer / Arrière-plan » dans l'inspecteur, aperçu et PDF dessinés dans cet ordre — la photo recouvrait le texte | à valider |
| 55 | Écrans bénévole et référent redessinés d'après la maquette (`design/Planning bénévole mobile.dc.html`, pensée pour le téléphone — tablette et desktop gardent la même colonne, centrée) : bénévole — créneau en cours ou prochain mis en avant (compte à rebours, lieu, chevauchement, bouton « Appeler » le référent), planning par journée de festival avec frise horaire et statut de chaque créneau (terminé / en cours), message de fin de festival ; référent — onglets de postes, choix du créneau (en cours ou prochain par défaut), compteurs présents / attendus / absents, alerte d'absence, pointage (toujours local à l'écran) avec appel direct. Pas encore repris de la maquette, faute de donnée : téléphone de la coordination, bouton « Demander un renfort » (consigne du poste et message de l'organisation : étape 56) | à valider |
| 56 | Messages aux bénévoles : message de l'organisation, le même pour tous (`Edition.volunteer_message`, onglet « Message aux bénévoles » de la configuration), et message de chaque poste (`Position.message`, saisi par le référent sur son écran ou par la coordination), migration `0029` — affichés sur l'écran bénévole (bloc « Message de l'organisation » daté, consignes du poste sur le créneau mis en avant et dans la liste) | à valider |
| 57 | Référent aussi bénévole : onglets « Mon équipe » / « Mon planning » sur l'écran référent (compte rattaché à une fiche bénévole), ouverture sur celui qui correspond à ce qu'il fait en ce moment, pastille sur l'onglet dont un créneau est en cours, mention « TOI » dans l'équipe pointée | à valider |
| 58 | Pointage enregistré : présent / absent du référent sauvegardé sur l'affectation (`Assignment.attendance` + heure, migration `0030`, `PUT .../assignments/{id}/attendance`, COORDINATION ou référent du poste) — il survivait seulement jusqu'au rechargement de la page ; heure du pointage affichée, message si l'enregistrement échoue | à valider |
| 59 | Statistiques de présence : section « Présence » de l'écran Statistiques — en ce moment (créneaux en cours : attendus, présents, absents, non pointés par poste) et créneaux terminés (même détail + taux de présence parmi les pointés), rafraîchie toutes les 30 s, en tête de l'écran pendant un créneau | à valider |
| 60 | Vue par bénévole sur un vrai axe des temps (tranches de 30 min par jour, comme la grille des besoins) : affectations étirées sur toute leur durée avec le nom du poste, deux postes simultanés empilés sur deux lignes et signalés en jaune, ligne verticale orange de l'heure actuelle avec l'heure — une affectation n'apparaissait que dans la colonne de son propre créneau | à valider |
| 61 | Badges : sans photo, le cadre de la photo reste vide (plus d'initiales sur fond beige) — PDF, aperçu de l'éditeur (repère pointillé non imprimé) et messages de contrôle | à valider |
| 62 | Feuilles de poste : génération interminable (CPU à 300 %, puis « erreur inattendue ») — photos d'origine intégrées en pleine résolution pour chaque bénévole de chaque créneau ; désormais réduites à la vignette une fois par PDF (40 photos : 15 s / 24 Mo ⇒ 0,2 s / 109 Ko), photo illisible ignorée. Badges : photo recadrée une seule fois par génération et décodée à taille réduite (120 badges : 10 s ⇒ 0,3 s) | à valider |
| 63 | Feuilles de poste : message de l'organisation en bas de chaque page (titre + texte, retours à la ligne conservés), rien si aucun message | à valider |
| 64 | Vue par bénévole : barre de recherche sur le nom du bénévole ou d'un de ses postes (sans accents ni casse), postes trouvés cernés d'orange, cumulable avec les filtres et le tri | à valider |
| 65 | Feuilles de poste compactées : bénévoles deux par ligne (vignette 10 mm, nom et téléphone superposés), titres, en-têtes de créneau et marges resserrés — sur un jeu réaliste de 6 feuilles, 11 pages ⇒ 6 (une par poste et par jour) | à valider |
| 66 | Ajout manuel d'un bénévole sans champ obligatoire (prénom et nom facultatifs, côté formulaire et API) ; un bénévole sans nom s'affiche « Sans nom » / « ? » dans les listes, la grille, la vue par bénévole, les badges et l'écran référent | à valider |
| 67 | Email de test : adresse + bouton dans l'onglet « Messages » de la configuration, envoi immédiat et résultat réel affiché (envoyé, envoi désactivé, connexion refusée, message refusé, avec la réponse du serveur SMTP) — `POST .../notifications/test` | à valider |
| 68 | L'email de test est le récapitulatif de planning compilé tel qu'envoyé aux bénévoles (modèle en cours d'édition, variables remplies avec le planning d'un bénévole au choix, option lien de connexion — sans vrai jeton —, sujet « [Test] … ») | à valider |
| 69 | Récapitulatif de planning : `{creneaux}` devient un tableau par jour de festival (horaire, poste, référent avec son téléphone), en HTML dans l'email et en lignes lisibles dans la version texte — envoi réel et email de test | à valider |
| 70 | `{lien_connexion}` devient un lien de connexion directe permanent (réutilisable, sans expiration, signé HMAC, rien de stocké — avant : lien magique de 30 min à usage unique, ou simple lien vers le site sans compte) ; compte bénévole créé à l'envoi s'il manque ; révocation par « Renouveler le lien de connexion » sur la fiche (`Account.login_link_version`, migration `0031`) ; jamais pour la coordination | à valider |
| 71 | Planches de badges centrées : espace libre réparti à parts égales (mêmes écarts à gauche, entre les badges, à droite ; en haut, entre les rangées, en bas), grille symétrique — le verso, imprimé en miroir, tombe exactement derrière le recto au lieu d'être décalé de l'espace restant | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, IA. Frontend : squelette + 6 écrans +
connexion en place (voir `frontend/`). connexion en place (voir `frontend/`).
+57 -3
View File
@@ -3,15 +3,18 @@
`resolve_owned_ids`/`set_artists`/`set_wished_positions` sont utilisés à la `resolve_owned_ids`/`set_artists`/`set_wished_positions` sont utilisés à la
fois par `volunteers.py` (fiche éditée par la coordination) et `forms.py` fois par `volunteers.py` (fiche éditée par la coordination) et `forms.py`
(vœux saisis depuis le formulaire public) — un seul endroit pour la (vœux saisis depuis le formulaire public) — un seul endroit pour la
validation « ces ids appartiennent bien à cette édition ».""" validation « ces ids appartiennent bien à cette édition ». Idem pour
`read_photo` (photo d'un bénévole)."""
import io
import uuid import uuid
from fastapi import HTTPException from fastapi import HTTPException, UploadFile
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete from sqlalchemy import delete
from sqlmodel import Session, select from sqlmodel import Session, select
from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish from app.models import Artist, Position, VolunteerArtist, VolunteerPositionWish, local_now
def owned_or_404(instance, edition_id: uuid.UUID): def owned_or_404(instance, edition_id: uuid.UUID):
@@ -77,3 +80,54 @@ def set_wished_positions(
filter_value=volunteer.id, filter_value=volunteer.id,
pairs=[{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids], pairs=[{"volunteer_id": volunteer.id, "position_id": pid} for pid in ids],
) )
MAX_PHOTO_BYTES = 8 * 1024 * 1024
ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
async def read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
"""Validation partagée par le formulaire public (inscription immédiate
ou en attente de paiement) et l'ajout d'une photo par la coordination
depuis la fiche (`volunteers.py:set_volunteer_photo`) — `None` si aucun
fichier fourni. `Content-Type` est
un en-tête fourni par l'appelant, jamais vérifié par lui-même (ce
endpoint est public, sans authentification) : un fichier qui ne se
décode pas comme une vraie image malgré un `Content-Type` correct
(`image/png`/`image/jpeg` revendiqué) est rejeté ici plutôt que de
planter plus tard la génération des badges/feuilles de poste
(`app/badges/pdf.py`/`app/sheets/pdf.py`, qui lisent ces octets sans
filet) — même validation que l'import d'image des badges
(`app/api/badges.py:_pixel_size`, réservé à la coordination)."""
if photo is None or not photo.filename:
return None
content_type = photo.content_type or ""
if content_type not in ALLOWED_PHOTO_TYPES:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
)
content = await photo.read()
if len(content) > MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
if not content:
return None
try:
with Image.open(io.BytesIO(content)) as img:
img.verify()
except (UnidentifiedImageError, OSError) as exc:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
) from exc
return content, content_type
def set_message(target: object, field: str, value: str | None) -> None:
"""Message affiché aux bénévoles (`Edition.volunteer_message`,
`Position.message`) : espaces de bord retirés, vide = plus de message.
`{field}_updated_at` n'est daté que si le texte change vraiment — il dit
aux bénévoles de quand date le message."""
text = (value or "").strip() or None
if text == getattr(target, field):
return
setattr(target, field, text)
setattr(target, f"{field}_updated_at", local_now() if text else None)
+18
View File
@@ -109,6 +109,24 @@ def update_account(
return account return account
@router.post("/{account_id}/reset-login-link", response_model=AccountRead)
def reset_login_link(
account_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Account:
"""Révoque le lien de connexion permanent du compte (`app/auth/
permanent_link.py`) : tous les liens déjà envoyés cessent de
fonctionner ; le prochain récapitulatif portera un nouveau lien. Ex.
email transféré, téléphone perdu."""
account = owned_or_404(session.get(Account, account_id), edition.id)
account.login_link_version += 1
session.commit()
session.refresh(account)
return account
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_account( def delete_account(
account_id: uuid.UUID, account_id: uuid.UUID,
+26 -1
View File
@@ -17,6 +17,7 @@ from app.api.schemas import (
AssignmentValidateRequest, AssignmentValidateRequest,
AssignmentValidateResult, AssignmentValidateResult,
AssignmentWriteResult, AssignmentWriteResult,
AttendanceUpdate,
) )
from app.auth.deps import ( from app.auth.deps import (
current_account_in_edition, current_account_in_edition,
@@ -24,7 +25,7 @@ from app.auth.deps import (
require_staff, require_staff,
) )
from app.db import get_session from app.db import get_session
from app.models import Account, Assignment, Edition, Role, Shift, Volunteer from app.models import Account, Assignment, Edition, Role, Shift, Volunteer, local_now
from app.validation import has_blocking, validate from app.validation import has_blocking, validate
from app.validation.loader import build_validation_input from app.validation.loader import build_validation_input
@@ -206,6 +207,30 @@ def update_assignment(
return AssignmentWriteResult(assignment=assignment, warnings=warnings) return AssignmentWriteResult(assignment=assignment, warnings=warnings)
@router.put("/{assignment_id}/attendance", response_model=AssignmentRead)
def set_attendance(
assignment_id: uuid.UUID,
payload: AttendanceUpdate,
edition: Edition = Depends(get_edition),
account: Account = Depends(current_account_in_edition),
session: Session = Depends(get_session),
) -> Assignment:
"""Pointe un bénévole présent ou absent sur son créneau (`None` annule) —
le référent du poste ou la coordination. Enregistré en base : le
pointage survit à un rechargement et se retrouve sur tout appareil."""
assignment = owned_or_404(
session.get(Assignment, assignment_id), edition.id
)
ensure_can_write_shift(
account, session, session.get(Shift, assignment.shift_id)
)
assignment.attendance = payload.attendance
assignment.attendance_marked_at = local_now() if payload.attendance else None
session.commit()
session.refresh(assignment)
return assignment
@router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{assignment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_assignment( def delete_assignment(
assignment_id: uuid.UUID, assignment_id: uuid.UUID,
+12
View File
@@ -25,6 +25,8 @@ from app.api.schemas import (
from app.auth.deps import get_current_account from app.auth.deps import get_current_account
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token from app.auth.magic_link import build_magic_link_url, issue_magic_link_token
from app.auth.passwords import MIN_LENGTH, hash_password, verify_password from app.auth.passwords import MIN_LENGTH, hash_password, verify_password
from app.auth.permanent_link import PREFIX as PERMANENT_PREFIX
from app.auth.permanent_link import account_from_permanent_token
from app.auth.rate_limit import login_limiter, magic_link_limiter from app.auth.rate_limit import login_limiter, magic_link_limiter
from app.auth.tokens import create_access_token, hash_token from app.auth.tokens import create_access_token, hash_token
from app.config import get_settings from app.config import get_settings
@@ -184,6 +186,16 @@ def verify_magic_link(
payload: VerifyRequest, payload: VerifyRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> TokenResponse: ) -> TokenResponse:
raw = payload.token.strip()
if raw.startswith(PERMANENT_PREFIX):
# Lien permanent du récapitulatif de planning : réutilisable, sans
# expiration, révocable (voir `app/auth/permanent_link.py`).
account = account_from_permanent_token(session, raw)
if account is None or account.edition_id != edition_id:
logger.info("Lien permanent refusé (édition %s)", edition_id)
raise _invalid_token()
logger.info("Connexion par lien permanent : compte %s (édition %s)", account.id, edition_id)
return _issue(account)
token = session.exec( token = session.exec(
select(MagicLinkToken).where( select(MagicLinkToken).where(
MagicLinkToken.token_hash == hash_token(payload.token.strip()) MagicLinkToken.token_hash == hash_token(payload.token.strip())
+101 -43
View File
@@ -1,20 +1,22 @@
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION) """Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
et génération du PDF. et génération du PDF.
Le verso est une image fixe, la même pour tous les bénévoles. Le recto Chaque face (recto, verso) combine une image de fond (la même pour tous les
combine une image de fond et des champs positionnés (nom, poste(s) affectés, bénévoles) et des champs positionnés (nom, poste(s) affectés, catégorie,
catégorie, rôle) — voir `app/badges/pdf.py` pour la composition du PDF et rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` pour la
`app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle. composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
`BadgeField`) pour le modèle.
""" """
import datetime as dt
import io import io
import re
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.orm import aliased
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api.deps import get_edition from app.api.deps import get_edition
@@ -31,6 +33,8 @@ from app.badges.pdf import (
DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec, BadgeFieldSpec,
BadgeSpec, BadgeSpec,
BadgeTable,
BadgeTableSection,
generate_badge_sheets_pdf, generate_badge_sheets_pdf,
image_size_mm, image_size_mm,
) )
@@ -39,20 +43,26 @@ from app.i18n import t
from app.models import ( from app.models import (
Account, Account,
Assignment, Assignment,
BadgeFace,
BadgeField, BadgeField,
BadgeFieldType, BadgeFieldType,
BadgeTemplate, BadgeTemplate,
DayPart,
Edition, Edition,
Position, Position,
Role, Role,
Shift, Shift,
Volunteer, Volunteer,
) )
from app.validation.engine import day_boundary, festival_day
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"]) router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
_MAX_IMAGE_BYTES = 8 * 1024 * 1024 _MAX_IMAGE_BYTES = 8 * 1024 * 1024
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"} _ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}")
# Champs centrés verticalement dans leur cadre (les autres partent du haut).
_MIDDLE_ALIGNED = {BadgeFieldType.FREE_TEXT, BadgeFieldType.ROLE}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -75,7 +85,14 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
session.exec( session.exec(
select(BadgeField) select(BadgeField)
.where(BadgeField.edition_id == edition_id) .where(BadgeField.edition_id == edition_id)
.order_by(BadgeField.field_type) # Ordre de dessin (voir `BadgeField.z_order`) ; à égalité, la
# photo dessous, pour ne jamais masquer un texte.
.order_by(
BadgeField.face,
BadgeField.z_order,
(BadgeField.field_type == BadgeFieldType.PHOTO).desc(),
BadgeField.field_type,
)
) )
) )
@@ -201,9 +218,11 @@ def set_fields(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> list[BadgeField]: ) -> list[BadgeField]:
seen: set[BadgeFieldType] = set() # Au plus un champ de chaque type par face ; le même sur les deux faces
# est permis.
seen: set[tuple[BadgeFace, BadgeFieldType]] = set()
for f in payload.fields: for f in payload.fields:
if f.field_type in seen: if (f.face, f.field_type) in seen:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail={ detail={
@@ -211,7 +230,12 @@ def set_fields(
"params": {"field_type": f.field_type}, "params": {"field_type": f.field_type},
}, },
) )
seen.add(f.field_type) seen.add((f.face, f.field_type))
if f.bg_color is not None and not _HEX_COLOR.fullmatch(f.bg_color):
raise HTTPException(
status_code=422,
detail={"code": "badge.invalid_color", "params": {"color": f.bg_color}},
)
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id)) session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
for f in payload.fields: for f in payload.fields:
@@ -245,7 +269,7 @@ def set_size(
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Génération du PDF # # Génération du PDF #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
AssignmentRow = tuple[Position, Shift, Volunteer | None] # (poste, créneau, référent) AssignmentRow = tuple[Position, Shift]
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str: def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
@@ -258,16 +282,29 @@ def _category_label(category) -> str:
return t(f"position.category.{category.value.lower()}") return t(f"position.category.{category.value.lower()}")
def _position_line( def _day_label(day: dt.date) -> str:
position: Position, shift: Shift, referent: Volunteer | None, volunteer_id: uuid.UUID """En-tête d'un jour dans le tableau des créneaux : « Samedi 11 »."""
) -> str: return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
"""Une ligne par créneau affecté : poste + horaire, et le référent du
poste s'il y en a un — sauf si le bénévole badgé est lui-même ce
référent (pas besoin de se l'indiquer à soi-même).""" def _positions_table(assignments: list[AssignmentRow], boundary: dt.time) -> BadgeTable | None:
line = f"{position.name} {shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}" """Créneaux affectés regroupés par jour de festival (une soirée qui
if referent is not None and referent.id != volunteer_id: passe minuit reste sur le jour où elle a commencé — `festival_day`) :
line += f" ({t('badge.role.referent')} : {referent.first_name} {referent.last_name})" le jour en en-tête, puis une ligne horaire / poste par créneau, dans
return line l'ordre chronologique. `None` sans aucun créneau (rien à dessiner)."""
by_day: dict[dt.date, list[tuple[str, str]]] = defaultdict(list)
for position, shift in assignments:
by_day[festival_day(shift.starts_at, boundary)].append(
(f"{shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}", position.name)
)
if not by_day:
return None
return BadgeTable(
sections=tuple(
BadgeTableSection(title=_day_label(day), rows=tuple(rows))
for day, rows in sorted(by_day.items())
)
)
def _field_text( def _field_text(
@@ -276,17 +313,22 @@ def _field_text(
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]], assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
lead_volunteer_ids: set[uuid.UUID], lead_volunteer_ids: set[uuid.UUID],
) -> str: ) -> str:
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
(`_positions_table`)."""
if field.field_type == BadgeFieldType.FULL_NAME: if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}" # Prénom puis nom, chacun sur sa ligne.
return f"{volunteer.first_name}\n{volunteer.last_name}"
if field.field_type == BadgeFieldType.PHOTO:
# Une image, pas un texte (rien d'imprimé sans photo).
return ""
if field.field_type == BadgeFieldType.FREE_TEXT:
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
# le « Texte du badge » de sa fiche s'il en a un.
return volunteer.badge_text or field.text or ""
assignments = assignments_by_volunteer.get(volunteer.id, []) assignments = assignments_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.POSITIONS:
return "\n".join(
_position_line(position, shift, referent, volunteer.id)
for position, shift, referent in assignments
)
if field.field_type == BadgeFieldType.CATEGORY: if field.field_type == BadgeFieldType.CATEGORY:
categories = sorted( categories = sorted(
{_category_label(position.category) for position, _shift, _r in assignments} {_category_label(position.category) for position, _shift in assignments}
) )
return ", ".join(categories) return ", ".join(categories)
if field.field_type == BadgeFieldType.ROLE: if field.field_type == BadgeFieldType.ROLE:
@@ -314,20 +356,20 @@ def generate_badges(
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"}) raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
volunteer_ids = [v.id for v in volunteers] volunteer_ids = [v.id for v in volunteers]
Referent = aliased(Volunteer)
rows = session.exec( rows = session.exec(
select(Assignment.volunteer_id, Position, Shift, Referent) select(Assignment.volunteer_id, Position, Shift)
.join(Shift, Shift.id == Assignment.shift_id) .join(Shift, Shift.id == Assignment.shift_id)
.join(Position, Position.id == Shift.position_id) .join(Position, Position.id == Shift.position_id)
.join(Account, Account.id == Position.lead_account_id, isouter=True)
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
.where(Assignment.volunteer_id.in_(volunteer_ids)) .where(Assignment.volunteer_id.in_(volunteer_ids))
.order_by(Shift.starts_at) .order_by(Shift.starts_at)
).all() ).all()
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list) assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
for volunteer_id, position, shift, referent in rows: for volunteer_id, position, shift in rows:
assignments_by_volunteer[volunteer_id].append((position, shift, referent)) assignments_by_volunteer[volunteer_id].append((position, shift))
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
lead_volunteer_ids = { lead_volunteer_ids = {
account.volunteer_id account.volunteer_id
for account in session.exec( for account in session.exec(
@@ -340,13 +382,9 @@ def generate_badges(
if account.volunteer_id is not None if account.volunteer_id is not None
} }
badges = [ def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
BadgeSpec( return BadgeFieldSpec(
fields=tuple( text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
BadgeFieldSpec(
text=_field_text(
field, volunteer, assignments_by_volunteer, lead_volunteer_ids
),
x_mm=field.x_mm, x_mm=field.x_mm,
y_mm=field.y_mm, y_mm=field.y_mm,
width_mm=field.width_mm, width_mm=field.width_mm,
@@ -355,11 +393,31 @@ def generate_badges(
align=field.align.value, align=field.align.value,
bold=field.bold, bold=field.bold,
color=field.color, color=field.color,
is_photo=field.field_type == BadgeFieldType.PHOTO,
image=volunteer.photo if field.field_type == BadgeFieldType.PHOTO else None,
table=_positions_table(assignments_by_volunteer.get(volunteer.id, []), boundary)
if field.field_type == BadgeFieldType.POSITIONS
else None,
bg_color=field.bg_color,
auto_fit=field.auto_fit,
valign="MIDDLE" if field.field_type in _MIDDLE_ALIGNED else "TOP",
photo_shape=field.photo_shape.value,
) )
for field in fields
) front_fields = [f for f in fields if f.face == BadgeFace.FRONT]
) back_fields = [f for f in fields if f.face == BadgeFace.BACK]
# Un badge par exemplaire demandé sur la fiche (`Volunteer.badge_count`),
# à la suite les uns des autres.
badges = [
badge
for volunteer in volunteers for volunteer in volunteers
for badge in [
BadgeSpec(
fields=tuple(spec(field, volunteer) for field in front_fields),
back_fields=tuple(spec(field, volunteer) for field in back_fields),
)
]
* volunteer.badge_count
] ]
pdf_bytes = generate_badge_sheets_pdf( pdf_bytes = generate_badge_sheets_pdf(
+5 -1
View File
@@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session from sqlmodel import Session
from app.api._common import set_message
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import EditionRead, EditionUpdate from app.api.schemas import EditionRead, EditionUpdate
from app.auth.deps import current_account_in_edition, require_coordinator from app.auth.deps import current_account_in_edition, require_coordinator
@@ -27,7 +28,10 @@ def update_edition(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> Edition: ) -> Edition:
for field, value in payload.model_dump(exclude_unset=True).items(): changes = payload.model_dump(exclude_unset=True)
if "volunteer_message" in changes:
set_message(edition, "volunteer_message", changes.pop("volunteer_message"))
for field, value in changes.items():
setattr(edition, field, value) setattr(edition, field, value)
if edition.end_date < edition.start_date: if edition.end_date < edition.start_date:
raise HTTPException( raise HTTPException(
+6 -40
View File
@@ -25,7 +25,6 @@ coordination, faute de savoir si la personne visée est déjà inscrite (voir
CLAUDE.md § 2, `Volunteer`).""" CLAUDE.md § 2, `Volunteer`)."""
import datetime as dt import datetime as dt
import io
import json import json
import logging import logging
import secrets import secrets
@@ -43,13 +42,15 @@ from fastapi import (
UploadFile, UploadFile,
status, status,
) )
from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import ( from app.api._common import (
ALLOWED_PHOTO_TYPES,
MAX_PHOTO_BYTES,
owned_or_404, owned_or_404,
read_photo,
resolve_owned_ids, resolve_owned_ids,
set_artists, set_artists,
set_wished_positions, set_wished_positions,
@@ -109,8 +110,6 @@ logger = logging.getLogger("app.forms")
router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"]) router = APIRouter(prefix="/editions/{edition_id}/form", tags=["form"])
_MAX_PHOTO_BYTES = 8 * 1024 * 1024
_ALLOWED_PHOTO_TYPES = {"image/png", "image/jpeg"}
# Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les # Vœux de poste : au plus deux, pour éviter que tout le monde coche tous les
# postes « au cas où » — force une vraie priorisation dès le formulaire. # postes « au cas où » — force une vraie priorisation dès le formulaire.
_MAX_POSITION_WISHES = 2 _MAX_POSITION_WISHES = 2
@@ -340,14 +339,14 @@ def delete_payment_option(
async def _read_logo(file: UploadFile) -> tuple[bytes, str]: async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
content_type = file.content_type or "" content_type = file.content_type or ""
if content_type not in _ALLOWED_PHOTO_TYPES: if content_type not in ALLOWED_PHOTO_TYPES:
raise HTTPException( raise HTTPException(
status_code=422, detail={"code": "form.logo_type_not_supported"} status_code=422, detail={"code": "form.logo_type_not_supported"}
) )
content = await file.read() content = await file.read()
if not content: if not content:
raise HTTPException(status_code=400, detail={"code": "form.logo_empty"}) raise HTTPException(status_code=400, detail={"code": "form.logo_empty"})
if len(content) > _MAX_PHOTO_BYTES: if len(content) > MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"}) raise HTTPException(status_code=413, detail={"code": "form.logo_too_large"})
return content, content_type return content, content_type
@@ -462,39 +461,6 @@ def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
session.commit() session.commit()
async def _read_photo(photo: UploadFile | None) -> tuple[bytes, str] | None:
"""Validation partagée par l'inscription immédiate et l'inscription en
attente de paiement — `None` si aucun fichier fourni. `Content-Type` est
un en-tête fourni par l'appelant, jamais vérifié par lui-même (ce
endpoint est public, sans authentification) : un fichier qui ne se
décode pas comme une vraie image malgré un `Content-Type` correct
(`image/png`/`image/jpeg` revendiqué) est rejeté ici plutôt que de
planter plus tard la génération des badges/feuilles de poste
(`app/badges/pdf.py`/`app/sheets/pdf.py`, qui lisent ces octets sans
filet) — même validation que l'import d'image des badges
(`app/api/badges.py:_pixel_size`, réservé à la coordination)."""
if photo is None or not photo.filename:
return None
content_type = photo.content_type or ""
if content_type not in _ALLOWED_PHOTO_TYPES:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
)
content = await photo.read()
if len(content) > _MAX_PHOTO_BYTES:
raise HTTPException(status_code=413, detail={"code": "form.photo_too_large"})
if not content:
return None
try:
with Image.open(io.BytesIO(content)) as img:
img.verify()
except (UnidentifiedImageError, OSError) as exc:
raise HTTPException(
status_code=422, detail={"code": "form.photo_type_not_supported"}
) from exc
return content, content_type
def _filter_owned_ids( def _filter_owned_ids(
session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID] session: Session, model, edition_id: uuid.UUID, ids: list[uuid.UUID]
) -> list[uuid.UUID]: ) -> list[uuid.UUID]:
@@ -688,7 +654,7 @@ async def submit_public_form(
else payment_option.amount if payment_option is not None else None else payment_option.amount if payment_option is not None else None
) )
photo_data = await _read_photo(photo) if FormFieldType.PHOTO in fields else None photo_data = await read_photo(photo) if FormFieldType.PHOTO in fields else None
if payment_option is None: if payment_option is None:
# Pas de paiement en ligne (case désactivée, ou « je paierai sur # Pas de paiement en ligne (case désactivée, ou « je paierai sur
+267 -47
View File
@@ -1,23 +1,34 @@
"""Envoi d'emails d'information aux bénévoles (récapitulatif de planning).""" """Envoi d'emails d'information aux bénévoles (récapitulatif de planning)."""
import datetime as dt
import logging import logging
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass
from email.message import EmailMessage
from fastapi import APIRouter, BackgroundTasks, Depends from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import func
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import ( from app.api.schemas import (
PlanningNotificationRequest, PlanningNotificationRequest,
PlanningNotificationResult, PlanningNotificationResult,
TestEmailRequest,
TestEmailResult,
) )
from app.auth.deps import require_coordinator from app.auth.deps import require_coordinator
from app.auth.magic_link import build_magic_link_url, issue_magic_link_token from app.auth.magic_link import build_magic_link_url
from app.auth.permanent_link import permanent_login_token
from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.email import edition_logo, render_planning, send_many from app.email import edition_logo, render_planning
from app.email.html import render_planning_table
from app.email.sender import build_message, deliver_test, send_messages
from app.i18n import t from app.i18n import t
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer from app.models import Account, Assignment, DayPart, Edition, Position, Role, Shift, Volunteer
from app.validation.engine import day_boundary, festival_day
logger = logging.getLogger("app.notifications") logger = logging.getLogger("app.notifications")
@@ -29,16 +40,244 @@ _LOGIN_PLACEHOLDER = "{lien_connexion}"
def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str: def _login_link(session: Session, edition: Edition, volunteer: Volunteer) -> str:
"""Lien magique personnel si le bénévole a un compte, sinon l'accueil.""" """Lien de connexion **permanent** du bénévole (`app/auth/
permanent_link.py` : connexion directe en un clic, réutilisable, sans
expiration, révocable). Envoyer ce lien vaut « donner un accès de
connexion » : un bénévole qui n'a pas encore de compte en reçoit un
(rôle `VOLUNTEER`, à son email). Si son email est déjà celui d'un autre
compte de l'édition (couple, famille partageant une adresse), pas de
compte possible : simple lien vers la page de connexion."""
account = session.exec( account = session.exec(
select(Account) select(Account)
.where(Account.edition_id == edition.id) .where(Account.edition_id == edition.id)
.where(Account.volunteer_id == volunteer.id) .where(Account.volunteer_id == volunteer.id)
.where(Account.password_hash.is_(None))
).first() ).first()
if account is None: if account is None and volunteer.email:
email = volunteer.email.strip()
taken = session.exec(
select(Account.id)
.where(Account.edition_id == edition.id)
.where(func.lower(Account.email) == email.lower())
).first()
if taken is None:
account = Account(
edition_id=edition.id, email=email, role=Role.VOLUNTEER, volunteer_id=volunteer.id
)
session.add(account)
session.flush()
logger.info(
"Compte créé pour le lien de connexion : bénévole %s (compte %s)",
volunteer.id,
account.id,
)
if (
account is None
or account.password_hash is not None
or account.role not in (Role.VOLUNTEER, Role.LEAD)
):
return _login_page_url()
return build_magic_link_url(permanent_login_token(account))
def _login_page_url() -> str:
"""Page de connexion, sans jeton."""
return build_magic_link_url("").split("?")[0] return build_magic_link_url("").split("?")[0]
return build_magic_link_url(issue_magic_link_token(session, account))
# Repère du tableau des créneaux dans la version HTML (caractère d'usage
# privé : jamais saisi dans un modèle), remplacé par le vrai tableau.
_SHIFTS_MARKER = "\ue000creneaux\ue000"
@dataclass(frozen=True)
class _PlanningContext:
"""Ce qui sert à composer le récapitulatif de chaque bénévole, chargé
une fois par envoi."""
positions: dict[uuid.UUID, Position]
# Nom + téléphone du référent de chaque poste qui en a un.
referents: dict[uuid.UUID, str]
shifts_by_volunteer: dict[uuid.UUID, list[Shift]]
boundary: dt.time
def _load_context(session: Session, edition: Edition) -> _PlanningContext:
positions = {
p.id: p for p in session.exec(select(Position).where(Position.edition_id == edition.id))
}
referents: dict[uuid.UUID, str] = {}
for position_id, referent in session.exec(
select(Position.id, Volunteer)
.join(Account, Account.id == Position.lead_account_id)
.join(Volunteer, Volunteer.id == Account.volunteer_id)
.where(Position.edition_id == edition.id)
):
name = f"{referent.first_name} {referent.last_name}".strip()
referents[position_id] = f"{name} · {referent.phone}" if referent.phone else name
shifts_by_volunteer: dict[uuid.UUID, list[Shift]] = defaultdict(list)
for volunteer_id, shift in session.exec(
select(Assignment.volunteer_id, Shift)
.join(Shift, Shift.id == Assignment.shift_id)
.where(Assignment.edition_id == edition.id)
):
shifts_by_volunteer[volunteer_id].append(shift)
boundary = day_boundary(session.exec(select(DayPart).where(DayPart.edition_id == edition.id)))
return _PlanningContext(positions, referents, shifts_by_volunteer, boundary)
def _day_label(day: dt.date) -> str:
"""« Samedi 11 » — même en-tête de jour que le tableau des badges."""
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
def _shift_sections(
shifts: list[Shift], ctx: _PlanningContext
) -> list[tuple[str, list[tuple[str, str, str]]]]:
"""Créneaux regroupés par journée de festival (une nuit reste sur le
jour où elle a commencé), comme le tableau des badges :
`[(jour, [(horaire, poste, référent)])]`."""
by_day: dict[dt.date, list[tuple[str, str, str]]] = defaultdict(list)
for s in sorted(shifts, key=lambda s: s.starts_at):
by_day[festival_day(s.starts_at, ctx.boundary)].append(
(
f"{s.starts_at:%H:%M}–{s.ends_at:%H:%M}",
ctx.positions[s.position_id].name,
ctx.referents.get(s.position_id) or t("email.planning.table.no_referent"),
)
)
return [(_day_label(day), rows) for day, rows in sorted(by_day.items())]
def _shifts_text(sections: list[tuple[str, list[tuple[str, str, str]]]]) -> str:
"""Version texte du tableau (clients mail sans HTML) : le jour, puis
une ligne par créneau."""
if not sections:
return t("email.planning.no_shifts")
return "\n\n".join(
day
+ "\n"
+ "\n".join(
t("email.planning.table.text_line", time=time, position=position, referent=referent)
for time, position, referent in rows
)
for day, rows in sections
)
@dataclass(frozen=True)
class _PlanningEmail:
subject: str
body: str
# Corps de la version HTML : `{creneaux}` y devient un vrai tableau.
html_body: str
html_blocks: dict[str, str]
def _planning_email(
*,
ctx: _PlanningContext,
subject_template: str,
body_template: str,
first_name: str,
last_name: str,
shifts: list[Shift],
login_link: str,
) -> _PlanningEmail:
"""Récapitulatif de planning d'un bénévole — partagé par l'envoi réel
et l'email de test. `{creneaux}` : tableau par jour (horaire, poste,
référent avec son téléphone), en HTML et en texte."""
minutes = sum((s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts)
sections = _shift_sections(shifts, ctx)
def render(shifts_text: str) -> tuple[str, str]:
return render_planning(
subject_template=subject_template,
body_template=body_template,
first_name=first_name,
last_name=last_name,
shift_count=len(shifts),
hours=round(minutes / 60, 1),
shifts_text=shifts_text,
login_link=login_link,
)
subject, body = render(_shifts_text(sections))
if not sections:
return _PlanningEmail(subject, body, body, {})
_, html_body = render(_SHIFTS_MARKER)
table = render_planning_table(
sections,
(
t("email.planning.table.time"),
t("email.planning.table.position"),
t("email.planning.table.referent"),
),
)
return _PlanningEmail(subject, body, html_body, {_SHIFTS_MARKER: table})
@router.post("/test", response_model=TestEmailResult)
def send_test_email(
payload: TestEmailRequest,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> TestEmailResult:
"""Envoie tout de suite à l'adresse saisie le récapitulatif de planning
**tel que les bénévoles le recevront** : le modèle en cours d'édition
(`subject`/`body`), variables remplies avec les données d'un vrai
bénévole, même mise en forme et même logo — sujet préfixé « [Test] ».
Renvoie le résultat réel du serveur SMTP, pour vérifier à la fois le
contenu et la configuration d'envoi depuis l'écran « Messages ».
`{lien_connexion}` y est remplacé par l'adresse de la page de connexion,
**sans jeton** : un vrai lien magique ouvrirait le compte du bénévole à
quiconque lit la boîte de test."""
email = payload.email.strip()
if "@" not in email or email.startswith("@") or email.endswith("@") or " " in email:
raise HTTPException(status_code=422, detail={"code": "notifications.invalid_email"})
ctx = _load_context(session, edition)
shifts_by_volunteer = ctx.shifts_by_volunteer
volunteers = list(
session.exec(
select(Volunteer)
.where(Volunteer.edition_id == edition.id)
.order_by(Volunteer.last_name, Volunteer.first_name)
)
)
example = next((v for v in volunteers if v.id == payload.volunteer_id), None) or next(
(v for v in volunteers if shifts_by_volunteer.get(v.id)),
volunteers[0] if volunteers else None,
)
composed = _planning_email(
ctx=ctx,
subject_template=payload.subject or t("email.planning.default_subject"),
body_template=payload.body or t("email.planning.default_body"),
first_name=example.first_name if example else t("email.test.example_first_name"),
last_name=example.last_name if example else t("email.test.example_last_name"),
shifts=shifts_by_volunteer.get(example.id, []) if example else [],
login_link=_login_page_url(),
)
message = build_message(
email,
t("email.test.subject_prefix", subject=composed.subject),
composed.body,
logo=edition_logo(session, edition.id),
html_body=composed.html_body,
html_blocks=composed.html_blocks,
)
status, detail = deliver_test(message)
settings = get_settings()
return TestEmailResult(
status=status,
detail=detail,
smtp_host=f"{settings.smtp_host}:{settings.smtp_port}",
smtp_from=settings.smtp_from,
example_volunteer_id=example.id if example else None,
example_volunteer_name=(
f"{example.first_name} {example.last_name}".strip() if example else ""
),
)
@router.post("/planning", response_model=PlanningNotificationResult) @router.post("/planning", response_model=PlanningNotificationResult)
@@ -60,70 +299,51 @@ def send_planning_notification(
stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids)) stmt = stmt.where(Volunteer.id.in_(payload.volunteer_ids))
volunteers = list(session.exec(stmt.order_by(Volunteer.last_name))) volunteers = list(session.exec(stmt.order_by(Volunteer.last_name)))
positions = { ctx = _load_context(session, edition)
p.id: p logo = edition_logo(session, edition.id)
for p in session.exec(
select(Position).where(Position.edition_id == edition.id)
)
}
shifts_by_volunteer: dict[uuid.UUID, list[Shift]] = defaultdict(list)
for volunteer_id, shift in session.exec(
select(Assignment.volunteer_id, Shift)
.join(Shift, Shift.id == Assignment.shift_id)
.where(Assignment.edition_id == edition.id)
):
shifts_by_volunteer[volunteer_id].append(shift)
items: list[tuple[str, str, str]] = [] messages: list[EmailMessage] = []
recipients: list[uuid.UUID] = [] recipients: list[uuid.UUID] = []
skipped_no_email = 0 skipped_no_email = 0
for volunteer in volunteers: for volunteer in volunteers:
if not volunteer.email: if not volunteer.email:
skipped_no_email += 1 skipped_no_email += 1
continue continue
shifts = sorted( composed = _planning_email(
shifts_by_volunteer.get(volunteer.id, []), ctx=ctx,
key=lambda s: s.starts_at,
)
minutes = sum(
(s.ends_at - s.starts_at).total_seconds() / 60 for s in shifts
)
shifts_text = "\n".join(
t(
"email.planning.shift_line",
position=positions[s.position_id].name,
start=s.starts_at.strftime("%d/%m %H:%M"),
end=s.ends_at.strftime("%d/%m %H:%M"),
)
for s in shifts
) or t("email.planning.no_shifts")
subject, body = render_planning(
subject_template=subject_template, subject_template=subject_template,
body_template=body_template, body_template=body_template,
first_name=volunteer.first_name, first_name=volunteer.first_name,
last_name=volunteer.last_name, last_name=volunteer.last_name,
shift_count=len(shifts), shifts=ctx.shifts_by_volunteer.get(volunteer.id, []),
hours=round(minutes / 60, 1),
shifts_text=shifts_text,
login_link=( login_link=(
_login_link(session, edition, volunteer) _login_link(session, edition, volunteer)
if wants_login_link if wants_login_link
else "" else ""
), ),
) )
items.append((volunteer.email, subject, body)) messages.append(
build_message(
volunteer.email,
composed.subject,
composed.body,
logo=logo,
html_body=composed.html_body,
html_blocks=composed.html_blocks,
)
)
recipients.append(volunteer.id) recipients.append(volunteer.id)
session.commit() # persiste les éventuels MagicLinkToken émis session.commit() # persiste les comptes créés pour les liens de connexion
logger.info( logger.info(
"Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email", "Récapitulatif de planning (édition %s) : %d emails en file, %d bénévoles sans email",
edition.id, edition.id,
len(items), len(messages),
skipped_no_email, skipped_no_email,
) )
background.add_task(send_many, items, logo=edition_logo(session, edition.id)) background.add_task(send_messages, messages)
return PlanningNotificationResult( return PlanningNotificationResult(
queued=len(items), queued=len(messages),
skipped_no_email=skipped_no_email, skipped_no_email=skipped_no_email,
recipients=recipients, recipients=recipients,
) )
+10 -1
View File
@@ -17,10 +17,12 @@ from app.api.deps import get_edition
from app.api.schemas import PositionSheetsRequest from app.api.schemas import PositionSheetsRequest
from app.auth.deps import require_coordinator from app.auth.deps import require_coordinator
from app.db import get_session from app.db import get_session
from app.i18n import t
from app.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer from app.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer
from app.sheets.pdf import ( from app.sheets.pdf import (
PositionDaySheet, PositionDaySheet,
SheetArtist, SheetArtist,
SheetFooter,
SheetShift, SheetShift,
SheetVolunteer, SheetVolunteer,
generate_position_sheets_pdf, generate_position_sheets_pdf,
@@ -151,7 +153,14 @@ def generate_position_sheets(
status_code=422, detail={"code": "position_sheets.no_shifts"} status_code=422, detail={"code": "position_sheets.no_shifts"}
) )
pdf_bytes = generate_position_sheets_pdf(sheets) # Message de l'organisation (le même que sur l'écran bénévole) en bas de
# chaque page.
footer = (
SheetFooter(title=t("position_sheets.footer_title"), message=edition.volunteer_message)
if edition.volunteer_message
else None
)
pdf_bytes = generate_position_sheets_pdf(sheets, footer)
return Response( return Response(
content=pdf_bytes, content=pdf_bytes,
media_type="application/pdf", media_type="application/pdf",
+3 -1
View File
@@ -10,7 +10,7 @@ import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import owned_or_404 from app.api._common import owned_or_404, set_message
from app.api._shiftgen import generate_missing_shifts from app.api._shiftgen import generate_missing_shifts
from app.api.deps import get_edition from app.api.deps import get_edition
from app.api.schemas import ( from app.api.schemas import (
@@ -103,6 +103,8 @@ def update_position(
ensure_can_write_position(account, position) ensure_can_write_position(account, position)
changes = payload.model_dump(exclude_unset=True) changes = payload.model_dump(exclude_unset=True)
_check_lead_account(session, edition, changes.get("lead_account_id")) _check_lead_account(session, edition, changes.get("lead_account_id"))
if "message" in changes:
set_message(position, "message", changes.pop("message"))
for field, value in changes.items(): for field, value in changes.items():
setattr(position, field, value) setattr(position, field, value)
session.commit() session.commit()
+109 -4
View File
@@ -7,8 +7,11 @@ from pydantic import BaseModel, ConfigDict, Field
from app.models import ( from app.models import (
AssignmentSource, AssignmentSource,
AttendanceStatus,
BadgeAlign, BadgeAlign,
BadgeFace,
BadgeFieldType, BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus, FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
@@ -33,23 +36,34 @@ class EditionRead(ApiModel):
start_date: dt.date start_date: dt.date
end_date: dt.date end_date: dt.date
retention_days: int | None retention_days: int | None
volunteer_message: str | None
volunteer_message_updated_at: dt.datetime | None
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
# Plafond d'un message (organisation, poste) : quelques paragraphes, pas un
# document.
MESSAGE_MAX_LENGTH = 2000
class EditionUpdate(ApiModel): class EditionUpdate(ApiModel):
name: str | None = None name: str | None = None
year: int | None = None year: int | None = None
start_date: dt.date | None = None start_date: dt.date | None = None
end_date: dt.date | None = None end_date: dt.date | None = None
retention_days: int | None = None retention_days: int | None = None
# Vide ou blanc = plus de message.
volunteer_message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Volunteer # # Volunteer #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
class VolunteerCreate(ApiModel): class VolunteerCreate(ApiModel):
first_name: str # Facultatifs à l'ajout manuel par la coordination (fiche complétée plus
last_name: str # tard) ; le formulaire public et l'import les exigent toujours.
first_name: str = ""
last_name: str = ""
email: str | None = None email: str | None = None
phone: str | None = None phone: str | None = None
comment: str | None = None comment: str | None = None
@@ -74,6 +88,11 @@ class VolunteerUpdate(ApiModel):
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite # Issus du formulaire public (`app/api/forms.py`), éditables ensuite
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer). # depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
companion_wish_note: str | None = None companion_wish_note: str | None = None
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
badge_text: str | None = None
# Exemplaires du badge sur les planches (1 à 100 ; garde-fou contre une
# faute de frappe qui imprimerait des centaines de pages).
badge_count: int | None = Field(default=None, ge=1, le=100)
self_declared_available: bool | None = None self_declared_available: bool | None = None
willing_to_pay_fee: bool | None = None willing_to_pay_fee: bool | None = None
@@ -91,6 +110,8 @@ class VolunteerRead(ApiModel):
wished_companion_ids: list[uuid.UUID] wished_companion_ids: list[uuid.UUID]
has_photo: bool has_photo: bool
companion_wish_note: str | None companion_wish_note: str | None
badge_text: str | None
badge_count: int
self_declared_available: bool self_declared_available: bool
willing_to_pay_fee: bool willing_to_pay_fee: bool
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) — # Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
@@ -132,6 +153,9 @@ class VolunteerPlanningEntry(ApiModel):
# pas rattaché à une fiche bénévole (pas de téléphone à donner alors). # pas rattaché à une fiche bénévole (pas de téléphone à donner alors).
referent_name: str | None referent_name: str | None
referent_phone: str | None referent_phone: str | None
# Message du poste (voir `Position.message`).
position_message: str | None
position_message_updated_at: dt.datetime | None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -178,6 +202,8 @@ class PositionUpdate(ApiModel):
location: str | None = None location: str | None = None
category: PositionCategory | None = None category: PositionCategory | None = None
lead_account_id: uuid.UUID | None = None lead_account_id: uuid.UUID | None = None
# Vide ou blanc = plus de message.
message: str | None = Field(default=None, max_length=MESSAGE_MAX_LENGTH)
class PositionRead(ApiModel): class PositionRead(ApiModel):
@@ -187,6 +213,8 @@ class PositionRead(ApiModel):
location: str | None location: str | None
category: PositionCategory category: PositionCategory
lead_account_id: uuid.UUID | None lead_account_id: uuid.UUID | None
message: str | None
message_updated_at: dt.datetime | None
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
@@ -280,10 +308,17 @@ class AssignmentRead(ApiModel):
shift_id: uuid.UUID shift_id: uuid.UUID
source: AssignmentSource source: AssignmentSource
locked: bool locked: bool
attendance: AttendanceStatus | None
attendance_marked_at: dt.datetime | None
created_at: dt.datetime created_at: dt.datetime
updated_at: dt.datetime updated_at: dt.datetime
class AttendanceUpdate(ApiModel):
# `None` = annuler le pointage.
attendance: AttendanceStatus | None
class AssignmentWriteResult(ApiModel): class AssignmentWriteResult(ApiModel):
assignment: AssignmentRead assignment: AssignmentRead
warnings: list[Violation] = Field(default_factory=list) warnings: list[Violation] = Field(default_factory=list)
@@ -343,6 +378,7 @@ class MatrixResponse(ApiModel):
# Badges (planches PDF) # # Badges (planches PDF) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
class BadgeFieldIn(ApiModel): class BadgeFieldIn(ApiModel):
face: BadgeFace = BadgeFace.FRONT
field_type: BadgeFieldType field_type: BadgeFieldType
x_mm: float x_mm: float
y_mm: float y_mm: float
@@ -352,11 +388,21 @@ class BadgeFieldIn(ApiModel):
align: BadgeAlign = BadgeAlign.LEFT align: BadgeAlign = BadgeAlign.LEFT
bold: bool = False bold: bool = False
color: str = "#000000" color: str = "#000000"
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
text: str | None = None
# Fond de la zone (`#rrggbb`), `None` = transparent — voir `BadgeField`.
bg_color: str | None = None
auto_fit: bool = False
# `PHOTO` seulement, ignoré sinon.
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
# Ordre d'empilement sur la face (voir `BadgeField.z_order`).
z_order: int = 0
class BadgeFieldsSet(ApiModel): class BadgeFieldsSet(ApiModel):
"""Remplace l'intégralité des champs du recto — au plus un par """Remplace l'intégralité des champs du recto et du verso — au plus un
`field_type` (décocher un champ dans l'éditeur retire sa ligne).""" par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
ligne)."""
fields: list[BadgeFieldIn] = Field(default_factory=list) fields: list[BadgeFieldIn] = Field(default_factory=list)
@@ -470,6 +516,33 @@ class PlanningNotificationResult(ApiModel):
recipients: list[uuid.UUID] = Field(default_factory=list) recipients: list[uuid.UUID] = Field(default_factory=list)
class TestEmailRequest(ApiModel):
email: str
# Modèle du récapitulatif de planning en cours d'édition (mêmes
# variables que `PlanningNotificationRequest`) ; absent = modèle par
# défaut.
subject: str | None = None
body: str | None = None
# Bénévole dont les données remplissent les variables ; absent = le
# premier (par nom) qui a au moins un créneau.
volunteer_id: uuid.UUID | None = None
class TestEmailResult(ApiModel):
"""`status` : `sent` / `disabled` / `connection_failed` / `rejected` —
voir `app/email/sender.py:deliver_test`. `detail` : message du serveur
SMTP en cas d'échec."""
status: str
detail: str | None = None
smtp_host: str
smtp_from: str
# Bénévole dont les données ont rempli le modèle (`None` : exemple
# fictif, l'édition n'a encore aucun bénévole).
example_volunteer_id: uuid.UUID | None = None
example_volunteer_name: str
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Formulaire public d'inscription # # Formulaire public d'inscription #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -722,9 +795,41 @@ class StatsNeeds(ApiModel):
volunteers_with_assignment: int volunteers_with_assignment: int
class StatsAttendanceCounts(ApiModel):
"""Pointage (`Assignment.attendance`) d'un ensemble d'affectations :
`assigned` = `present` + `absent` + `unmarked`."""
assigned: int = 0
present: int = 0
absent: int = 0
unmarked: int = 0
class StatsAttendancePosition(ApiModel):
position_id: uuid.UUID
name: str
# Créneaux terminés (`ends_at <= as_of`) et créneaux en cours.
past: StatsAttendanceCounts
live: StatsAttendanceCounts
live_shifts: int
class StatsAttendance(ApiModel):
"""Présents / absents pointés par les référents, par poste : sur les
créneaux déjà terminés et sur ceux en cours à `as_of` (heure du calcul,
locale). Les créneaux à venir ne comptent pas."""
as_of: dt.datetime
past: StatsAttendanceCounts
live: StatsAttendanceCounts
live_shifts: int
by_position: list[StatsAttendancePosition]
class EditionStats(ApiModel): class EditionStats(ApiModel):
registrations: StatsRegistrations registrations: StatsRegistrations
answers: StatsAnswers answers: StatsAnswers
payments: StatsPayments payments: StatsPayments
artist_wishes: list[StatsArtistWish] artist_wishes: list[StatsArtistWish]
needs: StatsNeeds needs: StatsNeeds
attendance: StatsAttendance
+58
View File
@@ -16,6 +16,9 @@ from app.api.schemas import (
EditionStats, EditionStats,
StatsAnswers, StatsAnswers,
StatsArtistWish, StatsArtistWish,
StatsAttendance,
StatsAttendanceCounts,
StatsAttendancePosition,
StatsNeedDay, StatsNeedDay,
StatsNeedPosition, StatsNeedPosition,
StatsNeeds, StatsNeeds,
@@ -30,6 +33,7 @@ from app.models import (
Account, Account,
Artist, Artist,
Assignment, Assignment,
AttendanceStatus,
DayPart, DayPart,
Edition, Edition,
FeePaymentStatus, FeePaymentStatus,
@@ -108,6 +112,59 @@ def _payments(
) )
def _count(counts: StatsAttendanceCounts, assignment: Assignment) -> None:
counts.assigned += 1
if assignment.attendance == AttendanceStatus.PRESENT:
counts.present += 1
elif assignment.attendance == AttendanceStatus.ABSENT:
counts.absent += 1
else:
counts.unmarked += 1
def _attendance(
positions: list[Position], shifts: list[Shift], assignments: list[Assignment]
) -> StatsAttendance:
"""Pointage par poste, sur les créneaux terminés et ceux en cours."""
now = local_now()
past = StatsAttendanceCounts()
live = StatsAttendanceCounts()
by_position = {
p.id: StatsAttendancePosition(
position_id=p.id,
name=p.name,
past=StatsAttendanceCounts(),
live=StatsAttendanceCounts(),
live_shifts=0,
)
for p in positions
}
# Créneau terminé ⇒ « past », en cours ⇒ « live », à venir : ignoré.
phase: dict = {}
for s in shifts:
if s.ends_at <= now:
phase[s.id] = ("past", s.position_id)
elif s.starts_at <= now:
phase[s.id] = ("live", s.position_id)
by_position[s.position_id].live_shifts += 1
for a in assignments:
if a.shift_id not in phase:
continue
when, position_id = phase[a.shift_id]
row = by_position[position_id]
_count(past if when == "past" else live, a)
_count(row.past if when == "past" else row.live, a)
return StatsAttendance(
as_of=now,
past=past,
live=live,
live_shifts=sum(r.live_shifts for r in by_position.values()),
by_position=[
r for r in by_position.values() if r.past.assigned or r.live.assigned or r.live_shifts
],
)
@router.get("", response_model=EditionStats) @router.get("", response_model=EditionStats)
def get_stats( def get_stats(
edition: Edition = Depends(get_edition), edition: Edition = Depends(get_edition),
@@ -256,4 +313,5 @@ def get_stats(
payments=_payments(volunteers, pending_unfinished), payments=_payments(volunteers, pending_unfinished),
artist_wishes=artist_stats, artist_wishes=artist_stats,
needs=needs, needs=needs,
attendance=_attendance(positions, shifts, assignments),
) )
+42 -1
View File
@@ -5,12 +5,13 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from sqlalchemy.orm import aliased from sqlalchemy.orm import aliased
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import ( from app.api._common import (
owned_or_404, owned_or_404,
read_photo,
replace_links, replace_links,
resolve_owned_ids, resolve_owned_ids,
set_artists, set_artists,
@@ -316,6 +317,8 @@ def get_volunteer_planning(
position_location=p.location, position_location=p.location,
referent_name=f"{r.first_name} {r.last_name}" if r else None, referent_name=f"{r.first_name} {r.last_name}" if r else None,
referent_phone=r.phone if r else None, referent_phone=r.phone if r else None,
position_message=p.message,
position_message_updated_at=p.message_updated_at,
) )
for a, s, p, r in rows for a, s, p, r in rows
] ]
@@ -342,6 +345,44 @@ def get_volunteer_photo(
) )
@router.put("/{volunteer_id}/photo", response_model=VolunteerRead)
async def set_volunteer_photo(
volunteer_id: uuid.UUID,
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
"""Ajout (ou remplacement) de la photo par la coordination, depuis la
fiche — pour un bénévole qui n'en a pas envoyé au formulaire public ou
ajouté à la main. Même validation que le formulaire (`read_photo`)."""
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
photo = await read_photo(file)
if photo is None:
raise HTTPException(status_code=400, detail={"code": "volunteer.empty_photo"})
volunteer.photo, volunteer.photo_content_type = photo
session.add(volunteer)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.delete("/{volunteer_id}/photo", response_model=VolunteerRead)
def delete_volunteer_photo(
volunteer_id: uuid.UUID,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> VolunteerRead:
volunteer = owned_or_404(session.get(Volunteer, volunteer_id), edition.id)
volunteer.photo = None
volunteer.photo_content_type = None
session.add(volunteer)
session.commit()
session.refresh(volunteer)
return _read(session, volunteer)
@router.patch("/{volunteer_id}", response_model=VolunteerRead) @router.patch("/{volunteer_id}", response_model=VolunteerRead)
def update_volunteer( def update_volunteer(
volunteer_id: uuid.UUID, volunteer_id: uuid.UUID,
+64
View File
@@ -0,0 +1,64 @@
"""Lien de connexion permanent — celui du récapitulatif de planning
(`{lien_connexion}`, `app/api/notifications.py`).
Contrairement au lien magique de la page de connexion (`magic_link.py` :
aléatoire, haché en base, 30 min, usage unique), ce lien se réutilise sans
limite de durée : le bénévole retrouve son planning en un clic depuis
n'importe quel email reçu. Il n'est stocké nulle part : c'est une signature
HMAC (clé `JWT_SECRET`) de l'identifiant du compte et de sa
`login_link_version`, donc le même lien figure dans tous les emails d'un
bénévole. Révocation : incrémenter la version (`POST .../accounts/{id}/
reset-login-link`) invalide tous les liens déjà envoyés à ce compte ;
changer `JWT_SECRET` les invalide tous. Bénévoles et référents seulement,
jamais la coordination ni un compte à mot de passe."""
import base64
import hashlib
import hmac
import uuid
from sqlmodel import Session
from app.config import get_settings
from app.models import Account, Role
# Jamais la coordination, même sans mot de passe : son accès complet ne
# doit pas tenir à un lien qui circule par email.
_ALLOWED_ROLES = (Role.VOLUNTEER, Role.LEAD)
PREFIX = "pl."
def _signature(account_id: uuid.UUID, version: int) -> str:
digest = hmac.new(
get_settings().jwt_secret.encode("utf-8"),
f"permanent-login:{account_id}:{version}".encode(),
hashlib.sha256,
).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def permanent_login_token(account: Account) -> str:
"""Jeton en clair du lien permanent d'un compte (toujours le même tant
que sa version ne change pas)."""
return f"{PREFIX}{account.id.hex}.{_signature(account.id, account.login_link_version)}"
def account_from_permanent_token(session: Session, token: str) -> Account | None:
"""Compte désigné par un jeton permanent valide, `None` sinon (format,
compte inconnu, signature ou version périmée, compte à mot de passe ou
de la coordination)."""
if not token.startswith(PREFIX):
return None
raw_id, _, signature = token[len(PREFIX) :].partition(".")
try:
account_id = uuid.UUID(hex=raw_id)
except ValueError:
return None
account = session.get(Account, account_id)
if account is None or account.password_hash is not None or account.role not in _ALLOWED_ROLES:
return None
expected = _signature(account.id, account.login_link_version)
if not hmac.compare_digest(signature, expected):
return None
return account
+411 -25
View File
@@ -8,27 +8,43 @@ Format du badge : dérivé de l'image de fond du recto (pas de format fixe
imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée, imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée,
`DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la `DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la
taille standard des badges/cartes d'événement) servent de repère à taille standard des badges/cartes d'événement) servent de repère à
l'éditeur. Planche : grille sur page A4 portrait. Le verso est identique l'éditeur. Planche : grille sur page A4 portrait. Chaque page de recto est
pour tous les bénévoles : la planche imprime toutes les pages de recto suivie de sa page de verso (même image de fond à chaque emplacement rempli,
(personnalisées) puis, pour chacune, la page de verso correspondante (même plus les champs du verso de chaque bénévole) — impression recto-verso
disposition, même image répétée à chaque emplacement rempli) — impression classique, retournement sur le bord long : sur la page de verso, chaque
recto-verso classique, page de verso n directement après la page de recto n. badge est placé en miroir horizontal (colonne `cols - 1 - col`, même rangée)
pour tomber exactement derrière son recto — voir `_cell_origin`.
""" """
import io import io
from dataclasses import dataclass from dataclasses import dataclass
from PIL import Image, ImageOps, UnidentifiedImageError
from reportlab.lib.colors import HexColor from reportlab.lib.colors import HexColor
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
DEFAULT_BADGE_WIDTH_MM = 85.6 DEFAULT_BADGE_WIDTH_MM = 85.6
DEFAULT_BADGE_HEIGHT_MM = 54.0 DEFAULT_BADGE_HEIGHT_MM = 54.0
# Minimums pour compter combien de badges tiennent sur la page (marge
# d'impression, écart entre deux badges) ; l'espace effectivement laissé est
# ensuite réparti à parts égales — voir `_spacing`.
_MARGIN_MM = 10.0 _MARGIN_MM = 10.0
_GUTTER_MM = 4.0 _GUTTER_MM = 4.0
_LINE_HEIGHT_FACTOR = 1.25 _LINE_HEIGHT_FACTOR = 1.25
# Tableau (champ « postes ») : marges intérieures des cellules et épaisseur
# des filets.
_CELL_PAD_X_MM = 0.8
_CELL_PAD_Y_MM = 0.5
_TABLE_LINE_WIDTH_PT = 0.4
# Réduction automatique de la taille du texte (`auto_fit`) : pas et plancher.
_AUTO_FIT_STEP_PT = 0.5
_AUTO_FIT_MIN_PT = 4.0
# Photo absente ou illisible : initiales sur ce fond, à cette taille (part de
# la largeur du cadre).
# Résolution supposée d'une image de fond importée, pour convertir ses pixels # Résolution supposée d'une image de fond importée, pour convertir ses pixels
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution # en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
@@ -46,11 +62,40 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu
return (width_px / dpi * 25.4, height_px / dpi * 25.4) return (width_px / dpi * 25.4, height_px / dpi * 25.4)
@dataclass(frozen=True)
class BadgeTableSection:
"""Un bloc du tableau : une ligne d'en-tête sur toute la largeur (le
jour), puis ses lignes (horaire, poste)."""
title: str
rows: tuple[tuple[str, ...], ...]
@dataclass(frozen=True)
class BadgeTable:
"""Contenu d'un champ affiché en tableau plutôt qu'en texte libre : les
créneaux du bénévole regroupés par jour."""
sections: tuple[BadgeTableSection, ...]
@dataclass(frozen=True) @dataclass(frozen=True)
class BadgeFieldSpec: class BadgeFieldSpec:
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n` """Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
possibles — ex. un poste par ligne avec son horaire), positionné depuis possibles — ex. prénom puis nom), positionné depuis le coin haut-gauche
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end).""" du badge (comme dans l'éditeur visuel du front-end).
`is_photo` : le champ est la photo du bénévole (`image`, ses octets
bruts tels que stockés) plutôt qu'un texte, découpée selon
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), rien
n'est imprimé : le cadre reste vide.
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.
`bg_color` : fond plein de la zone du champ (`None` = transparent ;
ignoré pour la photo). `auto_fit` : réduit la taille du texte jusqu'à ce
que tout tienne dans le cadre (sinon, les lignes en trop ne sont pas
imprimées). `valign` : `TOP` ou `MIDDLE` (centré verticalement)."""
text: str text: str
x_mm: float x_mm: float
@@ -61,13 +106,22 @@ class BadgeFieldSpec:
align: str # "LEFT" | "CENTER" | "RIGHT" align: str # "LEFT" | "CENTER" | "RIGHT"
bold: bool bold: bool
color: str # "#rrggbb" color: str # "#rrggbb"
is_photo: bool = False
image: bytes | None = None
table: BadgeTable | None = None
bg_color: str | None = None
auto_fit: bool = False
valign: str = "TOP" # "TOP" | "MIDDLE"
photo_shape: str = "RECT" # "RECT" | "OVAL"
@dataclass(frozen=True) @dataclass(frozen=True)
class BadgeSpec: class BadgeSpec:
"""Un badge = un bénévole, avec ses champs déjà résolus.""" """Un badge = un bénévole, avec ses champs déjà résolus : `fields` pour
le recto, `back_fields` pour le verso."""
fields: tuple[BadgeFieldSpec, ...] fields: tuple[BadgeFieldSpec, ...]
back_fields: tuple[BadgeFieldSpec, ...] = ()
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]: def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
@@ -78,14 +132,45 @@ def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]
return cols, rows return cols, rows
def _spacing(count: int, page_mm: float, size_mm: float) -> tuple[float, float]:
"""`(marge extérieure, écart entre badges)` sur un axe, en mm : l'espace
libre est réparti **à parts égales** — même écart à gauche, entre chaque
badge et à droite (resp. en haut, entre les rangées et en bas) — pour une
grille centrée et symétrique. Si cet écart passait sous la marge
d'impression (`_MARGIN_MM`), la marge est gardée de chaque côté et le
reste réparti entre les badges : toujours symétrique. Avant, la grille
partait du coin haut-gauche et tout l'espace restant tombait à droite et
en bas ; une fois la feuille retournée, le verso se décalait d'autant par
rapport au recto (bug corrigé une fois)."""
free = page_mm - count * size_mm
even = free / (count + 1)
if even >= _MARGIN_MM or count == 1:
return (even, even) if count > 1 else (free / 2, 0.0)
return _MARGIN_MM, (free - 2 * _MARGIN_MM) / (count - 1)
def _cell_origin( def _cell_origin(
index: int, cols: int, badge_width_mm: float, badge_height_mm: float index: int,
cols: int,
badge_width_mm: float,
badge_height_mm: float,
mirrored: bool = False,
) -> tuple[float, float]: ) -> tuple[float, float]:
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page, """Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
en mm depuis le coin bas-gauche de la page.""" en mm depuis le coin bas-gauche de la page.
`mirrored` : page de verso. Une feuille retournée sur son bord long
inverse gauche et droite : le verso du badge de la colonne `col` doit
être imprimé en colonne `cols - 1 - col` (même rangée) pour tomber
derrière son recto — y compris sur une dernière planche incomplète."""
col, row = index % cols, index // cols col, row = index % cols, index // cols
x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM) if mirrored:
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM col = cols - 1 - col
rows = _grid_size(badge_width_mm, badge_height_mm)[1]
outer_x, gap_x = _spacing(cols, _PAGE_WIDTH_MM, badge_width_mm)
outer_y, gap_y = _spacing(rows, _PAGE_HEIGHT_MM, badge_height_mm)
x0 = outer_x + col * (badge_width_mm + gap_x)
y0 = _PAGE_HEIGHT_MM - outer_y - (row + 1) * badge_height_mm - row * gap_y
return x0, y0 return x0, y0
@@ -114,30 +199,310 @@ def _draw_background(
) )
def _draw_fields( def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
"""Recadre la photo au ratio du cadre (centrée, sans déformation — une
photo de téléphone n'a jamais le ratio du cadre) et la réduit à la
résolution d'impression : une photo de plusieurs Mo répétée sur 200
badges alourdirait inutilement le PDF. Applique l'orientation EXIF (une
photo prise en portrait au téléphone est souvent stockée couchée).
`None` si l'image est illisible : le badge s'imprime sans photo plutôt
que de faire échouer toute la planche."""
target = (
max(1, round(width_mm / 25.4 * ASSUMED_DPI)),
max(1, round(height_mm / 25.4 * ASSUMED_DPI)),
)
try:
with Image.open(io.BytesIO(image)) as img:
# JPEG : décodage directement à taille réduite (bien plus rapide
# qu'une photo de téléphone décodée en pleine résolution).
img.draft("RGB", (target[0] * 2, target[1] * 2))
fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target)
except (UnidentifiedImageError, OSError):
return None
out = io.BytesIO()
fitted.save(out, format="JPEG", quality=88)
return out.getvalue()
def _frame_rect(
f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> tuple[float, float, float, float]:
"""Cadre du champ en points, repère PDF : (gauche, bas, largeur,
hauteur). `f.y_mm` est le haut du cadre depuis le HAUT du badge ;
reportlab dessine depuis le BAS de la page."""
return (
(x0_mm + f.x_mm) * mm,
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
f.width_mm * mm,
f.height_mm * mm,
)
def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: float):
path = c.beginPath()
if shape == "OVAL":
path.ellipse(x, y, w, h)
else:
path.rect(x, y, w, h)
return path
# Photos déjà recadrées pendant une génération, par (photo, largeur,
# hauteur du cadre) : un bénévole a souvent la même photo au recto, au verso
# et sur plusieurs exemplaires (`badge_count`) — elle n'est traitée qu'une
# fois.
PhotoCache = dict[tuple[bytes, float, float], bytes | None]
def _draw_photo(
c: canvas.Canvas, c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...], f: BadgeFieldSpec,
x0_mm: float, x0_mm: float,
y0_mm: float, y0_mm: float,
badge_height_mm: float, badge_height_mm: float,
cache: PhotoCache,
) -> None: ) -> None:
for f in fields: x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size) photo = None
if f.image:
key = (f.image, f.width_mm, f.height_mm)
if key not in cache:
cache[key] = _fit_photo(f.image, f.width_mm, f.height_mm)
photo = cache[key]
if photo is None:
# Pas de photo (ou illisible) : cadre laissé vide, choix de la
# coordination — avant, les initiales étaient imprimées à la place.
return
c.saveState()
if f.photo_shape == "OVAL":
# Découpe en ellipse inscrite dans le cadre.
c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0)
c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h)
c.restoreState()
def _draw_zone_background(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
if not f.bg_color:
return
c.saveState()
c.setFillColor(HexColor(f.bg_color))
c.rect(*_frame_rect(f, x0_mm, y0_mm, badge_height_mm), stroke=0, fill=1)
c.restoreState()
def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
"""Découpe `text` en lignes qui tiennent dans `max_width_pt` : les `\\n`
du texte restent des retours à la ligne, puis chaque ligne trop longue
passe à la ligne entre deux mots — ou au milieu d'un mot qui, seul, ne
tient pas (nom très long dans un cadre étroit). Sans cela, un prénom +
nom ou une ligne de poste avec horaire débordait du cadre du champ."""
def fits(candidate: str) -> bool:
return stringWidth(candidate, font_name, font_size) <= max_width_pt
lines: list[str] = []
for paragraph in text.split("\n"):
current = ""
# `split(" ")` (et non `split()`) garde les doubles espaces voulus,
# ex. entre le nom du poste et son horaire.
for word in paragraph.split(" "):
candidate = f"{current} {word}" if current else word
if fits(candidate):
current = candidate
continue
if current:
lines.append(current.rstrip())
current = word
while current and not fits(current):
cut = len(current) - 1
while cut > 1 and not fits(current[:cut]):
cut -= 1
lines.append(current[:cut])
current = current[cut:]
lines.append(current.rstrip())
return lines
def _column_widths(natural: list[float], available: float) -> list[float]:
"""Largeurs des colonnes d'un tableau dans `available` : une colonne
courte (jour, horaire) garde sa largeur naturelle, les plus longues
(poste, référent) se partagent le reste à parts égales et passent à la
ligne. S'il reste de la place, la dernière colonne la prend."""
widths = [0.0] * len(natural)
pending = sorted(range(len(natural)), key=lambda i: natural[i])
remaining = available
while pending:
share = remaining / len(pending)
if natural[pending[0]] > share:
for i in pending:
widths[i] = share
return widths
i = pending.pop(0)
widths[i] = natural[i]
remaining -= natural[i]
widths[-1] += remaining
return widths
_TableLine = tuple[list[list[str]], str, bool, float]
def _table_layout(
table: BadgeTable, width_pt: float, font_size: float
) -> tuple[list[float], list[_TableLine]]:
"""Largeurs des colonnes et lignes du tableau à `font_size` : pour
chaque ligne, (texte replié de chaque cellule, police, en-tête de
section ?, hauteur en points)."""
regular, bold = "Helvetica", "Helvetica-Bold"
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
line_height = font_size * _LINE_HEIGHT_FACTOR
body = [row for section in table.sections for row in section.rows]
columns = max(len(row) for row in body) if body else 1
natural = [
max(stringWidth(row[col], regular, font_size) for row in body) + 2 * pad_x
for col in range(columns)
] if body else [0.0]
widths = _column_widths(natural, width_pt)
total_width = sum(widths)
# Un en-tête n'a qu'une cellule, sur toute la largeur.
cells_to_draw = []
for section in table.sections:
cells_to_draw.append(((section.title,), bold, True))
cells_to_draw.extend((row, regular, False) for row in section.rows)
lines: list[_TableLine] = []
for cells, font, is_title in cells_to_draw:
cell_widths = [total_width] if is_title else widths
wrapped = [
wrap_text(text, font, font_size, max(1.0, cell_widths[col] - 2 * pad_x))
for col, text in enumerate(cells)
]
row_height = max(len(w) for w in wrapped) * line_height + 2 * pad_y
lines.append((wrapped, font, is_title, row_height))
return widths, lines
def _draw_table(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
"""Tableau dans le cadre du champ : pour chaque section, une ligne
d'en-tête en gras sur toute la largeur (le jour), puis ses lignes ;
filets à la couleur du champ, texte de chaque cellule replié dans sa
colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
texte — sauf avec `auto_fit`, qui réduit la taille du texte jusqu'à ce
que toutes les lignes tiennent (plancher `_AUTO_FIT_MIN_PT`)."""
table = f.table
if table is None or not table.sections:
return
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
width_pt, height_pt = f.width_mm * mm, f.height_mm * mm
font_size = f.font_size
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
if f.auto_fit:
while (
sum(line[3] for line in lines_to_draw) > height_pt + 0.01
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
):
font_size -= _AUTO_FIT_STEP_PT
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
line_height = font_size * _LINE_HEIGHT_FACTOR
total_width = sum(widths)
left = (x0_mm + f.x_mm) * mm
top = (y0_mm + badge_height_mm - f.y_mm) * mm
bottom_limit = top - height_pt
color = HexColor(f.color)
c.setFillColor(color)
c.setStrokeColor(color)
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
y = top
for wrapped, font, is_title, row_height in lines_to_draw:
cell_widths = [total_width] if is_title else widths
if y - row_height < bottom_limit - 0.01:
break # la ligne déborderait du cadre du champ
c.setFont(font, font_size)
x = left
for col, lines in enumerate(wrapped):
for i, line in enumerate(lines):
baseline = y - pad_y - font_size * 0.85 - i * line_height
c.drawString(x + pad_x, baseline, line)
x += cell_widths[col]
if not is_title:
# Séparations de colonnes, sur la hauteur de cette ligne seulement.
x = left
for width in widths[:-1]:
x += width
c.line(x, y, x, y - row_height)
y -= row_height
c.line(left, y, left + total_width, y)
if y == top:
return # pas même le premier en-tête ne tient
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
def _lines_that_fit(font_size: float, height_mm: float) -> int:
"""Nombre de lignes de texte qui tiennent dans `height_mm` (première
ligne de base à 0,85 × la taille sous le haut du cadre, puis un
interligne par ligne)."""
font_size_mm = font_size / mm
first = font_size_mm * 0.85
if first > height_mm + 1e-9:
return 0
return 1 + int((height_mm - first) / (font_size_mm * _LINE_HEIGHT_FACTOR) + 1e-9)
def fit_text(
text: str, font_name: str, font_size: float, width_mm: float, height_mm: float,
auto_fit: bool,
) -> tuple[float, list[str]]:
"""Taille retenue et lignes à imprimer d'un champ texte. Sans
`auto_fit` : la taille du champ, lignes en trop retirées. Avec : taille
réduite par pas de `_AUTO_FIT_STEP_PT` (plancher `_AUTO_FIT_MIN_PT`)
jusqu'à ce que toutes les lignes repliées tiennent dans le cadre."""
lines = wrap_text(text, font_name, font_size, width_mm * mm)
if auto_fit:
while (
len(lines) > _lines_that_fit(font_size, height_mm)
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
):
font_size -= _AUTO_FIT_STEP_PT
lines = wrap_text(text, font_name, font_size, width_mm * mm)
return font_size, lines[: _lines_that_fit(font_size, height_mm)]
def _draw_text(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
font_size, lines = fit_text(
f.text, font_name, f.font_size, f.width_mm, f.height_mm, f.auto_fit
)
if not lines:
return
c.setFont(font_name, font_size)
c.setFillColor(HexColor(f.color)) c.setFillColor(HexColor(f.color))
left_x = x0_mm + f.x_mm left_x = x0_mm + f.x_mm
# `f.font_size` est en points (unité reportlab) ; le reste du calcul # `font_size` est en points (unité reportlab) ; le reste du calcul
# travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant # travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant
# ici le facteur de conversion pt/mm de reportlab). # ici le facteur de conversion pt/mm de reportlab).
font_size_mm = f.font_size / mm font_size_mm = font_size / mm
line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR
# `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab # `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
# dessine depuis le BAS de la page. # dessine depuis le BAS de la page.
top_y = y0_mm + badge_height_mm - f.y_mm top_y = y0_mm + badge_height_mm - f.y_mm
bottom_limit = top_y - f.height_mm if f.valign == "MIDDLE":
for i, line in enumerate(f.text.split("\n")): # Hauteur du bloc : interlignes entre les lignes + une hauteur de
# caractère pour la dernière.
block = (len(lines) - 1) * line_height_mm + font_size_mm
top_y -= max(0.0, (f.height_mm - block) / 2)
for i, line in enumerate(lines):
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
if baseline_y < bottom_limit:
break # la ligne déborderait de la boîte du champ
if f.align == "CENTER": if f.align == "CENTER":
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line) c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
elif f.align == "RIGHT": elif f.align == "RIGHT":
@@ -146,6 +511,25 @@ def _draw_fields(
c.drawString(left_x * mm, baseline_y * mm, line) c.drawString(left_x * mm, baseline_y * mm, line)
def _draw_fields(
c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...],
x0_mm: float,
y0_mm: float,
badge_height_mm: float,
photos: PhotoCache,
) -> None:
for f in fields:
if f.is_photo:
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm, photos)
continue
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
if f.table is not None:
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
else:
_draw_text(c, f, x0_mm, y0_mm, badge_height_mm)
def generate_badge_sheets_pdf( def generate_badge_sheets_pdf(
badges: list[BadgeSpec], badges: list[BadgeSpec],
front_image: bytes | None, front_image: bytes | None,
@@ -161,16 +545,18 @@ def generate_badge_sheets_pdf(
buffer = io.BytesIO() buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4) c = canvas.Canvas(buffer, pagesize=A4)
photos: PhotoCache = {}
for page_badges in pages: for page_badges in pages:
for i, badge in enumerate(page_badges): for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm) x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
_draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm) _draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.fields, x0, y0, badge_height_mm) _draw_fields(c, badge.fields, x0, y0, badge_height_mm, photos)
c.showPage() c.showPage()
for i in range(len(page_badges)): for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm) x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True)
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm) _draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm, photos)
c.showPage() c.showPage()
c.save() c.save()
return buffer.getvalue() return buffer.getvalue()
+63 -10
View File
@@ -9,6 +9,8 @@ que dans le texte déjà journalisable). Pas de CSS externe ni de police web
en ligne, polices système en repli du style « Archivo »/« DM Mono ».""" en ligne, polices système en repli du style « Archivo »/« DM Mono »."""
import html as _html import html as _html
import re
from collections.abc import Mapping, Sequence
_INK = "#14110E" _INK = "#14110E"
_CREAM = "#F5EFE1" _CREAM = "#F5EFE1"
@@ -26,21 +28,72 @@ _FONT_MONO = "'Courier New', Courier, monospace"
LOGO_CID = "edition-logo" LOGO_CID = "edition-logo"
def _paragraphs(body: str) -> str: _P_OPEN = '<p style="margin:0 0 14px;">'
def _paragraphs(body: str, blocks: Mapping[str, str]) -> str:
"""`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la """`\n\n` sépare les paragraphes, un simple `\n` reste un retour à la
ligne — même convention que les modèles texte (`app/email/templates.py`).""" ligne — même convention que les modèles texte (`app/email/templates.py`).
blocks = body.strip().split("\n\n") `blocks` : repère → HTML déjà construit (ex. tableau des créneaux),
return "".join( inséré tel quel à la place du repère, hors de tout paragraphe."""
f'<p style="margin:0 0 14px;">{_html.escape(block).replace(chr(10), "<br>")}</p>' out = []
for block in blocks for block in body.strip().split("\n\n"):
if block.strip() if not block.strip():
continue
html = _P_OPEN + _html.escape(block).replace(chr(10), "<br>") + "</p>"
for marker, fragment in blocks.items():
html = html.replace(_html.escape(marker), f"</p>{fragment}{_P_OPEN}")
# Paragraphes vidés par l'insertion d'un bloc (repère seul, ou
# entouré de retours à la ligne).
html = re.sub(rf"{re.escape(_P_OPEN)}(?:<br>|\s)*</p>", "", html)
out.append(html)
return "".join(out)
_CELL = f"padding:6px 8px;border:1px solid {_INK};vertical-align:top;"
def render_planning_table(
sections: Sequence[tuple[str, Sequence[tuple[str, str, str]]]],
headers: tuple[str, str, str],
) -> str:
"""Tableau des créneaux d'un bénévole, comme sur les badges : le jour
en en-tête sur toute la largeur, puis une ligne par créneau (horaire,
poste, référent). `sections` = `[(jour, [(horaire, poste, référent)])]`."""
head = "".join(
f'<th align="left" style="{_CELL}background:{_INK};color:{_CREAM};'
f'font-family:{_FONT_DISPLAY};font-size:11px;text-transform:uppercase;">'
f"{_html.escape(h)}</th>"
for h in headers
)
rows = [f"<tr>{head}</tr>"]
for day, lines in sections:
rows.append(
f'<tr><td colspan="3" style="{_CELL}background:{_YELLOW};'
f'font-family:{_FONT_DISPLAY};font-weight:800;">{_html.escape(day)}</td></tr>'
)
for time, position, referent in lines:
rows.append(
f'<tr><td style="{_CELL}white-space:nowrap;font-weight:700;">'
f"{_html.escape(time)}</td>"
f'<td style="{_CELL}font-family:{_FONT_DISPLAY};font-weight:700;">'
f"{_html.escape(position)}</td>"
f'<td style="{_CELL}">{_html.escape(referent).replace(chr(10), "<br>")}</td></tr>'
)
return (
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
f'style="border-collapse:collapse;margin:0 0 14px;font-size:12px;'
f'font-family:{_FONT_MONO};background:{_PAPER};">' + "".join(rows) + "</table>"
) )
def render_html(*, subject: str, body: str, has_logo: bool) -> str: def render_html(
*, subject: str, body: str, has_logo: bool, blocks: Mapping[str, str] | None = None
) -> str:
"""`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant """`has_logo` : voir `app/email/branding.py:edition_logo` — `False` tant
que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un que l'édition n'a pas importé de logo, l'en-tête retombe alors sur un
simple bandeau texte (le sujet, en capitales).""" simple bandeau texte (le sujet, en capitales). `blocks` : voir
`_paragraphs`."""
if has_logo: if has_logo:
header = ( header = (
f'<img src="cid:{LOGO_CID}" alt="" width="160" ' f'<img src="cid:{LOGO_CID}" alt="" width="160" '
@@ -72,7 +125,7 @@ def render_html(*, subject: str, body: str, has_logo: bool) -> str:
<td style="background:{_PAPER};border:3px solid {_INK}; <td style="background:{_PAPER};border:3px solid {_INK};
border-top:none;padding:22px;font-size:13px; border-top:none;padding:22px;font-size:13px;
line-height:1.6;color:{_INK};font-family:{_FONT_MONO};"> line-height:1.6;color:{_INK};font-family:{_FONT_MONO};">
{_paragraphs(body)} {_paragraphs(body, blocks or {})}
</td> </td>
</tr> </tr>
</table> </table>
+54 -3
View File
@@ -8,7 +8,7 @@ Pour diagnostiquer : `python -m app.email.check <destinataire>`."""
import logging import logging
import smtplib import smtplib
import ssl import ssl
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Mapping, Sequence
from email.message import EmailMessage from email.message import EmailMessage
from app.config import Settings, get_settings from app.config import Settings, get_settings
@@ -44,8 +44,15 @@ def build_message(
*, *,
logo: tuple[bytes, str] | None = None, logo: tuple[bytes, str] | None = None,
attachments: Sequence[Attachment] = (), attachments: Sequence[Attachment] = (),
html_body: str | None = None,
html_blocks: Mapping[str, str] | None = None,
) -> EmailMessage: ) -> EmailMessage:
"""`logo` = (octets, content-type) (voir `app/email/branding.py: """`html_body` / `html_blocks` : corps de la version HTML quand il
diffère du texte — repères remplacés par des blocs HTML déjà construits
(ex. tableau des créneaux, `app/email/html.py:render_planning_table`) ;
le texte brut garde sa propre version lisible.
`logo` = (octets, content-type) (voir `app/email/branding.py:
edition_logo`) : ajoute une version HTML au thème de l'appli edition_logo`) : ajoute une version HTML au thème de l'appli
(`app/email/html.py`) en alternative au texte brut — le texte reste la (`app/email/html.py`) en alternative au texte brut — le texte reste la
version de référence, envoyée telle quelle si aucun client ne préfère le version de référence, envoyée telle quelle si aucun client ne préfère le
@@ -61,7 +68,12 @@ def build_message(
message["Subject"] = subject message["Subject"] = subject
message.set_content(body) message.set_content(body)
message.add_alternative( message.add_alternative(
render_html(subject=subject, body=body, has_logo=logo is not None), render_html(
subject=subject,
body=html_body if html_body is not None else body,
has_logo=logo is not None,
blocks=html_blocks,
),
subtype="html", subtype="html",
) )
if logo is not None: if logo is not None:
@@ -145,6 +157,39 @@ def deliver_many(messages: list[EmailMessage]) -> None:
logger.info("Emails remis : %d/%d", sent, len(messages)) logger.info("Emails remis : %d/%d", sent, len(messages))
def deliver_test(message: EmailMessage) -> tuple[str, str | None]:
"""Envoi d'un email de test, **synchrone** et sans « best effort » :
renvoie `(statut, détail)` au lieu de seulement journaliser, pour que la
coordination voie tout de suite ce qui ne va pas (`POST
.../notifications/test`). Statuts : `sent`, `disabled`
(`EMAIL_ENABLED=false`), `connection_failed` (connexion ou
authentification), `rejected` (le serveur a refusé le message). Détail :
message du serveur ou type d'erreur — jamais l'adresse du destinataire
(`_smtp_error_summary`)."""
settings = get_settings()
if not settings.email_enabled:
logger.info("Email de test non envoyé (EMAIL_ENABLED=false)")
return "disabled", None
try:
smtp = open_smtp(settings)
except smtplib.SMTPException as exc:
logger.error("Email de test : connexion SMTP refusée : %s", _smtp_error_summary(exc))
return "connection_failed", _smtp_error_summary(exc)
except OSError as exc:
logger.error("Email de test : SMTP injoignable : %s", exc)
return "connection_failed", f"{exc.__class__.__name__} : {exc}"
with smtp:
try:
smtp.send_message(message)
except smtplib.SMTPException as exc:
logger.error(
"Email de test refusé (From=%s) : %s", message["From"], _smtp_error_summary(exc)
)
return "rejected", _smtp_error_summary(exc)
logger.info("Email de test remis")
return "sent", None
def send( def send(
to: str, to: str,
subject: str, subject: str,
@@ -156,6 +201,12 @@ def send(
deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)]) deliver_many([build_message(to, subject, body, logo=logo, attachments=attachments)])
def send_messages(messages: list[EmailMessage]) -> None:
"""Remet des messages déjà construits (`build_message`) — ex. le
récapitulatif de planning, dont la version HTML porte un tableau."""
deliver_many(messages)
def send_many( def send_many(
items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None items: Iterable[tuple[str, str, str]], *, logo: tuple[bytes, str] | None = None
) -> None: ) -> None:
+4
View File
@@ -36,6 +36,8 @@ _HEADER = [
"Mécénat — code postal", "Mécénat — code postal",
"Mécénat — pays", "Mécénat — pays",
"Commentaire", "Commentaire",
"Texte du badge",
"Nombre de badges",
"Inscrit le", "Inscrit le",
] ]
@@ -123,6 +125,8 @@ def generate_volunteers_xlsx(
_safe_text(v.mecenat_postal_code or ""), _safe_text(v.mecenat_postal_code or ""),
_safe_text(v.mecenat_country or ""), _safe_text(v.mecenat_country or ""),
_safe_text(v.comment or ""), _safe_text(v.comment or ""),
_safe_text(v.badge_text or ""),
v.badge_count,
_fmt_dt(v.created_at), _fmt_dt(v.created_at),
] ]
) )
+21 -2
View File
@@ -53,18 +53,36 @@
"email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.", "email.registration_confirmation_paid.mecenat_receipt_note": "Tu trouveras en pièce jointe ton reçu fiscal au titre des dons (cerfa n° 11580*05), à conserver pour ta déclaration de revenus.",
"email.planning.default_subject": "Ton planning bénévole", "email.planning.default_subject": "Ton planning bénévole",
"email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !", "email.planning.default_body": "Bonjour {prenom},\n\nTon planning a été mis à jour : {nb_creneaux} créneau(x), soit {nb_heures} h au total.\n\n{creneaux}\n\nÀ très vite sur le festival !",
"email.test.subject_prefix": "[Test] {subject}",
"email.test.example_first_name": "Camille",
"email.test.example_last_name": "Exemple",
"notifications.invalid_email": "Adresse email invalide.",
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.", "email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
"email.planning.shift_line": "- {position} : {start} → {end}", "email.planning.table.time": "Horaire",
"email.planning.table.position": "Poste",
"email.planning.table.referent": "Référent",
"email.planning.table.no_referent": "à désigner",
"email.planning.table.text_line": " {time} · {position} · référent : {referent}",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.", "badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.image_unreadable": "L'image est illisible ou corrompue.", "badge.image_unreadable": "L'image est illisible ou corrompue.",
"badge.empty_image": "L'image est vide.", "badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.", "badge.image_too_large": "L'image est trop volumineuse.",
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.", "badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
"badge.invalid_size": "La taille du badge doit être strictement positive.", "badge.invalid_size": "La taille du badge doit être strictement positive.",
"badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.", "badge.invalid_color": "La couleur « {color} » n'est pas valide (format attendu : #rrggbb).",
"badge.no_fields": "Aucun champ n'est configuré sur le badge.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.", "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
"badge.role.referent": "Référent", "badge.role.referent": "Référent",
"badge.role.volunteer": "Bénévole", "badge.role.volunteer": "Bénévole",
"position_sheets.footer_title": "Message de l'organisation",
"badge.table.day_label": "{weekday} {day}",
"badge.weekday.0": "Lundi",
"badge.weekday.1": "Mardi",
"badge.weekday.2": "Mercredi",
"badge.weekday.3": "Jeudi",
"badge.weekday.4": "Vendredi",
"badge.weekday.5": "Samedi",
"badge.weekday.6": "Dimanche",
"position.category.bar": "Bar", "position.category.bar": "Bar",
"position.category.ticketing": "Billetterie", "position.category.ticketing": "Billetterie",
"position.category.welcome": "Accueil", "position.category.welcome": "Accueil",
@@ -78,6 +96,7 @@
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.", "form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
"form.invalid_payload": "Données du formulaire invalides.", "form.invalid_payload": "Données du formulaire invalides.",
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.", "form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
"volunteer.empty_photo": "Le fichier de la photo est vide.",
"form.photo_too_large": "La photo est trop volumineuse.", "form.photo_too_large": "La photo est trop volumineuse.",
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.", "form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
"form.logo_empty": "Le logo est vide.", "form.logo_empty": "Le logo est vide.",
@@ -0,0 +1,30 @@
"""Texte libre du badge
Revision ID: 0024_volunteer_badge_text
Revises: 0023_fee_transaction
Create Date: 2026-10-27
`volunteer.badge_text` : texte propre à chaque bénévole, saisi par la
coordination sur sa fiche et imprimé sur le badge par le champ
`FREE_TEXT`. Les nouveaux types de champ de badge (`PHOTO`, `FREE_TEXT`)
tiennent dans la colonne `badge_field.field_type` existante (VARCHAR sans
contrainte CHECK, longueur 9 inchangée) : rien à migrer de ce côté.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0024_volunteer_badge_text"
down_revision: str | None = "0023_fee_transaction"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("volunteer", sa.Column("badge_text", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("volunteer", "badge_text")
@@ -0,0 +1,28 @@
"""Texte commun du champ libre des badges
Revision ID: 0025_badge_field_text
Revises: 0024_volunteer_badge_text
Create Date: 2026-10-27
`badge_field.text` : texte du champ `FREE_TEXT` saisi dans l'éditeur de
badges, imprimé sur tous les badges — sauf ceux dont la fiche porte un
`volunteer.badge_text`, qui le remplace pour ce bénévole.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0025_badge_field_text"
down_revision: str | None = "0024_volunteer_badge_text"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("badge_field", sa.Column("text", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("badge_field", "text")
@@ -0,0 +1,62 @@
"""Éditeur de badges v2 (verso, fond de zone, auto-ajustement, photo ovale)
Revision ID: 0026_badge_editor_v2
Revises: 0025_badge_field_text
Create Date: 2026-10-27
`badge_field.face` : face du badge (`FRONT`/`BACK`) — les champs existants
deviennent des champs du recto. La contrainte d'unicité passe de
(`edition_id`, `field_type`) à (`edition_id`, `face`, `field_type`) : un même
champ peut figurer sur les deux faces. `bg_color` (fond de la zone,
transparent si `NULL`), `auto_fit` (réduction automatique du texte) et
`photo_shape` (`RECT`/`OVAL`, champ `PHOTO`).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0026_badge_editor_v2"
down_revision: str | None = "0025_badge_field_text"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_FACE = sa.Enum("FRONT", "BACK", name="badgeface", native_enum=False)
_PHOTO_SHAPE = sa.Enum("RECT", "OVAL", name="badgephotoshape", native_enum=False)
def upgrade() -> None:
op.add_column(
"badge_field",
sa.Column("face", _FACE, server_default="FRONT", nullable=False),
)
op.add_column("badge_field", sa.Column("bg_color", sa.String(), nullable=True))
op.add_column(
"badge_field",
sa.Column("auto_fit", sa.Boolean(), server_default="false", nullable=False),
)
op.add_column(
"badge_field",
sa.Column("photo_shape", _PHOTO_SHAPE, server_default="RECT", nullable=False),
)
op.drop_constraint("uq_badge_field_edition_type", "badge_field", type_="unique")
op.create_unique_constraint(
"uq_badge_field_edition_face_type",
"badge_field",
["edition_id", "face", "field_type"],
)
def downgrade() -> None:
# Le verso n'existait pas : ses champs sont perdus (sinon l'ancienne
# contrainte d'unicité échouerait sur un champ présent sur les deux faces).
op.execute("DELETE FROM badge_field WHERE face = 'BACK'")
op.drop_constraint("uq_badge_field_edition_face_type", "badge_field", type_="unique")
op.create_unique_constraint(
"uq_badge_field_edition_type", "badge_field", ["edition_id", "field_type"]
)
op.drop_column("badge_field", "photo_shape")
op.drop_column("badge_field", "auto_fit")
op.drop_column("badge_field", "bg_color")
op.drop_column("badge_field", "face")
@@ -0,0 +1,35 @@
"""Nombre de badges par bénévole
Revision ID: 0027_volunteer_badge_count
Revises: 0026_badge_editor_v2
Create Date: 2026-10-27
`volunteer.badge_count` : nombre d'exemplaires de son badge imprimés sur les
planches (`POST .../badges/generate`), 1 par défaut — les bénévoles existants
gardent un seul badge.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0027_volunteer_badge_count"
down_revision: str | None = "0026_badge_editor_v2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"volunteer",
sa.Column("badge_count", sa.Integer(), nullable=False, server_default="1"),
)
op.create_check_constraint(
"ck_volunteer_badge_count_positive", "volunteer", "badge_count >= 1"
)
def downgrade() -> None:
op.drop_constraint("ck_volunteer_badge_count_positive", "volunteer", type_="check")
op.drop_column("volunteer", "badge_count")
@@ -0,0 +1,45 @@
"""Ordre d'empilement des champs du badge
Revision ID: 0028_badge_field_z_order
Revises: 0027_volunteer_badge_count
Create Date: 2026-10-27
`badge_field.z_order` : les champs d'une face sont dessinés du plus petit au
plus grand. Avant, ils l'étaient dans l'ordre alphabétique de leur type, et
la photo recouvrait le nom et le texte libre. Les champs existants sont
numérotés photo d'abord (dessous), puis les autres par type.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0028_badge_field_z_order"
down_revision: str | None = "0027_volunteer_badge_count"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"badge_field",
sa.Column("z_order", sa.Integer(), nullable=False, server_default="0"),
)
op.execute(
"""
UPDATE badge_field AS b SET z_order = ranked.rank
FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY edition_id, face
ORDER BY (field_type = 'PHOTO') DESC, field_type
) - 1 AS rank
FROM badge_field
) AS ranked
WHERE b.id = ranked.id
"""
)
def downgrade() -> None:
op.drop_column("badge_field", "z_order")
@@ -0,0 +1,37 @@
"""Messages aux bénévoles
Revision ID: 0029_volunteer_messages
Revises: 0028_badge_field_z_order
Create Date: 2026-10-28
`edition.volunteer_message` : message de l'organisation, le même pour tous
les bénévoles, saisi par la coordination. `position.message` : message du
poste (consignes…), saisi par son référent, montré aux bénévoles affectés.
Chacun avec la date de sa dernière modification (`*_updated_at`).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0029_volunteer_messages"
down_revision: str | None = "0028_badge_field_z_order"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("edition", sa.Column("volunteer_message", sa.String(), nullable=True))
op.add_column(
"edition", sa.Column("volunteer_message_updated_at", sa.DateTime(), nullable=True)
)
op.add_column("position", sa.Column("message", sa.String(), nullable=True))
op.add_column("position", sa.Column("message_updated_at", sa.DateTime(), nullable=True))
def downgrade() -> None:
op.drop_column("position", "message_updated_at")
op.drop_column("position", "message")
op.drop_column("edition", "volunteer_message_updated_at")
op.drop_column("edition", "volunteer_message")
@@ -0,0 +1,34 @@
"""Pointage des bénévoles
Revision ID: 0030_assignment_attendance
Revises: 0029_volunteer_messages
Create Date: 2026-10-28
`assignment.attendance` (`PRESENT`/`ABSENT`, `NULL` = pas pointé) et
`attendance_marked_at` : le pointage du référent était gardé dans le
navigateur seulement et se perdait au rechargement.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0030_assignment_attendance"
down_revision: str | None = "0029_volunteer_messages"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_ATTENDANCE = sa.Enum("PRESENT", "ABSENT", name="attendancestatus", native_enum=False)
def upgrade() -> None:
op.add_column("assignment", sa.Column("attendance", _ATTENDANCE, nullable=True))
op.add_column(
"assignment", sa.Column("attendance_marked_at", sa.DateTime(), nullable=True)
)
def downgrade() -> None:
op.drop_column("assignment", "attendance_marked_at")
op.drop_column("assignment", "attendance")
@@ -0,0 +1,31 @@
"""Lien de connexion permanent
Revision ID: 0031_account_login_link_version
Revises: 0030_assignment_attendance
Create Date: 2026-10-28
`account.login_link_version` : version du lien de connexion permanent du
récapitulatif de planning (`app/auth/permanent_link.py`) — l'incrémenter
révoque tous les liens déjà envoyés à ce compte.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0031_account_login_link_version"
down_revision: str | None = "0030_assignment_attendance"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"account",
sa.Column("login_link_version", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
op.drop_column("account", "login_link_version")
+6
View File
@@ -27,8 +27,11 @@ from app.models.domain import (
) )
from app.models.enums import ( from app.models.enums import (
AssignmentSource, AssignmentSource,
AttendanceStatus,
BadgeAlign, BadgeAlign,
BadgeFace,
BadgeFieldType, BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus, FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
@@ -40,9 +43,12 @@ __all__ = [
"Artist", "Artist",
"Assignment", "Assignment",
"AssignmentSource", "AssignmentSource",
"AttendanceStatus",
"BadgeAlign", "BadgeAlign",
"BadgeFace",
"BadgeField", "BadgeField",
"BadgeFieldType", "BadgeFieldType",
"BadgePhotoShape",
"BadgeTemplate", "BadgeTemplate",
"DayPart", "DayPart",
"Edition", "Edition",
+80 -5
View File
@@ -17,8 +17,11 @@ from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
from app.models.base import TimestampMixin from app.models.base import TimestampMixin
from app.models.enums import ( from app.models.enums import (
AssignmentSource, AssignmentSource,
AttendanceStatus,
BadgeAlign, BadgeAlign,
BadgeFace,
BadgeFieldType, BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus, FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
@@ -90,6 +93,11 @@ class Edition(TimestampMixin, table=True):
end_date: dt.date end_date: dt.date
# Durée de rétention des données nominatives, en jours. None = pas de purge. # Durée de rétention des données nominatives, en jours. None = pas de purge.
retention_days: int | None = None retention_days: int | None = None
# Message de l'organisation, le même pour tous les bénévoles (écran
# bénévole) — saisi par la coordination. `_updated_at` : date affichée
# sous le message, posée par l'API à chaque changement de texte.
volunteer_message: str | None = None
volunteer_message_updated_at: dt.datetime | None = None
day_parts: list["DayPart"] = Relationship( day_parts: list["DayPart"] = Relationship(
back_populates="edition", sa_relationship_kwargs=_PASSIVE back_populates="edition", sa_relationship_kwargs=_PASSIVE
@@ -160,6 +168,10 @@ class Artist(TimestampMixin, table=True):
class Volunteer(TimestampMixin, table=True): class Volunteer(TimestampMixin, table=True):
__table_args__ = (
CheckConstraint("badge_count >= 1", name="ck_volunteer_badge_count_positive"),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field( edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE" foreign_key="edition.id", index=True, ondelete="CASCADE"
@@ -183,6 +195,15 @@ class Volunteer(TimestampMixin, table=True):
# cette note sur la fiche et la transforme, si besoin, en un vrai # cette note sur la fiche et la transforme, si besoin, en un vrai
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits. # `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
companion_wish_note: str | None = None companion_wish_note: str | None = None
# Texte du badge propre à ce bénévole (champ `BadgeFieldType.FREE_TEXT`),
# saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
badge_text: str | None = None
# Nombre d'exemplaires de son badge à imprimer sur les planches (badge
# perdu, plusieurs accréditations…) — 1 par défaut.
badge_count: int = Field(
default=1, sa_column=Column(Integer, nullable=False, server_default="1")
)
# Cases à cocher du formulaire public — purement informatives : aucune # Cases à cocher du formulaire public — purement informatives : aucune
# logique n'y est attachée (pas de workflow de paiement). # logique n'y est attachée (pas de workflow de paiement).
self_declared_available: bool = Field( self_declared_available: bool = Field(
@@ -273,6 +294,12 @@ class Account(TimestampMixin, table=True):
nullable=True, nullable=True,
) )
# Version du lien de connexion permanent (`app/auth/permanent_link.py`) :
# l'incrémenter révoque tous les liens déjà envoyés à ce compte.
login_link_version: int = Field(
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
)
@property @property
def auth_method(self) -> str: def auth_method(self) -> str:
return "password" if self.password_hash else "magic_link" return "password" if self.password_hash else "magic_link"
@@ -311,6 +338,10 @@ class Position(TimestampMixin, table=True):
ondelete="SET NULL", ondelete="SET NULL",
nullable=True, nullable=True,
) )
# Message du poste (consignes…), saisi par son référent ou la
# coordination, montré aux bénévoles affectés au poste.
message: str | None = None
message_updated_at: dt.datetime | None = None
shifts: list["Shift"] = Relationship( shifts: list["Shift"] = Relationship(
back_populates="position", sa_relationship_kwargs=_PASSIVE back_populates="position", sa_relationship_kwargs=_PASSIVE
@@ -390,6 +421,14 @@ class Assignment(TimestampMixin, table=True):
default=False, default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"), sa_column=Column(Boolean, nullable=False, server_default="false"),
) )
# Pointage par le référent du poste (ou la coordination) : `None` tant
# que personne n'a pointé. `attendance_marked_at` : heure du dernier
# pointage, affichée sur l'écran référent.
attendance: AttendanceStatus | None = Field(
default=None,
sa_column=Column(SAEnum(AttendanceStatus, native_enum=False), nullable=True),
)
attendance_marked_at: dt.datetime | None = None
volunteer: Volunteer = Relationship(back_populates="assignments") volunteer: Volunteer = Relationship(back_populates="assignments")
shift: Shift = Relationship(back_populates="assignments") shift: Shift = Relationship(back_populates="assignments")
@@ -425,16 +464,17 @@ class BadgeTemplate(TimestampMixin, table=True):
class BadgeField(TimestampMixin, table=True): class BadgeField(TimestampMixin, table=True):
"""Un champ positionné sur le recto du badge (nom, poste(s), catégorie, """Un champ positionné sur une face du badge (`face` : recto ou verso —
rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en
haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`). millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`).
Au plus un champ par `field_type` et par édition : décocher un champ dans Au plus un champ par `field_type` et par face pour une édition (le même
champ peut figurer au recto et au verso) : décocher un champ dans
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`).""" l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
__tablename__ = "badge_field" __tablename__ = "badge_field"
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
"edition_id", "field_type", name="uq_badge_field_edition_type" "edition_id", "face", "field_type", name="uq_badge_field_edition_face_type"
), ),
) )
@@ -442,6 +482,14 @@ class BadgeField(TimestampMixin, table=True):
edition_id: uuid.UUID = Field( edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE" foreign_key="edition.id", index=True, ondelete="CASCADE"
) )
face: BadgeFace = Field(
default=BadgeFace.FRONT,
sa_column=Column(
SAEnum(BadgeFace, native_enum=False),
nullable=False,
server_default=BadgeFace.FRONT.value,
),
)
field_type: BadgeFieldType = Field( field_type: BadgeFieldType = Field(
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False) sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
) )
@@ -463,6 +511,33 @@ class BadgeField(TimestampMixin, table=True):
sa_column=Column(Boolean, nullable=False, server_default="false"), sa_column=Column(Boolean, nullable=False, server_default="false"),
) )
color: str = Field(default="#000000") color: str = Field(default="#000000")
# Champ `FREE_TEXT` seulement : texte saisi dans l'éditeur, imprimé sur
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
text: str | None = None
# Fond de la zone du champ (`#rrggbb`) ; `None` = transparent. Sans
# effet sur le champ `PHOTO`.
bg_color: str | None = None
# Réduit la taille du texte (par pas de 0,5 pt) jusqu'à ce que tout
# tienne dans le cadre, au lieu de tronquer les lignes en trop.
auto_fit: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
# Champ `PHOTO` seulement : rectangle ou ellipse inscrite dans le cadre.
photo_shape: BadgePhotoShape = Field(
default=BadgePhotoShape.RECT,
sa_column=Column(
SAEnum(BadgePhotoShape, native_enum=False),
nullable=False,
server_default=BadgePhotoShape.RECT.value,
),
)
# Ordre d'empilement sur sa face : les champs sont dessinés du plus petit
# au plus grand, un champ recouvre donc ceux d'un `z_order` inférieur.
z_order: int = Field(
default=0, sa_column=Column(Integer, nullable=False, server_default="0")
)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+30 -1
View File
@@ -18,6 +18,13 @@ class AssignmentSource(enum.StrEnum):
MANUAL = "MANUAL" MANUAL = "MANUAL"
class AttendanceStatus(enum.StrEnum):
"""Pointage d'un bénévole sur son créneau, par le référent du poste."""
PRESENT = "PRESENT"
ABSENT = "ABSENT"
class PositionCategory(enum.StrEnum): class PositionCategory(enum.StrEnum):
BAR = "BAR" BAR = "BAR"
TICKETING = "TICKETING" TICKETING = "TICKETING"
@@ -31,12 +38,34 @@ class PositionCategory(enum.StrEnum):
class BadgeFieldType(enum.StrEnum): class BadgeFieldType(enum.StrEnum):
"""Champ affichable sur le recto d'un badge (voir `app/badges/`).""" """Champ affichable sur le recto ou le verso d'un badge (voir `app/badges/`)."""
FULL_NAME = "FULL_NAME" FULL_NAME = "FULL_NAME"
POSITIONS = "POSITIONS" POSITIONS = "POSITIONS"
CATEGORY = "CATEGORY" CATEGORY = "CATEGORY"
ROLE = "ROLE" ROLE = "ROLE"
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
PHOTO = "PHOTO"
# Texte libre saisi par la coordination dans l'éditeur (`BadgeField.text`,
# commun à tous les badges), remplacé pour un bénévole par le « Texte du
# badge » de sa fiche (`Volunteer.badge_text`) s'il en a un.
FREE_TEXT = "FREE_TEXT"
class BadgeFace(enum.StrEnum):
"""Face du badge sur laquelle un champ est imprimé : le verso est
personnalisable comme le recto (même champ possible sur les deux)."""
FRONT = "FRONT"
BACK = "BACK"
class BadgePhotoShape(enum.StrEnum):
"""Forme de découpe du champ `PHOTO` (cadre rectangulaire ou ellipse
inscrite dans le cadre)."""
RECT = "RECT"
OVAL = "OVAL"
class BadgeAlign(enum.StrEnum): class BadgeAlign(enum.StrEnum):
+156 -31
View File
@@ -12,12 +12,14 @@ bénévole, une feuille identifie un poste pour la journée.
import datetime as dt import datetime as dt
import io import io
from dataclasses import dataclass from dataclasses import dataclass
from xml.sax.saxutils import escape
from PIL import Image as PILImage
from PIL import ImageOps, UnidentifiedImageError
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.platypus import ( from reportlab.platypus import (
Image, Image,
PageBreak, PageBreak,
@@ -28,7 +30,12 @@ from reportlab.platypus import (
TableStyle, TableStyle,
) )
_PHOTO_BOX_MM = 14 * mm # Mise en page compacte (une feuille = une page autant que possible) :
# vignette de 10 mm, deux bénévoles par ligne.
_PHOTO_BOX_MM = 10 * mm
# Côté de la vignette en pixels : 10 mm à 300 dpi.
_PHOTO_BOX_PX = round(10 / 25.4 * 300)
_PER_ROW = 2
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"] _WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
_MONTHS_FR = [ _MONTHS_FR = [
@@ -90,69 +97,187 @@ def _styles() -> dict[str, ParagraphStyle]:
base = getSampleStyleSheet() base = getSampleStyleSheet()
return { return {
"title": ParagraphStyle( "title": ParagraphStyle(
"SheetTitle", parent=base["Title"], alignment=0, fontSize=22, spaceAfter=2 "SheetTitle", parent=base["Title"], alignment=0,
fontSize=18, leading=21, spaceBefore=0, spaceAfter=1,
), ),
"subtitle": ParagraphStyle( "subtitle": ParagraphStyle(
"SheetSubtitle", parent=base["Normal"], "SheetSubtitle", parent=base["Normal"],
fontSize=12, textColor=colors.grey, spaceAfter=4, fontSize=10.5, leading=13, textColor=colors.grey, spaceAfter=2,
), ),
"referent": ParagraphStyle( "referent": ParagraphStyle(
"SheetReferent", parent=base["Normal"], "SheetReferent", parent=base["Normal"],
fontSize=11.5, fontName="Helvetica-Bold", spaceAfter=6, fontSize=10.5, leading=13, fontName="Helvetica-Bold", spaceAfter=3,
), ),
"artists": ParagraphStyle( "artists": ParagraphStyle(
"SheetArtists", parent=base["Normal"], "SheetArtists", parent=base["Normal"],
fontSize=9.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=16, fontSize=8.5, leading=10.5, textColor=colors.HexColor("#4a4a4a"), spaceAfter=4,
), ),
"shiftHeader": ParagraphStyle( "shiftHeader": ParagraphStyle(
"ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4 "ShiftHeader", parent=base["Heading3"],
fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
), ),
"shiftHeaderWarning": ParagraphStyle( "shiftHeaderWarning": ParagraphStyle(
"ShiftHeaderWarning", parent=base["Heading3"], "ShiftHeaderWarning", parent=base["Heading3"],
fontSize=13, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#b23a1f"), fontSize=11, leading=13, spaceBefore=7, spaceAfter=3,
textColor=colors.HexColor("#b23a1f"),
),
"volunteerName": ParagraphStyle(
"SheetVolunteerName", parent=base["Normal"],
fontName="Helvetica-Bold", fontSize=9.5, leading=11.5,
),
"volunteerPhone": ParagraphStyle(
"SheetVolunteerPhone", parent=base["Normal"], fontSize=9, leading=11,
),
"footerTitle": ParagraphStyle(
"SheetFooterTitle", parent=base["Normal"],
fontName="Helvetica-Bold", fontSize=8.5, leading=11,
),
"footer": ParagraphStyle(
"SheetFooter", parent=base["Normal"], fontSize=8.5, leading=11,
), ),
"normal": base["Normal"], "normal": base["Normal"],
} }
def _photo_thumbnail(photo: bytes) -> Image: @dataclass(frozen=True)
"""Vignette carrée (14 mm de côté max), proportions préservées.""" class SheetFooter:
width_px, height_px = ImageReader(io.BytesIO(photo)).getSize() """Message imprimé en bas de chaque page (message de l'organisation,
`Edition.volunteer_message`) : `title` en gras, puis `message` —
retours à la ligne conservés."""
title: str
message: str
_MARGIN_X = 15 * mm
_MARGIN_TOP = 12 * mm
_MARGIN_BOTTOM = 12 * mm
# Écart entre le bas du contenu et le filet du pied de page.
_FOOTER_GAP = 5 * mm
def _footer_paragraphs(footer: SheetFooter, styles: dict) -> list[Paragraph]:
message = escape(footer.message).replace("\n", "<br/>")
return [
Paragraph(escape(footer.title), styles["footerTitle"]),
Paragraph(message, styles["footer"]),
]
def _thumbnail(photo: bytes) -> tuple[bytes, int, int] | None:
"""Photo réduite à la taille de la vignette (orientation EXIF appliquée),
en JPEG : `(octets, largeur, hauteur)`, `None` si illisible. La photo
d'origine (souvent plusieurs Mo, prise au téléphone) intégrée telle
quelle pour chaque bénévole de chaque créneau faisait tourner la
génération plusieurs minutes et produisait un PDF énorme — jusqu'à
l'erreur (bug corrigé une fois)."""
try:
with PILImage.open(io.BytesIO(photo)) as img:
# JPEG : décodage directement à taille réduite (bien plus rapide).
img.draft("RGB", (_PHOTO_BOX_PX, _PHOTO_BOX_PX))
thumb = ImageOps.exif_transpose(img).convert("RGB")
thumb.thumbnail((_PHOTO_BOX_PX, _PHOTO_BOX_PX))
except (UnidentifiedImageError, OSError, ValueError):
return None
out = io.BytesIO()
thumb.save(out, format="JPEG", quality=85)
return out.getvalue(), thumb.width, thumb.height
def _photo_cell(photo: bytes | None, cache: dict[bytes, tuple[bytes, int, int] | None]):
"""Vignette carrée (14 mm de côté max), proportions préservées. Chaque
photo n'est réduite qu'une fois par PDF (`cache`), même si le bénévole
figure sur plusieurs créneaux."""
if not photo:
return ""
if photo not in cache:
cache[photo] = _thumbnail(photo)
thumb = cache[photo]
if thumb is None:
return ""
data, width_px, height_px = thumb
scale = _PHOTO_BOX_MM / max(width_px, height_px) scale = _PHOTO_BOX_MM / max(width_px, height_px)
return Image(io.BytesIO(photo), width=width_px * scale, height=height_px * scale) return Image(io.BytesIO(data), width=width_px * scale, height=height_px * scale)
def _shift_table(shift: SheetShift) -> Table: def _shift_table(
rows = [["", "Bénévole", "Téléphone"]] + [ shift: SheetShift,
[_photo_thumbnail(v.photo) if v.photo else "", v.name, v.phone or "—"] cache: dict[bytes, tuple[bytes, int, int] | None],
styles: dict[str, ParagraphStyle],
width: float,
) -> Table:
"""Bénévoles du créneau, deux par ligne : vignette, puis nom (gras) et
téléphone l'un sous l'autre. Deux fois moins de lignes qu'une liste à
une personne par ligne (avant l'étape 65, souvent deux pages par
feuille)."""
photo_w = _PHOTO_BOX_MM + 3 * mm
text_w = width / _PER_ROW - photo_w
cells = [
[
_photo_cell(v.photo, cache),
[
Paragraph(escape(v.name), styles["volunteerName"]),
Paragraph(escape(v.phone or "—"), styles["volunteerPhone"]),
],
]
for v in shift.volunteers for v in shift.volunteers
] ]
table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm]) rows = []
table.setStyle( for i in range(0, len(cells), _PER_ROW):
TableStyle( chunk = cells[i : i + _PER_ROW]
[ chunk += [["", ""]] * (_PER_ROW - len(chunk))
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), rows.append([cell for pair in chunk for cell in pair])
("FONTSIZE", (0, 0), (-1, -1), 10.5), table = Table(rows, colWidths=[photo_w, text_w] * _PER_ROW)
("TOPPADDING", (0, 0), (-1, -1), 5), style = [
("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("TOPPADDING", (0, 0), (-1, -1), 2),
("BOTTOMPADDING", (0, 0), (-1, -1), 2),
("LEFTPADDING", (0, 0), (-1, -1), 3),
("RIGHTPADDING", (0, 0), (-1, -1), 3),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"), ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black), ("LINEBELOW", (0, 0), (-1, -1), 0.25, colors.lightgrey),
("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey), ("LINEABOVE", (0, 0), (-1, 0), 0.25, colors.lightgrey),
] ]
) # Filet vertical entre les deux bénévoles d'une même ligne.
) for col in range(2, 2 * _PER_ROW, 2):
style.append(("LINEBEFORE", (col, 0), (col, -1), 0.25, colors.lightgrey))
table.setStyle(TableStyle(style))
return table return table
def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes: def generate_position_sheets_pdf(
sheets: list[PositionDaySheet], footer: SheetFooter | None = None
) -> bytes:
"""`footer` : imprimé en bas de **chaque** page (y compris la suite
d'une feuille sur une seconde page) ; la marge du bas s'agrandit de sa
hauteur pour que le contenu ne passe jamais dessous."""
styles = _styles() styles = _styles()
buffer = io.BytesIO() buffer = io.BytesIO()
width = A4[0] - 2 * _MARGIN_X
paragraphs = _footer_paragraphs(footer, styles) if footer else []
footer_height = sum(p.wrap(width, A4[1])[1] for p in paragraphs)
doc = SimpleDocTemplate( doc = SimpleDocTemplate(
buffer, pagesize=A4, buffer, pagesize=A4,
leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm, leftMargin=_MARGIN_X, rightMargin=_MARGIN_X, topMargin=_MARGIN_TOP,
bottomMargin=_MARGIN_BOTTOM + (footer_height + _FOOTER_GAP if paragraphs else 0),
) )
def draw_footer(canvas, _doc) -> None:
if not paragraphs:
return
canvas.saveState()
top = _MARGIN_BOTTOM + footer_height
canvas.setStrokeColor(colors.black)
canvas.setLineWidth(0.75)
canvas.line(_MARGIN_X, top + 2 * mm, _MARGIN_X + width, top + 2 * mm)
y = top
for p in paragraphs:
_, h = p.wrap(width, A4[1])
y -= h
p.drawOn(canvas, _MARGIN_X, y)
canvas.restoreState()
story = [] story = []
thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {}
for i, sheet in enumerate(sheets): for i, sheet in enumerate(sheets):
if i > 0: if i > 0:
story.append(PageBreak()) story.append(PageBreak())
@@ -190,10 +315,10 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"] style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
story.append(Paragraph(header, style)) story.append(Paragraph(header, style))
if shift.volunteers: if shift.volunteers:
story.append(_shift_table(shift)) story.append(_shift_table(shift, thumbnails, styles, width))
else: else:
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"])) story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
story.append(Spacer(1, 0)) story.append(Spacer(1, 0))
doc.build(story) doc.build(story, onFirstPage=draw_footer, onLaterPages=draw_footer)
return buffer.getvalue() return buffer.getvalue()
+72
View File
@@ -0,0 +1,72 @@
"""Pointage présent / absent d'un bénévole sur son créneau
(`PUT .../assignments/{id}/attendance`) : enregistré en base, par le
référent du poste ou la coordination."""
from app.models import Assignment, Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
def _assignment(session, edition, lead=None):
position = make_position(session, edition)
if lead is not None:
position.lead_account_id = lead.id
session.add(position)
session.commit()
shift = make_shift(session, edition, position, start=dt_at(10, 18), end=dt_at(10, 22))
volunteer = make_volunteer(session, edition)
assignment = Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
session.add(assignment)
session.commit()
session.refresh(assignment)
return assignment
def _url(edition, assignment):
return f"/editions/{edition.id}/assignments/{assignment.id}/attendance"
def test_lead_marks_attendance_and_it_is_kept(edition, session, make_account, as_account):
lead = make_account(Role.LEAD)
assignment = _assignment(session, edition, lead)
client = as_account(lead)
resp = client.put(_url(edition, assignment), json={"attendance": "PRESENT"})
assert resp.status_code == 200
assert resp.json()["attendance"] == "PRESENT"
assert resp.json()["attendance_marked_at"] is not None
# Relu ensuite (rechargement de l'écran) : le pointage est toujours là.
(listed,) = client.get(f"/editions/{edition.id}/assignments").json()
assert listed["attendance"] == "PRESENT"
def test_attendance_can_be_switched_and_cleared(client, edition, session):
assignment = _assignment(session, edition)
assert client.put(_url(edition, assignment), json={"attendance": "ABSENT"}).json()[
"attendance"
] == "ABSENT"
cleared = client.put(_url(edition, assignment), json={"attendance": None}).json()
assert cleared["attendance"] is None
assert cleared["attendance_marked_at"] is None
def test_lead_of_another_position_cannot_mark(edition, session, make_account, as_account):
assignment = _assignment(session, edition, make_account(Role.LEAD))
resp = as_account(make_account(Role.LEAD)).put(
_url(edition, assignment), json={"attendance": "PRESENT"}
)
assert resp.status_code == 403
def test_volunteer_cannot_mark(edition, session, make_account, as_account):
assignment = _assignment(session, edition)
resp = as_account(make_account(Role.VOLUNTEER)).put(
_url(edition, assignment), json={"attendance": "PRESENT"}
)
assert resp.status_code == 403
def test_unknown_status_is_rejected(client, edition, session):
assignment = _assignment(session, edition)
resp = client.put(_url(edition, assignment), json={"attendance": "LATE"})
assert resp.status_code == 422
+88
View File
@@ -0,0 +1,88 @@
"""Messages aux bénévoles : celui de l'organisation (`Edition.
volunteer_message`, coordination) et celui de chaque poste (`Position.
message`, référent du poste ou coordination)."""
from app.models import Assignment, Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
def test_coordination_sets_the_organisation_message(
client, edition, session, make_account, as_account
):
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": " Repas à 18h "})
assert resp.status_code == 200
assert resp.json()["volunteer_message"] == "Repas à 18h"
assert resp.json()["volunteer_message_updated_at"] is not None
volunteer = make_volunteer(session, edition)
reader = as_account(make_account(Role.VOLUNTEER, volunteer_id=volunteer.id))
resp = reader.get(f"/editions/{edition.id}")
assert resp.json()["volunteer_message"] == "Repas à 18h"
def test_blank_message_clears_it(client, edition):
client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Bonjour"})
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": " "})
assert resp.json()["volunteer_message"] is None
assert resp.json()["volunteer_message_updated_at"] is None
def test_unchanged_message_keeps_its_date(client, edition):
first = client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Bonjour"}).json()
again = client.patch(
f"/editions/{edition.id}", json={"volunteer_message": "Bonjour", "name": "X"}
).json()
assert again["volunteer_message_updated_at"] == first["volunteer_message_updated_at"]
def test_volunteer_and_lead_cannot_set_the_organisation_message(edition, make_account, as_account):
for role in (Role.VOLUNTEER, Role.LEAD):
resp = as_account(make_account(role)).patch(
f"/editions/{edition.id}", json={"volunteer_message": "Hack"}
)
assert resp.status_code == 403
def test_message_too_long_is_rejected(client, edition):
resp = client.patch(f"/editions/{edition.id}", json={"volunteer_message": "x" * 2001})
assert resp.status_code == 422
def test_lead_sets_the_message_of_their_position(edition, session, make_account, as_account):
lead = make_account(Role.LEAD)
position = make_position(session, edition)
position.lead_account_id = lead.id
session.add(position)
session.commit()
resp = as_account(lead).patch(
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Gilet fourni"}
)
assert resp.status_code == 200
assert resp.json()["message"] == "Gilet fourni"
assert resp.json()["message_updated_at"] is not None
def test_lead_cannot_set_the_message_of_another_position(
edition, session, make_account, as_account
):
position = make_position(session, edition)
resp = as_account(make_account(Role.LEAD)).patch(
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Hack"}
)
assert resp.status_code == 403
def test_position_message_reaches_the_volunteer_planning(client, edition, session):
volunteer = make_volunteer(session, edition)
position = make_position(session, edition)
shift = make_shift(session, edition, position, start=dt_at(10, 9), end=dt_at(10, 12))
session.add(Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id))
session.commit()
client.patch(
f"/editions/{edition.id}/positions/{position.id}", json={"message": "Gilet fourni"}
)
(entry,) = client.get(f"/editions/{edition.id}/volunteers/{volunteer.id}/planning").json()
assert entry["position_message"] == "Gilet fourni"
assert entry["position_message_updated_at"] is not None
+111 -2
View File
@@ -62,8 +62,9 @@ def test_magic_link_is_embedded_when_placeholder_present(
_url(edition), json={"body": "Connecte-toi : {lien_connexion}"} _url(edition), json={"body": "Connecte-toi : {lien_connexion}"}
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert "/connexion?token=" in plain_text(outbox[0]) assert "/connexion?token=pl." in plain_text(outbox[0])
assert len(session.exec(select(MagicLinkToken)).all()) == 1 # Lien permanent : rien de stocké, pas de jeton à usage unique.
assert session.exec(select(MagicLinkToken)).all() == []
def test_no_magic_link_token_without_placeholder( def test_no_magic_link_token_without_placeholder(
@@ -91,3 +92,111 @@ def test_planning_notification_is_coordinator_only(
lead = make_account(Role.LEAD, email="lead@e.fr") lead = make_account(Role.LEAD, email="lead@e.fr")
resp = as_account(lead).post(_url(edition), json={}) resp = as_account(lead).post(_url(edition), json={})
assert resp.status_code == 403 assert resp.status_code == 403
def test_shifts_placeholder_is_a_table_by_day_with_the_referent(
client, edition, session, outbox, make_account
):
rita = make_volunteer(session, edition, first_name="Rita", last_name="Ref", email="r@e.fr")
rita.phone = "06 11 22 33 44"
session.add(rita)
lead = make_account(Role.LEAD, volunteer_id=rita.id)
bar = make_position(session, edition, name="Bar central")
bar.lead_account_id = lead.id
session.add(bar)
parking = make_position(session, edition, name="Parking")
s1 = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
s2 = make_shift(session, edition, parking, start=dt_at(11, 9), end=dt_at(11, 12))
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
session.add_all(
[
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s1.id),
Assignment(edition_id=edition.id, volunteer_id=ana.id, shift_id=s2.id),
]
)
session.commit()
client.post(
_url(edition), json={"volunteer_ids": [str(ana.id)], "body": "Ton planning :\n\n{creneaux}"}
)
(message,) = outbox
html = message.get_body(preferencelist=("html",)).get_content()
# Vrai tableau : en-têtes, une ligne de jour, horaire, poste, référent.
assert "<table" in html and "Référent" in html
assert "18:00–22:00" in html and "Bar central" in html
assert "Rita Ref · 06 11 22 33 44" in html
assert "à désigner" in html # Parking n'a pas de référent
assert "" not in html
text = plain_text(message)
assert "18:00–22:00 · Bar central · référent : Rita Ref · 06 11 22 33 44" in text
assert "09:00–12:00 · Parking · référent : à désigner" in text
def _link(message) -> str:
import re
return re.search(r"token=(\S+)", plain_text(message)).group(1)
def _verify(client, edition, token):
return client.post(f"/editions/{edition.id}/auth/verify", json={"token": token})
def test_login_link_is_permanent_and_reusable(client, edition, session, make_account, outbox):
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
client.post(_url(edition), json={"body": "{lien_connexion}"})
client.post(_url(edition), json={"body": "{lien_connexion}"})
first, second = (_link(m) for m in outbox)
# Le même lien dans chaque email, utilisable plusieurs fois.
assert first == second
for _ in range(3):
resp = _verify(client, edition, first)
assert resp.status_code == 200
assert resp.json()["account"]["volunteer_id"] == str(ana.id)
def test_reset_revokes_the_links_already_sent(client, edition, session, make_account, outbox):
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
account = make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="ana@e.fr")
client.post(_url(edition), json={"body": "{lien_connexion}"})
old = _link(outbox[0])
resp = client.post(f"/editions/{edition.id}/accounts/{account.id}/reset-login-link")
assert resp.status_code == 200
assert _verify(client, edition, old).status_code == 401
client.post(_url(edition), json={"body": "{lien_connexion}"})
new = _link(outbox[1])
assert new != old
assert _verify(client, edition, new).status_code == 200
def test_sending_the_link_gives_an_account_to_a_volunteer_without_one(
client, edition, session, outbox
):
from app.models import Account
ana = make_volunteer(session, edition, first_name="Ana", email="ana@e.fr")
client.post(_url(edition), json={"body": "{lien_connexion}"})
(account,) = session.exec(select(Account).where(Account.volunteer_id == ana.id)).all()
assert account.role == Role.VOLUNTEER
assert _verify(client, edition, _link(outbox[0])).status_code == 200
def test_shared_address_gets_the_login_page_only(client, edition, session, make_account, outbox):
# Couple sur la même adresse : un seul compte possible pour cet email.
ana = make_volunteer(session, edition, first_name="Ana", email="foyer@e.fr")
make_account(Role.VOLUNTEER, volunteer_id=ana.id, email="foyer@e.fr")
bob = make_volunteer(session, edition, first_name="Bob", email="foyer@e.fr")
client.post(_url(edition), json={"volunteer_ids": [str(bob.id)], "body": "{lien_connexion}"})
assert "token=" not in plain_text(outbox[0])
def test_forged_or_coordinator_links_are_refused(client, edition, coordinator):
from app.auth.permanent_link import permanent_login_token
assert _verify(client, edition, permanent_login_token(coordinator)).status_code == 401
assert _verify(client, edition, "pl.nimportequoi.abc").status_code == 401
assert _verify(client, edition, f"pl.{coordinator.id.hex}.faux").status_code == 401
+46
View File
@@ -187,3 +187,49 @@ def test_filled_slots_never_count_overstaffing(client, edition, session):
assert position["assigned"] == 3 assert position["assigned"] == 3
assert position["filled"] == 2 assert position["filled"] == 2
assert position["ideal_slots"] == 4 assert position["ideal_slots"] == 4
def test_attendance_splits_past_and_live_shifts_per_position(
client, edition, session, monkeypatch
):
import app.api.stats as stats_api
from app.models import AttendanceStatus
# Maintenant : le 10 à 20:00 — 14h–18h terminé, 18h–22h en cours,
# 22h–02h à venir (ignoré).
monkeypatch.setattr(stats_api, "local_now", lambda: dt_at(10, 20))
bar = make_position(session, edition, name="Bar")
parking = make_position(session, edition, name="Parking")
past = make_shift(session, edition, bar, start=dt_at(10, 14), end=dt_at(10, 18))
live = make_shift(session, edition, bar, start=dt_at(10, 18), end=dt_at(10, 22))
later = make_shift(session, edition, parking, start=dt_at(10, 22), end=dt_at(11, 2))
def assign(shift, name, attendance=None):
volunteer = make_volunteer(session, edition, first_name=name)
session.add(
Assignment(
edition_id=edition.id,
volunteer_id=volunteer.id,
shift_id=shift.id,
attendance=attendance,
)
)
assign(past, "A", AttendanceStatus.PRESENT)
assign(past, "B", AttendanceStatus.ABSENT)
assign(past, "C")
assign(live, "D", AttendanceStatus.PRESENT)
assign(live, "E")
assign(later, "F", AttendanceStatus.PRESENT)
session.commit()
att = client.get(_url(edition)).json()["attendance"]
assert att["past"] == {"assigned": 3, "present": 1, "absent": 1, "unmarked": 1}
assert att["live"] == {"assigned": 2, "present": 1, "absent": 0, "unmarked": 1}
assert att["live_shifts"] == 1
# Parking n'a encore aucun créneau commencé : absent du détail.
(row,) = att["by_position"]
assert row["name"] == "Bar"
assert row["past"]["assigned"] == 3
assert row["live"]["present"] == 1
assert row["live_shifts"] == 1
+144
View File
@@ -0,0 +1,144 @@
"""Email de test depuis l'écran « Messages » (`POST .../notifications/test`) :
envoi synchrone, résultat réel du serveur SMTP renvoyé à la coordination."""
import smtplib
import pytest
from app.config import get_settings
from app.models import Role
def _url(edition):
return f"/editions/{edition.id}/notifications/test"
class _FakeSmtp:
def __init__(self, error=None):
self.sent = []
self.error = error
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def send_message(self, message):
if self.error:
raise self.error
self.sent.append(message)
@pytest.fixture
def smtp_enabled(monkeypatch):
monkeypatch.setenv("EMAIL_ENABLED", "true")
get_settings.cache_clear()
def test_disabled_email_is_reported(client, edition):
resp = client.post(_url(edition), json={"email": "moi@exemple.fr"})
assert resp.status_code == 200
assert resp.json()["status"] == "disabled"
def test_sends_the_test_email(client, edition, smtp_enabled, monkeypatch):
fake = _FakeSmtp()
monkeypatch.setattr("app.email.sender.open_smtp", lambda settings: fake)
resp = client.post(_url(edition), json={"email": " moi@exemple.fr "})
assert resp.json()["status"] == "sent"
(message,) = fake.sent
assert message["To"] == "moi@exemple.fr"
def test_connection_failure_is_reported_with_the_server_message(
client, edition, smtp_enabled, monkeypatch
):
def refuse(settings):
raise smtplib.SMTPAuthenticationError(535, b"authentication failed")
monkeypatch.setattr("app.email.sender.open_smtp", refuse)
body = client.post(_url(edition), json={"email": "moi@exemple.fr"}).json()
assert body["status"] == "connection_failed"
assert "535" in body["detail"]
def test_rejected_message_never_echoes_the_address(client, edition, smtp_enabled, monkeypatch):
refused = smtplib.SMTPRecipientsRefused({"moi@exemple.fr": (550, b"no such user")})
monkeypatch.setattr("app.email.sender.open_smtp", lambda settings: _FakeSmtp(refused))
body = client.post(_url(edition), json={"email": "moi@exemple.fr"}).json()
assert body["status"] == "rejected"
assert "550" in body["detail"]
assert "moi@exemple.fr" not in body["detail"]
@pytest.mark.parametrize("email", ["", "pas-un-email", "@exemple.fr", "moi@", "a b@c.fr"])
def test_invalid_address_is_rejected(client, edition, email):
resp = client.post(_url(edition), json={"email": email})
assert resp.status_code == 422
def test_is_coordinator_only(edition, make_account, as_account):
lead = as_account(make_account(Role.LEAD))
assert lead.post(_url(edition), json={"email": "moi@exemple.fr"}).status_code == 403
def _send(client, edition, monkeypatch, **payload):
captured = []
monkeypatch.setattr(
"app.api.notifications.deliver_test",
lambda message: captured.append(message) or ("sent", None),
)
body = client.post(_url(edition), json={"email": "moi@exemple.fr", **payload}).json()
return body, captured[0]
def test_test_email_is_the_compiled_planning_email(client, edition, session, monkeypatch):
from app.models import Assignment
from tests.factories import dt_at, make_position, make_shift, make_volunteer
from tests.mail import plain_text
make_volunteer(session, edition, first_name="Zoé", last_name="Zébu") # sans créneau
alex = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
shift = make_shift(session, edition, make_position(session, edition, name="Bar"),
start=dt_at(10, 18), end=dt_at(10, 22))
session.add(Assignment(edition_id=edition.id, volunteer_id=alex.id, shift_id=shift.id))
session.commit()
body, message = _send(
client, edition, monkeypatch,
subject="Planning de {prenom}",
body="Salut {prenom} {nom} : {nb_creneaux} créneau(x), {nb_heures} h\n{creneaux}",
)
# Premier bénévole (par nom) qui a un créneau, pas Zoé.
assert body["example_volunteer_name"] == "Alex Martin"
assert message["Subject"] == "[Test] Planning de Alex"
text = plain_text(message)
assert "Salut Alex Martin : 1 créneau(x), 4.0 h" in text
assert "Bar" in text
assert "{" not in text
def test_login_link_in_test_email_carries_no_token(client, edition, session, monkeypatch):
from sqlmodel import select
from app.models import MagicLinkToken
from tests.factories import make_volunteer
from tests.mail import plain_text
make_volunteer(session, edition)
_, message = _send(client, edition, monkeypatch, body="Connexion : {lien_connexion}")
assert "token=" not in plain_text(message)
assert session.exec(select(MagicLinkToken)).all() == []
def test_chosen_volunteer_and_fictitious_example(client, edition, session, monkeypatch):
from tests.factories import make_volunteer
body, message = _send(client, edition, monkeypatch, subject="Pour {prenom}")
assert body["example_volunteer_id"] is None
assert message["Subject"] == "[Test] Pour Camille"
sam = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
body, _ = _send(client, edition, monkeypatch, volunteer_id=str(sam.id))
assert body["example_volunteer_id"] == str(sam.id)
+56
View File
@@ -609,3 +609,59 @@ def test_export_gives_the_sumup_transaction(client, edition, session):
assert fiche["fee_transaction_code"] == "TEENSK4W2K" assert fiche["fee_transaction_code"] == "TEENSK4W2K"
# L'identifiant du checkout, lui, reste interne. # L'identifiant du checkout, lui, reste interne.
assert "fee_checkout_id" not in fiche assert "fee_checkout_id" not in fiche
def _png() -> bytes:
from PIL import Image
out = io.BytesIO()
Image.new("RGB", (4, 4), "red").save(out, format="PNG")
return out.getvalue()
def test_coordinator_adds_then_removes_a_volunteer_photo(client, edition, session):
volunteer = make_volunteer(session, edition)
url = _url(edition, f"/{volunteer.id}/photo")
assert client.get(url).status_code == 404
resp = client.put(url, files={"file": ("photo.png", _png(), "image/png")})
assert resp.status_code == 200
assert resp.json()["has_photo"] is True
photo = client.get(url)
assert photo.status_code == 200
assert photo.headers["content-type"] == "image/png"
resp = client.delete(url)
assert resp.status_code == 200
assert resp.json()["has_photo"] is False
assert client.get(url).status_code == 404
def test_adding_a_fake_photo_is_rejected(client, edition, session):
volunteer = make_volunteer(session, edition)
resp = client.put(
_url(edition, f"/{volunteer.id}/photo"),
files={"file": ("photo.png", b"pas une image", "image/png")},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "form.photo_type_not_supported"
def test_only_the_coordination_can_change_a_photo(
session, edition, make_account, as_account
):
volunteer = make_volunteer(session, edition)
lead = as_account(make_account(Role.LEAD, email="lead@e.fr"))
url = _url(edition, f"/{volunteer.id}/photo")
assert lead.put(url, files={"file": ("p.png", _png(), "image/png")}).status_code == 403
assert lead.delete(url).status_code == 403
def test_manual_add_requires_no_field(client, edition):
resp = client.post(_url(edition), json={})
assert resp.status_code == 201
body = resp.json()
assert body["first_name"] == ""
assert body["last_name"] == ""
assert client.get(_url(edition)).status_code == 200
+233
View File
@@ -207,3 +207,236 @@ def test_generating_produces_a_pdf(client, edition, session):
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.headers["content-type"] == "application/pdf" assert resp.headers["content-type"] == "application/pdf"
assert resp.content.startswith(b"%PDF") assert resp.content.startswith(b"%PDF")
def test_generating_with_photo_and_free_text_fields(client, edition, session):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
{
"field_type": "PHOTO", "x_mm": 60, "y_mm": 5,
"width_mm": 20, "height_mm": 25, "font_size": 8,
},
{
"field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40,
"width_mm": 50, "height_mm": 8, "font_size": 9,
"text": "Staff 2026",
},
]
},
)
assert resp.status_code == 200
by_type = {f["field_type"]: f for f in resp.json()}
assert set(by_type) == {"PHOTO", "FREE_TEXT"}
assert by_type["FREE_TEXT"]["text"] == "Staff 2026"
with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
with_photo.photo = _PNG
with_photo.photo_content_type = "image/png"
without_photo = make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
session.add(with_photo)
session.commit()
resp = client.patch(
f"/editions/{edition.id}/volunteers/{without_photo.id}",
json={"badge_text": "Accès backstage"},
)
assert resp.status_code == 200
assert resp.json()["badge_text"] == "Accès backstage"
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 200
assert resp.content.startswith(b"%PDF")
def _field(**kw):
return {"x_mm": 0, "y_mm": 0, "width_mm": 10, "height_mm": 10, **kw}
def test_new_field_attributes_default_and_round_trip(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FULL_NAME"),
_field(
field_type="PHOTO", face="BACK", photo_shape="OVAL",
bg_color="#FFD23F", auto_fit=True,
),
]
},
)
assert resp.status_code == 200
by_face = {f["face"]: f for f in resp.json()}
front, back = by_face["FRONT"], by_face["BACK"]
assert front["field_type"] == "FULL_NAME"
assert front["bg_color"] is None
assert front["auto_fit"] is False
assert front["photo_shape"] == "RECT"
assert back["field_type"] == "PHOTO"
assert back["photo_shape"] == "OVAL"
assert back["bg_color"] == "#FFD23F"
assert back["auto_fit"] is True
again = client.get(f"{_badges_url(edition)}/template").json()["fields"]
assert {(f["face"], f["field_type"]) for f in again} == {
("FRONT", "FULL_NAME"), ("BACK", "PHOTO"),
}
def test_same_field_type_on_front_and_back_is_allowed(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FREE_TEXT", face="FRONT", text="Recto"),
_field(field_type="FREE_TEXT", face="BACK", text="Verso"),
]
},
)
assert resp.status_code == 200
assert {(f["face"], f["text"]) for f in resp.json()} == {
("FRONT", "Recto"), ("BACK", "Verso"),
}
def test_same_field_type_twice_on_the_back_is_rejected(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="ROLE", face="BACK"),
_field(field_type="ROLE", face="BACK"),
]
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
@pytest.mark.parametrize("color", ["red", "#FFF", "#GGGGGG", "FFD23F", "#FFD23F0"])
def test_invalid_background_color_is_rejected(client, edition, color):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={"fields": [_field(field_type="ROLE", bg_color=color)]},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.invalid_color"
def test_generating_with_back_fields(client, edition, session, monkeypatch):
import app.api.badges as badges_api
captured = {}
original = badges_api.generate_badge_sheets_pdf
def spy(badges, *args, **kwargs):
captured["badges"] = badges
return original(badges, *args, **kwargs)
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FULL_NAME", width_mm=60, font_size=12),
_field(field_type="ROLE", face="BACK", bg_color="#FFD23F", auto_fit=True),
_field(field_type="PHOTO", face="BACK", photo_shape="OVAL"),
]
},
)
assert resp.status_code == 200
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 200
assert resp.content.startswith(b"%PDF")
(badge,) = captured["badges"]
assert [f.text for f in badge.fields] == ["Alex\nMartin"]
back = {f.is_photo: f for f in badge.back_fields}
assert back[False].text == "Bénévole"
assert back[False].valign == "MIDDLE"
assert back[False].bg_color == "#FFD23F"
assert back[False].auto_fit is True
assert back[True].photo_shape == "OVAL"
assert back[True].text == "" # pas de photo : rien d'imprimé
def test_generating_prints_as_many_badges_as_requested(client, edition, session, monkeypatch):
import app.api.badges as badges_api
captured = {}
original = badges_api.generate_badge_sheets_pdf
def spy(badges, *args, **kwargs):
captured["badges"] = badges
return original(badges, *args, **kwargs)
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
client.put(
f"{_badges_url(edition)}/template/fields",
json={"fields": [_field(field_type="FULL_NAME", width_mm=60, font_size=12)]},
)
many = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
resp = client.patch(f"/editions/{edition.id}/volunteers/{many.id}", json={"badge_count": 3})
assert resp.status_code == 200
assert resp.json()["badge_count"] == 3
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 200
names = [badge.fields[0].text for badge in captured["badges"]]
assert names == ["Alex\nMartin"] * 3 + ["Sam\nNuit"]
@pytest.mark.parametrize("count", [0, -1, 101])
def test_badge_count_out_of_range_is_rejected(client, edition, session, count):
volunteer = make_volunteer(session, edition)
resp = client.patch(
f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count}
)
assert resp.status_code == 422
def test_fields_are_drawn_in_stacking_order(client, edition, session, monkeypatch):
import app.api.badges as badges_api
captured = {}
original = badges_api.generate_badge_sheets_pdf
def spy(badges, *args, **kwargs):
captured["badges"] = badges
return original(badges, *args, **kwargs)
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
_field(field_type="FREE_TEXT", text="Staff", z_order=2),
_field(field_type="FULL_NAME", z_order=0),
_field(field_type="PHOTO", z_order=1),
]
},
)
assert resp.status_code == 200
assert [f["field_type"] for f in resp.json()] == ["FULL_NAME", "PHOTO", "FREE_TEXT"]
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200
(badge,) = captured["badges"]
assert [(f.is_photo, f.text) for f in badge.fields] == [
(False, "Alex\nMartin"),
(True, ""),
(False, "Staff"),
]
def test_photo_is_drawn_below_text_at_equal_stacking_order(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={"fields": [_field(field_type="FULL_NAME"), _field(field_type="PHOTO")]},
)
assert [f["field_type"] for f in resp.json()] == ["PHOTO", "FULL_NAME"]
+75 -51
View File
@@ -3,7 +3,7 @@
import datetime as dt import datetime as dt
import uuid import uuid
from app.api.badges import _field_text, _position_line from app.api.badges import _field_text, _positions_table
from app.models import ( from app.models import (
BadgeField, BadgeField,
BadgeFieldType, BadgeFieldType,
@@ -29,65 +29,45 @@ def _shift(start: dt.datetime, end: dt.datetime) -> Shift:
) )
def test_position_line_shows_the_schedule(): _BOUNDARY = dt.time(2, 0) # journée de festival de 02:00 à 02:00
volunteer = _volunteer(first_name="Alex", last_name="Martin")
position = _position(name="Bar central")
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
line = _position_line(position, shift, referent=None, volunteer_id=volunteer.id)
assert line == "Bar central 19:00–22:00"
def test_position_line_shows_the_referent_when_there_is_one(): def test_positions_table_groups_shifts_under_their_day():
volunteer = _volunteer(first_name="Alex", last_name="Martin") accueil = (
position = _position(name="Bar central")
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
referent = _volunteer(first_name="Sam", last_name="Nuit")
line = _position_line(position, shift, referent=referent, volunteer_id=volunteer.id)
assert line == "Bar central 19:00–22:00 (Référent : Sam Nuit)"
def test_position_line_hides_the_referent_when_it_is_the_badged_volunteer():
# Pas besoin de se dire à soi-même qu'on est referent de son propre poste.
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
line = _position_line(
_position(name="Bar central"),
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
referent=volunteer,
volunteer_id=volunteer.id,
)
assert "Référent" not in line
def test_field_text_positions_lists_one_line_per_shift_in_chronological_order():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.POSITIONS,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
morning = (
_position(name="Accueil"), _position(name="Accueil"),
_shift(dt.datetime(2026, 7, 9, 9, 0), dt.datetime(2026, 7, 9, 13, 0)), _shift(dt.datetime(2026, 7, 11, 9, 0), dt.datetime(2026, 7, 11, 13, 0)),
None,
) )
evening = ( bar = (
_position(name="Bar central"), _position(name="Bar central"),
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)), _shift(dt.datetime(2026, 7, 11, 19, 0), dt.datetime(2026, 7, 11, 22, 0)),
None, )
demontage = (
_position(name="Démontage"),
_shift(dt.datetime(2026, 7, 12, 14, 0), dt.datetime(2026, 7, 12, 18, 0)),
) )
text = _field_text( table = _positions_table([accueil, bar, demontage], _BOUNDARY)
field, volunteer,
assignments_by_volunteer={volunteer.id: [morning, evening]}, assert [(s.title, s.rows) for s in table.sections] == [
lead_volunteer_ids=set(), ("Samedi 11", (("09:00–13:00", "Accueil"), ("19:00–22:00", "Bar central"))),
("Dimanche 12", (("14:00–18:00", "Démontage"),)),
]
def test_positions_table_keeps_a_night_shift_on_the_day_it_started():
night = (
_position(name="Bar central"),
_shift(dt.datetime(2026, 7, 12, 0, 0), dt.datetime(2026, 7, 12, 1, 0)),
) )
assert text == "Accueil 09:00–13:00\nBar central 19:00–22:00" table = _positions_table([night], _BOUNDARY)
assert table.sections[0].title == "Samedi 11"
assert table.sections[0].rows == (("00:00–01:00", "Bar central"),)
def test_positions_table_is_empty_without_any_shift():
assert _positions_table([], _BOUNDARY) is None
def test_field_text_role_is_referent_only_for_lead_accounts(): def test_field_text_role_is_referent_only_for_lead_accounts():
@@ -99,3 +79,47 @@ def test_field_text_role_is_referent_only_for_lead_accounts():
assert _field_text(field, volunteer, {}, lead_volunteer_ids=set()) == "Bénévole" assert _field_text(field, volunteer, {}, lead_volunteer_ids=set()) == "Bénévole"
assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent" assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent"
def test_field_text_free_text_is_the_volunteer_badge_text():
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000",
)
with_text = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage")
without = _volunteer(first_name="Sam", last_name="Nuit")
assert _field_text(field, with_text, {}, set()) == "Accès backstage"
assert _field_text(field, without, {}, set()) == ""
def test_field_text_free_text_falls_back_to_the_common_editor_text():
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000",
text="Staff 2026",
)
own = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage")
other = _volunteer(first_name="Sam", last_name="Nuit")
assert _field_text(field, own, {}, set()) == "Accès backstage"
assert _field_text(field, other, {}, set()) == "Staff 2026"
def test_field_text_full_name_is_first_name_then_last_name_on_two_lines():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FULL_NAME,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
assert _field_text(field, volunteer, {}, set()) == "Alex\nMartin"
def test_field_text_photo_is_empty():
volunteer = _volunteer(first_name="alex", last_name="martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
# Une image, pas un texte : rien n'est imprimé sans photo.
assert _field_text(field, volunteer, {}, set()) == ""
+337
View File
@@ -1,14 +1,28 @@
"""Génération des planches de badges (module pur, sans base).""" """Génération des planches de badges (module pur, sans base)."""
import io
import pytest
from PIL import Image
from reportlab.lib.units import mm
from reportlab.pdfbase.pdfmetrics import stringWidth
from app.badges.pdf import ( from app.badges.pdf import (
ASSUMED_DPI,
DEFAULT_BADGE_HEIGHT_MM, DEFAULT_BADGE_HEIGHT_MM,
DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec, BadgeFieldSpec,
BadgeSpec, BadgeSpec,
BadgeTable,
BadgeTableSection,
_cell_origin, _cell_origin,
_column_widths,
_fit_photo,
_grid_size, _grid_size,
_spacing,
generate_badge_sheets_pdf, generate_badge_sheets_pdf,
image_size_mm, image_size_mm,
wrap_text,
) )
@@ -97,3 +111,326 @@ def test_more_badges_than_one_sheet_produces_a_larger_pdf():
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None [_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
) )
assert len(two_pages) > len(one_page) assert len(two_pages) > len(one_page)
def _jpeg(width_px: int, height_px: int) -> bytes:
out = io.BytesIO()
Image.new("RGB", (width_px, height_px), "red").save(out, format="JPEG")
return out.getvalue()
def _photo_badge(image: bytes | None) -> BadgeSpec:
return BadgeSpec(
fields=(
BadgeFieldSpec(
text="", x_mm=60, y_mm=5, width_mm=20, height_mm=25,
font_size=8, align="LEFT", bold=False, color="#000000",
is_photo=True, image=image,
),
)
)
def test_fit_photo_crops_to_the_frame_ratio_at_print_resolution():
# Photo paysage 4000×3000 dans un cadre portrait 20×25 mm : recadrée
# (pas déformée) et réduite à 300 dpi.
fitted = _fit_photo(_jpeg(4000, 3000), width_mm=20, height_mm=25)
with Image.open(io.BytesIO(fitted)) as img:
assert img.size == (round(20 / 25.4 * ASSUMED_DPI), round(25 / 25.4 * ASSUMED_DPI))
def test_fit_photo_returns_none_for_an_unreadable_image():
assert _fit_photo(b"pas une image", width_mm=20, height_mm=25) is None
def test_photo_field_embeds_the_volunteer_photo():
without = generate_badge_sheets_pdf([_photo_badge(None)], front_image=None, back_image=None)
with_photo = generate_badge_sheets_pdf(
[_photo_badge(_jpeg(400, 500))], front_image=None, back_image=None
)
assert without.startswith(b"%PDF")
assert b"/Subtype /Image" in with_photo
assert b"/Subtype /Image" not in without
def test_unreadable_photo_does_not_crash_generation():
pdf = generate_badge_sheets_pdf(
[_photo_badge(b"pas une image")], front_image=None, back_image=None
)
assert pdf.startswith(b"%PDF")
def test_wrap_text_keeps_a_short_text_on_one_line():
assert wrap_text("Alex Martin", "Helvetica", 12, 60 * mm) == ["Alex Martin"]
def test_wrap_text_breaks_between_words_to_fit_the_width():
# Assez large pour « Marie-Christine » seul, trop étroit pour le tout.
width = 40 * mm
lines = wrap_text("Marie-Christine de La Fontaine", "Helvetica-Bold", 12, width)
assert len(lines) > 1
assert " ".join(lines) == "Marie-Christine de La Fontaine"
assert all(stringWidth(line, "Helvetica-Bold", 12) <= width for line in lines)
def test_wrap_text_keeps_explicit_line_breaks_and_wraps_each_line():
text = "Bar central 19:00–22:00 (Référent : Sam Nuit)\nAccueil 09:00–13:00"
width = 40 * mm
lines = wrap_text(text, "Helvetica", 8, width)
assert lines[0].startswith("Bar central 19:00–22:00")
assert "Accueil 09:00–13:00" in lines
assert all(stringWidth(line, "Helvetica", 8) <= width for line in lines)
def test_wrap_text_cuts_a_single_word_too_long_for_the_frame():
width = 10 * mm
lines = wrap_text("Anticonstitutionnellement", "Helvetica", 12, width)
assert "".join(lines) == "Anticonstitutionnellement"
assert all(stringWidth(line, "Helvetica", 12) <= width for line in lines)
def _table_badge(rows: int, height_mm: float = 30) -> BadgeSpec:
return BadgeSpec(
fields=(
BadgeFieldSpec(
text="", x_mm=5, y_mm=5, width_mm=60, height_mm=height_mm,
font_size=7, align="LEFT", bold=False, color="#000000",
table=BadgeTable(
sections=(
BadgeTableSection(
title="Samedi 11",
rows=tuple(
("19:00–22:00", "Bar central très très long")
for _ in range(rows)
),
),
)
),
),
)
)
def test_column_widths_keep_short_columns_and_share_the_rest():
widths = _column_widths([20, 30, 200, 100], available=200)
assert widths[:2] == [20, 30]
assert widths[2] == widths[3] == 75
assert sum(widths) == 200
def test_column_widths_give_the_spare_room_to_the_last_column():
assert _column_widths([20, 30, 40], available=150) == [20, 30, 100]
def test_table_field_generates_a_valid_pdf():
pdf = generate_badge_sheets_pdf([_table_badge(3)], front_image=None, back_image=None)
assert pdf.startswith(b"%PDF")
def test_table_rows_beyond_the_frame_are_not_drawn():
# Cadre trop petit pour 50 lignes : celles qui débordent sont omises,
# la planche fait la même taille qu'avec quelques lignes seulement.
few = generate_badge_sheets_pdf([_table_badge(4, height_mm=12)], None, None)
many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None)
assert abs(len(many) - len(few)) < 50
# --------------------------------------------------------------------------- #
# Éditeur v2 : verso, fond de zone, réduction automatique, photo #
# --------------------------------------------------------------------------- #
from app.badges import pdf as pdf_module # noqa: E402
from app.badges.pdf import fit_text # noqa: E402
def _text_field(**kw) -> BadgeFieldSpec:
base = dict(
text="Alex", x_mm=5, y_mm=5, width_mm=60, height_mm=10,
font_size=12, align="LEFT", bold=False, color="#000000",
)
base.update(kw)
return BadgeFieldSpec(**base)
def test_cell_origin_mirrors_the_column_on_the_back_page():
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
# Retournement bord long : colonne 0 au recto ⇒ dernière colonne au
# verso, même rangée.
assert _cell_origin(0, cols, w, h, mirrored=True) == _cell_origin(cols - 1, cols, w, h)
assert _cell_origin(cols, cols, w, h, mirrored=True) == _cell_origin(
2 * cols - 1, cols, w, h
)
def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeypatch):
calls = []
original = pdf_module._draw_fields
def spy(c, fields, x0, y0, badge_height_mm, photos):
calls.append((fields, x0, y0))
original(c, fields, x0, y0, badge_height_mm, photos)
monkeypatch.setattr(pdf_module, "_draw_fields", spy)
back = (_text_field(text="Verso Alex"),)
# Un seul badge : planche incomplète, le verso doit quand même être en
# miroir (colonne de droite).
generate_badge_sheets_pdf(
[BadgeSpec(fields=(_text_field(),), back_fields=back)], None, None
)
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
(front_fields, fx, fy), (back_fields, bx, by) = calls
assert (fx, fy) == _cell_origin(0, cols, w, h)
assert back_fields == back
assert (bx, by) == _cell_origin(cols - 1, cols, w, h)
def test_fit_text_without_auto_fit_drops_the_lines_that_overflow():
text = "\n".join(["ligne"] * 10)
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)
assert size == 12
assert 0 < len(lines) < 10
def test_fit_text_with_auto_fit_shrinks_until_every_line_fits():
text = "\n".join(["ligne"] * 4)
assert len(fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)[1]) < 4
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
assert 4 < size < 12
assert (size * 2) == int(size * 2) # pas de 0,5 pt
assert len(lines) == 4
def test_fit_text_auto_fit_stops_at_the_minimum_size():
text = "\n".join(["ligne"] * 200)
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
assert size == 4.0
assert len(lines) < 200
def test_fit_text_auto_fit_keeps_the_size_when_the_text_already_fits():
assert fit_text("Alex", "Helvetica", 12, 60, 10, auto_fit=True) == (12, ["Alex"])
def _table_lines_drawn(monkeypatch, auto_fit: bool) -> int:
drawn = []
monkeypatch.setattr(
pdf_module.canvas.Canvas, "drawString",
lambda self, x, y, text, *a, **k: drawn.append(text),
)
badge = _table_badge(4, height_mm=20)
field = badge.fields[0]
badge = BadgeSpec(fields=(BadgeFieldSpec(**{**field.__dict__, "auto_fit": auto_fit}),))
generate_badge_sheets_pdf([badge], None, None)
return sum(1 for text in drawn if text == "19:00–22:00")
def test_table_auto_fit_draws_every_row(monkeypatch):
# En-tête + 4 lignes : la dernière ne tient pas à 7 pt dans 20 mm.
assert _table_lines_drawn(monkeypatch, auto_fit=False) < 4
assert _table_lines_drawn(monkeypatch, auto_fit=True) == 4
def _filled_rects(monkeypatch) -> list:
fills = []
original = pdf_module.canvas.Canvas.rect
def spy(self, x, y, w, h, stroke=1, fill=0):
if fill:
fills.append((round(w / mm, 3), round(h / mm, 3)))
return original(self, x, y, w, h, stroke=stroke, fill=fill)
monkeypatch.setattr(pdf_module.canvas.Canvas, "rect", spy)
return fills
def test_zone_background_fills_the_field_frame(monkeypatch):
fills = _filled_rects(monkeypatch)
generate_badge_sheets_pdf(
[BadgeSpec(fields=(_text_field(bg_color="#FFD23F"),))], None, None
)
assert fills == [(60, 10)]
def test_no_zone_background_by_default(monkeypatch):
fills = _filled_rects(monkeypatch)
generate_badge_sheets_pdf([BadgeSpec(fields=(_text_field(),))], None, None)
assert fills == []
def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch):
baselines = []
monkeypatch.setattr(
pdf_module.canvas.Canvas, "drawString",
lambda self, x, y, text, *a, **k: baselines.append(y),
)
for valign in ("TOP", "MIDDLE"):
generate_badge_sheets_pdf(
[BadgeSpec(fields=(_text_field(height_mm=30, valign=valign),))], None, None
)
top, middle = baselines
assert middle < top
def test_missing_photo_prints_nothing(monkeypatch):
calls = []
for name in ("drawImage", "drawPath", "drawCentredString", "drawString"):
monkeypatch.setattr(
pdf_module.canvas.Canvas, name,
lambda self, *a, _name=name, **k: calls.append(_name),
)
for image in (None, b"pas une image"):
generate_badge_sheets_pdf([_photo_badge(image)], None, None)
assert calls == []
def test_oval_photo_and_missing_oval_photo_generate_a_valid_pdf():
for image in (_jpeg(400, 500), None):
field = _photo_badge(image).fields[0]
badge = BadgeSpec(
fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL"}),)
)
pdf = generate_badge_sheets_pdf([badge], None, None)
assert pdf.startswith(b"%PDF")
@pytest.mark.parametrize(
"size", [(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM), (54, 85.6), (50, 50), (74, 105)]
)
def test_grid_is_centred_with_equal_gaps_and_back_falls_behind_front(size):
from app.badges.pdf import _PAGE_HEIGHT_MM, _PAGE_WIDTH_MM
w, h = size
cols, rows = _grid_size(w, h)
xs = sorted({_cell_origin(i, cols, w, h)[0] for i in range(cols * rows)})
ys = sorted({_cell_origin(i, cols, w, h)[1] for i in range(cols * rows)})
left, right = xs[0], _PAGE_WIDTH_MM - (xs[-1] + w)
bottom, top = ys[0], _PAGE_HEIGHT_MM - (ys[-1] + h)
# Symétrique : même marge à gauche et à droite, en haut et en bas.
assert left == pytest.approx(right)
assert top == pytest.approx(bottom)
# Écarts entre badges tous égaux, et égaux aux marges quand c'est possible.
gaps_x = {round(b - a - w, 6) for a, b in zip(xs, xs[1:], strict=False)}
gaps_y = {round(b - a - h, 6) for a, b in zip(ys, ys[1:], strict=False)}
assert len(gaps_x) <= 1 and len(gaps_y) <= 1
_, gap_x = _spacing(cols, _PAGE_WIDTH_MM, w)
if gap_x >= 10 and cols > 1:
assert left == pytest.approx(gap_x)
# Verso : exactement derrière le recto une fois la feuille retournée
# sur son bord long (miroir horizontal).
for i in range(cols * rows):
fx, fy = _cell_origin(i, cols, w, h)
bx, by = _cell_origin(i, cols, w, h, mirrored=True)
assert bx == pytest.approx(_PAGE_WIDTH_MM - fx - w)
assert by == pytest.approx(fy)
def test_cr80_gaps_are_even():
# 2 × 85,6 mm sur 210 mm : 12,9 mm à gauche, entre les deux, à droite.
assert _spacing(2, 210, 85.6) == pytest.approx((12.9333, 12.9333), abs=1e-3)
+22
View File
@@ -174,3 +174,25 @@ def test_a_night_that_passes_midnight_stays_on_its_festival_day(client, edition,
text = reader.pages[0].extract_text() text = reader.pages[0].extract_text()
assert "jeudi 9 juillet" in text.lower() assert "jeudi 9 juillet" in text.lower()
assert "01:00" in text assert "01:00" in text
def test_organisation_message_is_passed_as_the_page_footer(client, edition, session, monkeypatch):
import app.api.position_sheets as sheets_api
captured = {}
original = sheets_api.generate_position_sheets_pdf
def spy(sheets, footer=None):
captured["footer"] = footer
return original(sheets, footer)
monkeypatch.setattr(sheets_api, "generate_position_sheets_pdf", spy)
position = make_position(session, edition)
make_shift(session, edition, position, start=dt_at(9, 19), end=dt_at(9, 22))
assert client.post(_url(edition), json={}).status_code == 200
assert captured["footer"] is None
client.patch(f"/editions/{edition.id}", json={"volunteer_message": "Repas à 18h30"})
assert client.post(_url(edition), json={}).status_code == 200
assert captured["footer"].message == "Repas à 18h30"
+84
View File
@@ -2,6 +2,7 @@
import base64 import base64
import datetime as dt import datetime as dt
import io
from app.sheets.pdf import ( from app.sheets.pdf import (
PositionDaySheet, PositionDaySheet,
@@ -142,3 +143,86 @@ def test_generates_a_valid_pdf_without_a_lineup():
assert sheet.artists == () assert sheet.artists == ()
pdf = generate_position_sheets_pdf([sheet]) pdf = generate_position_sheets_pdf([sheet])
assert pdf.startswith(b"%PDF") assert pdf.startswith(b"%PDF")
def _phone_photo(seed: int) -> bytes:
"""Photo de téléphone : 3000 × 4000 px, plusieurs Mo."""
from PIL import Image as PILImage
img = PILImage.new("RGB", (3000, 4000), (seed * 40 % 255, 120, 60))
img.paste(PILImage.effect_noise((800, 800), 80).convert("RGB"), (seed * 100, 200))
out = io.BytesIO()
img.save(out, format="JPEG", quality=92)
return out.getvalue()
def test_large_photos_are_shrunk_to_the_thumbnail():
# Régression : la photo d'origine était intégrée telle quelle pour
# chaque bénévole de chaque créneau — génération interminable, PDF
# énorme, puis erreur.
photos = [_phone_photo(i) for i in range(5)]
volunteers = tuple(
SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=p) for i, p in enumerate(photos)
)
pdf = generate_position_sheets_pdf([_sheet(volunteers=volunteers)])
assert sum(len(p) for p in photos) > 2_000_000
assert len(pdf) < 200_000
def test_unreadable_photo_does_not_break_the_sheet():
pdf = generate_position_sheets_pdf(
[_sheet(volunteers=(SheetVolunteer(name="Alex", phone=None, photo=b"pas une image"),))]
)
assert pdf.startswith(b"%PDF")
def _page_texts(pdf: bytes) -> list[str]:
from pypdf import PdfReader
return [page.extract_text() for page in PdfReader(io.BytesIO(pdf)).pages]
def test_footer_message_is_printed_at_the_bottom_of_every_page():
from app.sheets.pdf import SheetFooter
# Deux feuilles, dont une assez longue pour déborder sur une 2e page.
many = tuple(SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=None) for i in range(120))
pdf = generate_position_sheets_pdf(
[_sheet(volunteers=many), _sheet()],
SheetFooter(title="Message de l'organisation", message="Repas à 18h30\nBadges à l'accueil"),
)
pages = _page_texts(pdf)
assert len(pages) >= 3
for text in pages:
assert "Message de l'organisation" in text
assert "Repas à 18h30" in text
assert "Badges à l'accueil" in text
def test_no_footer_without_message():
pages = _page_texts(generate_position_sheets_pdf([_sheet()]))
assert "Message de l'organisation" not in pages[0]
def test_volunteers_are_listed_two_per_row():
from app.sheets.pdf import _shift_table, _styles
shift = _sheet(
volunteers=tuple(SheetVolunteer(name=f"B{i}", phone=None, photo=None) for i in range(5))
).shifts[0]
table = _shift_table(shift, {}, _styles(), 180)
# 5 bénévoles, deux par ligne : 3 lignes (la dernière à moitié vide).
assert len(table._cellvalues) == 3
def test_a_typical_sheet_fits_on_one_page():
volunteers = tuple(
SheetVolunteer(name=f"Prénom{i} Nom{i}", phone="06 12 34 56 78", photo=_PNG)
for i in range(8)
)
shift = _sheet(volunteers=volunteers).shifts[0]
sheet = _sheet(volunteers=volunteers)
many_shifts = PositionDaySheet(
**{**sheet.__dict__, "shifts": (shift, shift, shift, shift)}
)
assert len(_page_texts(generate_position_sheets_pdf([many_shifts]))) == 1
+59 -1
View File
@@ -461,6 +461,64 @@ function checkDayBoundary() {
console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`); console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`);
} }
// Éditeur de badges (onglet « Personnaliser ») : un champ par face, rendu
// avec les vraies données (nom du premier bénévole dans l'ordre
// d'impression), contrôle avant génération présent ; le mock refuse un même
// type de champ deux fois sur une face, mais l'accepte sur les deux.
async function checkBadgeEditor() {
if (!coordinator) throw new Error('badges : pas de session coordination pour ce test');
const base = {
x_mm: 4, y_mm: 4, width_mm: 40, height_mm: 12, font_size: 9, align: 'LEFT' as const,
bold: true, color: '#14110E', bg_color: null, auto_fit: true, photo_shape: 'RECT' as const,
};
let duplicate = 'non';
try {
await api.badges.setFields(e, {
fields: [
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
],
});
} catch (err) {
duplicate = err instanceof ApiError ? err.code : String(err);
}
if (duplicate !== 'badge.duplicate_field_type') throw new Error(`doublon accepté : ${duplicate}`);
await api.badges.setFields(e, {
fields: [
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
{ ...base, field_type: 'POSITIONS', face: 'FRONT', y_mm: 18, height_mm: 30, bold: false },
{ ...base, field_type: 'FULL_NAME', face: 'BACK', bg_color: '#FFD23F' },
{ ...base, field_type: 'FREE_TEXT', face: 'BACK', y_mm: 30, text: 'STAFF' },
],
});
const vols = [...(await api.volunteers.list(e))].sort(
(a, b) =>
a.last_name.localeCompare(b.last_name, 'fr') || a.first_name.localeCompare(b.first_name, 'fr'),
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await prefetchCore(client);
await Promise.all([
client.prefetchQuery({ queryKey: ['badgeTemplate', e], queryFn: () => api.badges.getTemplate(e) }),
client.prefetchQuery({ queryKey: ['dayParts', e], queryFn: () => api.dayParts.list(e) }),
client.prefetchQuery({ queryKey: ['accounts', e], queryFn: () => api.accounts.list(e) }),
]);
const html = renderToString(
createElement(
QueryClientProvider,
{ client },
createElement(
AuthContext.Provider,
{ value: authValue(coordinator) },
createElement(MemoryRouter, null, createElement(BadgesPage)),
),
),
);
const expected = [vols[0]?.last_name ?? '', 'STAFF', 'Contrôle des'];
const missing = expected.filter((text) => !html.includes(text));
if (missing.length) throw new Error(`badges : absent du rendu ${JSON.stringify(missing)}`);
console.log(`ok ${'/badges'.padEnd(26)} ${html.length} octets`);
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
try { try {
checkDayBoundary(); checkDayBoundary();
@@ -484,7 +542,7 @@ if (coordinator) {
await renderProtected('/grille', coordinator, createElement(GrillePage)); await renderProtected('/grille', coordinator, createElement(GrillePage));
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage)); await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
await renderProtected('/configuration', coordinator, createElement(ConfigPage)); await renderProtected('/configuration', coordinator, createElement(ConfigPage));
await renderProtected('/badges', coordinator, createElement(BadgesPage)); await checkBadgeEditor().catch((err) => fail('checkBadgeEditor', err));
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage)); await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => { await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => {
await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) }); await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) });
+77 -5
View File
@@ -37,6 +37,9 @@ import type {
ShiftUpdate, ShiftUpdate,
VolunteerCreate, VolunteerCreate,
VolunteerUpdate, VolunteerUpdate,
AssignmentRead,
AttendanceStatus,
TestEmailRequest,
} from './schema'; } from './schema';
const edition = EDITION_ID; const edition = EDITION_ID;
@@ -103,9 +106,15 @@ export function useVolunteerPlanning(volunteerId: string | null | undefined) {
}); });
} }
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public), ou /** URL objet (aperçu) de la photo d'un bénévole (formulaire public ou
* `null` si absente. */ * ajoutée par la coordination), ou `null` si absente. `version` (ex.
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) { * `updated_at`) force le rechargement quand la photo est remplacée —
* `hasPhoto` resterait `true` et rien ne changerait sinon. */
export function useVolunteerPhotoUrl(
volunteerId: string | null | undefined,
hasPhoto: boolean,
version?: string,
) {
const [url, setUrl] = useState<string | null>(null); const [url, setUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let objectUrl: string | null = null; let objectUrl: string | null = null;
@@ -123,7 +132,7 @@ export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, has
cancelled = true; cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl); if (objectUrl) URL.revokeObjectURL(objectUrl);
}; };
}, [volunteerId, hasPhoto]); }, [volunteerId, hasPhoto, version]);
return url; return url;
} }
@@ -151,8 +160,14 @@ export function useMatrix() {
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) — /** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
* recalculées côté serveur à chaque appel. */ * recalculées côté serveur à chaque appel. */
/** Rafraîchie toutes les 30 s tant que l'écran est ouvert : la section
* « Présence » suit le pointage des référents en temps réel. */
export function useStats() { export function useStats() {
return useQuery({ queryKey: ['stats', edition] as const, queryFn: () => api.stats.get(edition) }); return useQuery({
queryKey: ['stats', edition] as const,
queryFn: () => api.stats.get(edition),
refetchInterval: 30_000,
});
} }
/** Configuration du recto/verso des badges (images, champs). */ /** Configuration du recto/verso des badges (images, champs). */
@@ -336,6 +351,14 @@ export function useUpdateAccount() {
}); });
} }
export function useResetLoginLink() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.accounts.resetLoginLink(edition, id),
onSuccess: invalidate.accounts,
});
}
export function useDeleteAccount() { export function useDeleteAccount() {
const invalidate = useInvalidate(); const invalidate = useInvalidate();
return useMutation({ return useMutation({
@@ -361,6 +384,23 @@ export function useUpdateVolunteer() {
}); });
} }
export function useSetVolunteerPhoto() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) =>
api.volunteers.setPhoto(edition, id, file),
onSuccess: invalidate.volunteers,
});
}
export function useRemoveVolunteerPhoto() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.volunteers.removePhoto(edition, id),
onSuccess: invalidate.volunteers,
});
}
export function useDeleteVolunteer() { export function useDeleteVolunteer() {
const invalidate = useInvalidate(); const invalidate = useInvalidate();
return useMutation({ return useMutation({
@@ -500,6 +540,32 @@ export function useUpdateAssignment() {
}); });
} }
/** Pointage présent / absent (`null` annule), enregistré en base. Mis à
* jour tout de suite dans les listes d'affectations en cache (le référent
* pointe vite, sur un réseau de festival souvent lent), puis rechargé. */
export function useSetAttendance() {
const client = useQueryClient();
const patch = (id: string, change: Partial<AssignmentRead>) =>
client.setQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] }, (list) =>
list?.map((a) => (a.id === id ? { ...a, ...change } : a)),
);
return useMutation({
mutationFn: ({ id, attendance }: { id: string; attendance: AttendanceStatus | null }) =>
api.assignments.setAttendance(edition, id, { attendance }),
onMutate: async ({ id, attendance }) => {
await client.cancelQueries({ queryKey: ['assignments', edition] });
const previous = client.getQueriesData<AssignmentRead[]>({ queryKey: ['assignments', edition] });
patch(id, { attendance });
return { previous };
},
onError: (_error, _vars, context) => {
for (const [key, data] of context?.previous ?? []) client.setQueryData(key, data);
},
onSuccess: (saved) => patch(saved.id, saved),
onSettled: () => client.invalidateQueries({ queryKey: ['assignments', edition] }),
});
}
export function useDeleteAssignment() { export function useDeleteAssignment() {
const invalidate = useInvalidate(); const invalidate = useInvalidate();
return useMutation({ return useMutation({
@@ -529,6 +595,12 @@ export function useGeneratePositionSheets() {
}); });
} }
export function useSendTestEmail() {
return useMutation({
mutationFn: (body: TestEmailRequest) => api.notifications.sendTest(edition, body),
});
}
export function useSendPlanningNotification() { export function useSendPlanningNotification() {
return useMutation({ return useMutation({
mutationFn: (body: PlanningNotificationRequest) => mutationFn: (body: PlanningNotificationRequest) =>
+21
View File
@@ -30,6 +30,8 @@ import type {
VerifyRequest, VerifyRequest,
VolunteerCreate, VolunteerCreate,
VolunteerUpdate, VolunteerUpdate,
AttendanceUpdate,
TestEmailRequest,
} from './schema'; } from './schema';
import { getAuthToken, notifyUnauthorized } from './session'; import { getAuthToken, notifyUnauthorized } from './session';
import { ApiError, type PlanningApi } from './types'; import { ApiError, type PlanningApi } from './types';
@@ -155,6 +157,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
planning: (editionId, id) => planning: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/planning`), request(`${base(editionId)}/volunteers/${id}/planning`),
photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`), photoBlob: (editionId, id) => requestBlob(`${base(editionId)}/volunteers/${id}/photo`),
setPhoto: (editionId, id, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'PUT', body: form });
},
removePhoto: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}/photo`, { method: 'DELETE' }),
exportXlsx: async (editionId) => { exportXlsx: async (editionId) => {
const blob = await requestBlob(`${base(editionId)}/volunteers/export`); const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
if (!blob) throw new ApiError(500, 'error.generic', []); if (!blob) throw new ApiError(500, 'error.generic', []);
@@ -174,6 +183,8 @@ export function createHttpApi(baseUrl: string): PlanningApi {
request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), request(`${base(editionId)}/accounts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) => remove: (editionId, id) =>
request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }), request(`${base(editionId)}/accounts/${id}`, { method: 'DELETE' }),
resetLoginLink: (editionId, id) =>
request(`${base(editionId)}/accounts/${id}/reset-login-link`, { method: 'POST' }),
}, },
imports: { imports: {
analyze: (editionId, file: File) => { analyze: (editionId, file: File) => {
@@ -225,6 +236,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }), request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) => remove: (editionId, id) =>
request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }), request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }),
setAttendance: (editionId, id, body: AttendanceUpdate) =>
request(`${base(editionId)}/assignments/${id}/attendance`, {
method: 'PUT',
body: JSON.stringify(body),
}),
validate: (editionId, body: AssignmentValidateRequest) => validate: (editionId, body: AssignmentValidateRequest) =>
request(`${base(editionId)}/assignments/validate`, { request(`${base(editionId)}/assignments/validate`, {
method: 'POST', method: 'POST',
@@ -365,6 +381,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
method: 'POST', method: 'POST',
body: JSON.stringify(body), body: JSON.stringify(body),
}), }),
sendTest: (editionId, body: TestEmailRequest) =>
request(`${base(editionId)}/notifications/test`, {
method: 'POST',
body: JSON.stringify(body),
}),
}, },
auth: { auth: {
login: (editionId, body: LoginRequest) => login: (editionId, body: LoginRequest) =>
+17
View File
@@ -195,6 +195,11 @@ function demoEmail(firstName: string, lastName: string): string {
return `${n(firstName)}.${n(lastName)}@mail.fr`; return `${n(firstName)}.${n(lastName)}@mail.fr`;
} }
const DEMO_POSITION_MESSAGES: Record<string, string> = {
'Parking — Nord': 'Gilet jaune fourni à la base bénévoles. Lampe frontale conseillée le soir.',
'Bar — Grande scène': 'Caisse à récupérer auprès du référent en début de créneau. Pas de vente aux mineurs.',
};
export function buildMockState(): MockState { export function buildMockState(): MockState {
const rnd = mulberry32(20260715); const rnd = mulberry32(20260715);
const edition_id = MOCK_EDITION_ID; const edition_id = MOCK_EDITION_ID;
@@ -206,6 +211,9 @@ export function buildMockState(): MockState {
location: p.location, location: p.location,
category: p.category, category: p.category,
lead_account_id: null, lead_account_id: null,
// Quelques consignes de démo (voir `PositionRead.message`).
message: DEMO_POSITION_MESSAGES[p.name] ?? null,
message_updated_at: DEMO_POSITION_MESSAGES[p.name] ? NOW : null,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
})); }));
@@ -289,6 +297,8 @@ export function buildMockState(): MockState {
wished_companion_ids, wished_companion_ids,
has_photo: false, has_photo: false,
companion_wish_note: null, companion_wish_note: null,
badge_text: null,
badge_count: 1,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null, fee_payment_label: null,
@@ -372,6 +382,8 @@ export function buildMockState(): MockState {
shift_id: shift.id, shift_id: shift.id,
source: 'SOLVER', source: 'SOLVER',
locked: false, locked: false,
attendance: null,
attendance_marked_at: null,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}); });
@@ -401,6 +413,8 @@ export function buildMockState(): MockState {
shift_id: shift.id, shift_id: shift.id,
source: 'MANUAL', source: 'MANUAL',
locked: false, locked: false,
attendance: null,
attendance_marked_at: null,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}); });
@@ -464,6 +478,9 @@ export function buildMockState(): MockState {
start_date: DAY_DATES[0], start_date: DAY_DATES[0],
end_date: DAY_DATES[DAY_DATES.length - 1], end_date: DAY_DATES[DAY_DATES.length - 1],
retention_days: 365, retention_days: 365,
volunteer_message:
"Badges et t-shirts à récupérer à l'accueil bénévoles dès 17:00. Repas chaud servi au catering de 17:30 à 18:30.",
volunteer_message_updated_at: NOW,
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}; };
+152 -3
View File
@@ -1,5 +1,5 @@
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning'; import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
import { hoursBetween, naiveMinutes } from '@/lib/time'; import { hoursBetween, naiveMinutes, nowNaiveMinutes } from '@/lib/time';
import type { import type {
AccountRead, AccountRead,
@@ -44,6 +44,7 @@ import type {
VolunteerRead, VolunteerRead,
EditionStats, EditionStats,
} from '../schema'; } from '../schema';
import { MESSAGE_MAX_LENGTH } from '../schema';
import { getAuthToken } from '../session'; import { getAuthToken } from '../session';
import { ApiError, type PlanningApi } from '../types'; import { ApiError, type PlanningApi } from '../types';
import { buildMockState, type MockPendingSubmission, type MockState } from './dataset'; import { buildMockState, type MockPendingSubmission, type MockState } from './dataset';
@@ -234,6 +235,8 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
wished_companion_ids: [], wished_companion_ids: [],
has_photo: pending.photo !== null, has_photo: pending.photo !== null,
companion_wish_note: pending.companionWishNote, companion_wish_note: pending.companionWishNote,
badge_text: null,
badge_count: 1,
self_declared_available: pending.available, self_declared_available: pending.available,
willing_to_pay_fee: true, willing_to_pay_fee: true,
fee_payment_label: pending.paymentLabel, fee_payment_label: pending.paymentLabel,
@@ -591,6 +594,53 @@ function buildStats(): EditionStats {
}), }),
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size, volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
}, },
attendance: buildAttendance(),
};
}
/** Même calcul que `app/api/stats.py:_attendance`. */
function buildAttendance(): EditionStats['attendance'] {
const now = nowNaiveMinutes();
const empty = () => ({ assigned: 0, present: 0, absent: 0, unmarked: 0 });
const count = (c: ReturnType<typeof empty>, a: AssignmentRead) => {
c.assigned += 1;
if (a.attendance === 'PRESENT') c.present += 1;
else if (a.attendance === 'ABSENT') c.absent += 1;
else c.unmarked += 1;
};
const past = empty();
const live = empty();
const rows = new Map(
state.positions.map((p) => [
p.id,
{ position_id: p.id, name: p.name, past: empty(), live: empty(), live_shifts: 0 },
]),
);
const phase = new Map<string, 'past' | 'live'>();
for (const s of state.shifts) {
if (naiveMinutes(s.ends_at) <= now) phase.set(s.id, 'past');
else if (naiveMinutes(s.starts_at) <= now) {
phase.set(s.id, 'live');
const row = rows.get(s.position_id);
if (row) row.live_shifts += 1;
}
}
for (const a of state.assignments) {
const when = phase.get(a.shift_id);
const shift = state.shifts.find((s) => s.id === a.shift_id);
const row = shift ? rows.get(shift.position_id) : undefined;
if (!when || !row) continue;
count(when === 'past' ? past : live, a);
count(when === 'past' ? row.past : row.live, a);
}
return {
as_of: touch(),
past,
live,
live_shifts: [...rows.values()].reduce((n, r) => n + r.live_shifts, 0),
by_position: [...rows.values()]
.filter((r) => r.past.assigned || r.live.assigned || r.live_shifts)
.sort((a, b) => a.name.localeCompare(b.name, 'fr')),
}; };
} }
@@ -834,6 +884,14 @@ function runSolver(): SolverRunResult {
}; };
} }
/** Message (organisation, poste) comme l'API : bords retirés, vide = plus
* de message, 2000 caractères au plus. */
function checkMessage(value: string | null | undefined): string | null {
const text = (value ?? '').trim() || null;
if (text && text.length > MESSAGE_MAX_LENGTH) throw new ApiError(422, 'error.generic');
return text;
}
export function createMockApi(): PlanningApi { export function createMockApi(): PlanningApi {
return { return {
editions: { editions: {
@@ -845,6 +903,13 @@ export function createMockApi(): PlanningApi {
if (body.start_date) next.start_date = body.start_date; if (body.start_date) next.start_date = body.start_date;
if (body.end_date) next.end_date = body.end_date; if (body.end_date) next.end_date = body.end_date;
if ('retention_days' in body) next.retention_days = body.retention_days ?? null; if ('retention_days' in body) next.retention_days = body.retention_days ?? null;
if ('volunteer_message' in body) {
const text = checkMessage(body.volunteer_message);
if (text !== next.volunteer_message) {
next.volunteer_message = text;
next.volunteer_message_updated_at = text ? touch() : null;
}
}
if (next.end_date < next.start_date) { if (next.end_date < next.start_date) {
throw new ApiError(422, 'edition.dates_inconsistent'); throw new ApiError(422, 'edition.dates_inconsistent');
} }
@@ -995,6 +1060,8 @@ export function createMockApi(): PlanningApi {
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []), wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
has_photo: false, has_photo: false,
companion_wish_note: null, companion_wish_note: null,
badge_text: null,
badge_count: 1,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null, fee_payment_label: null,
@@ -1039,6 +1106,15 @@ export function createMockApi(): PlanningApi {
...('companion_wish_note' in body ...('companion_wish_note' in body
? { companion_wish_note: body.companion_wish_note ?? null } ? { companion_wish_note: body.companion_wish_note ?? null }
: {}), : {}),
...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}),
...(body.badge_count != null
? (() => {
if (!Number.isInteger(body.badge_count) || body.badge_count < 1 || body.badge_count > 100) {
throw new ApiError(422, 'error.generic');
}
return { badge_count: body.badge_count };
})()
: {}),
...('self_declared_available' in body && body.self_declared_available != null ...('self_declared_available' in body && body.self_declared_available != null
? { self_declared_available: body.self_declared_available } ? { self_declared_available: body.self_declared_available }
: {}), : {}),
@@ -1066,6 +1142,21 @@ export function createMockApi(): PlanningApi {
if (!dataUrl) return null; if (!dataUrl) return null;
return (await fetch(dataUrl)).blob(); return (await fetch(dataUrl)).blob();
}, },
setPhoto: async (_edition, id, file) => {
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
if (!['image/png', 'image/jpeg'].includes(file.type)) {
throw new ApiError(422, 'form.photo_type_not_supported');
}
state.photos.set(id, await fileToDataUrl(file));
Object.assign(v, { has_photo: true, updated_at: touch() });
return delay(v);
},
removePhoto: (_edition, id) => {
const v = state.volunteers.find((x) => x.id === id) ?? notFound();
state.photos.delete(id);
Object.assign(v, { has_photo: false, updated_at: touch() });
return delay(v);
},
exportXlsx: () => delay(MOCK_XLSX_BLOB()), exportXlsx: () => delay(MOCK_XLSX_BLOB()),
exportMecenatReceipts: () => { exportMecenatReceipts: () => {
const paid = state.volunteers.some( const paid = state.volunteers.some(
@@ -1096,6 +1187,8 @@ export function createMockApi(): PlanningApi {
position_location: position?.location ?? null, position_location: position?.location ?? null,
referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null, referent_name: referent ? `${referent.first_name} ${referent.last_name}` : null,
referent_phone: referent?.phone ?? null, referent_phone: referent?.phone ?? null,
position_message: position?.message ?? null,
position_message_updated_at: position?.message_updated_at ?? null,
}; };
}) })
.sort((a, b) => a.starts_at.localeCompare(b.starts_at)); .sort((a, b) => a.starts_at.localeCompare(b.starts_at));
@@ -1147,6 +1240,9 @@ export function createMockApi(): PlanningApi {
account.updated_at = touch(); account.updated_at = touch();
return delay(account); return delay(account);
}, },
// Pas de lien permanent en démo (liens magiques simulés) : rien à
// révoquer, le compte est renvoyé tel quel.
resetLoginLink: (_edition, id) => delay(state.accounts.find((a) => a.id === id) ?? notFound()),
remove: (_edition, id) => { remove: (_edition, id) => {
state.accounts = state.accounts.filter((a) => a.id !== id); state.accounts = state.accounts.filter((a) => a.id !== id);
return delay(undefined); return delay(undefined);
@@ -1302,6 +1398,8 @@ export function createMockApi(): PlanningApi {
wished_companion_ids: [], wished_companion_ids: [],
has_photo: false, has_photo: false,
companion_wish_note: null, companion_wish_note: null,
badge_text: null,
badge_count: 1,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null, fee_payment_label: null,
@@ -1354,6 +1452,8 @@ export function createMockApi(): PlanningApi {
location: body.location ?? null, location: body.location ?? null,
category: body.category, category: body.category,
lead_account_id: body.lead_account_id ?? null, lead_account_id: body.lead_account_id ?? null,
message: null,
message_updated_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1365,6 +1465,13 @@ export function createMockApi(): PlanningApi {
update: (_edition, id, body) => { update: (_edition, id, body) => {
const p = state.positions.find((x) => x.id === id) ?? notFound(); const p = state.positions.find((x) => x.id === id) ?? notFound();
if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id); if ('lead_account_id' in body) checkLeadAccount(body.lead_account_id);
if ('message' in body) {
const text = checkMessage(body.message);
if (text !== p.message) {
p.message = text;
p.message_updated_at = text ? touch() : null;
}
}
Object.assign(p, { Object.assign(p, {
...('name' in body ? { name: body.name } : {}), ...('name' in body ? { name: body.name } : {}),
...('location' in body ? { location: body.location ?? null } : {}), ...('location' in body ? { location: body.location ?? null } : {}),
@@ -1479,6 +1586,8 @@ export function createMockApi(): PlanningApi {
shift_id: body.shift_id, shift_id: body.shift_id,
source: body.source ?? 'MANUAL', source: body.source ?? 'MANUAL',
locked: body.locked ?? false, locked: body.locked ?? false,
attendance: null,
attendance_marked_at: null,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}; };
@@ -1494,6 +1603,13 @@ export function createMockApi(): PlanningApi {
}); });
return delay({ assignment: a, warnings: [] as Violation[] }); return delay({ assignment: a, warnings: [] as Violation[] });
}, },
setAttendance: (_edition, id, body) => {
const a = state.assignments.find((x) => x.id === id) ?? notFound();
a.attendance = body.attendance;
a.attendance_marked_at = body.attendance ? touch() : null;
a.updated_at = touch();
return delay({ ...a });
},
remove: (_edition, id) => { remove: (_edition, id) => {
state.assignments = state.assignments.filter((x) => x.id !== id); state.assignments = state.assignments.filter((x) => x.id !== id);
return delay(undefined); return delay(undefined);
@@ -1558,13 +1674,25 @@ export function createMockApi(): PlanningApi {
return (await fetch(state.badgeTemplate.backImage)).blob(); return (await fetch(state.badgeTemplate.backImage)).blob();
}, },
setFields: (_edition, body: BadgeFieldsSet) => { setFields: (_edition, body: BadgeFieldsSet) => {
// Un même type de champ peut exister une fois par face (recto/verso),
// comme côté back-end.
const seen = new Set<string>(); const seen = new Set<string>();
const hexColor = /^#[0-9a-fA-F]{6}$/;
for (const f of body.fields) { for (const f of body.fields) {
if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type'); const key = `${f.face ?? 'FRONT'}:${f.field_type}`;
seen.add(f.field_type); if (seen.has(key)) throw new ApiError(422, 'badge.duplicate_field_type');
seen.add(key);
if (!hexColor.test(f.color) || (f.bg_color != null && !hexColor.test(f.bg_color))) {
throw new ApiError(422, 'badge.invalid_color');
}
} }
const fields: BadgeFieldRead[] = body.fields.map((f) => ({ const fields: BadgeFieldRead[] = body.fields.map((f) => ({
...f, ...f,
face: f.face ?? 'FRONT',
bg_color: f.bg_color ?? null,
auto_fit: f.auto_fit ?? true,
photo_shape: f.photo_shape ?? 'RECT',
z_order: f.z_order ?? 0,
id: `bf${state.seq++}`, id: `bf${state.seq++}`,
edition_id: state.edition.id, edition_id: state.edition.id,
})); }));
@@ -1772,6 +1900,8 @@ export function createMockApi(): PlanningApi {
wished_companion_ids: [], wished_companion_ids: [],
has_photo: photo !== null, has_photo: photo !== null,
companion_wish_note: companionWishNote, companion_wish_note: companionWishNote,
badge_text: null,
badge_count: 1,
self_declared_available: fields.has('AVAILABLE') ? available : false, self_declared_available: fields.has('AVAILABLE') ? available : false,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'), willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
fee_payment_label: null, fee_payment_label: null,
@@ -1925,6 +2055,25 @@ export function createMockApi(): PlanningApi {
}, },
notifications: { notifications: {
// Pas de serveur SMTP en démo : rien n'est envoyé, comme
// `EMAIL_ENABLED=false` côté serveur.
sendTest: (_edition, body) => {
if (!/^[^\s@]+@[^\s@]+$/.test(body.email.trim())) {
throw new ApiError(422, 'notifications.invalid_email');
}
const assigned = new Set(state.assignments.map((a) => a.volunteer_id));
const sorted = [...state.volunteers].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr'));
const example =
sorted.find((v) => v.id === body.volunteer_id) ?? sorted.find((v) => assigned.has(v.id)) ?? sorted[0];
return delay({
status: 'disabled' as const,
detail: null,
smtp_host: 'localhost:25',
smtp_from: 'noreply@festival.local',
example_volunteer_id: example?.id ?? null,
example_volunteer_name: example ? `${example.first_name} ${example.last_name}`.trim() : '',
});
},
sendPlanning: (_edition, body: PlanningNotificationRequest) => { sendPlanning: (_edition, body: PlanningNotificationRequest) => {
const ids = body.volunteer_ids ?? null; const ids = body.volunteer_ids ?? null;
const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id)); const targets = state.volunteers.filter((v) => ids === null || ids.includes(v.id));
+113 -1
View File
@@ -39,16 +39,25 @@ export interface EditionRead {
start_date: string; start_date: string;
end_date: string; end_date: string;
retention_days: number | null; retention_days: number | null;
/** Message de l'organisation, le même pour tous les bénévoles (écran
* bénévole), et la date de sa dernière modification. */
volunteer_message: string | null;
volunteer_message_updated_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
/** Longueur maximale d'un message (organisation, poste) — comme l'API. */
export const MESSAGE_MAX_LENGTH = 2000;
export interface EditionUpdate { export interface EditionUpdate {
name?: string | null; name?: string | null;
year?: number; year?: number;
start_date?: string; start_date?: string;
end_date?: string; end_date?: string;
retention_days?: number | null; retention_days?: number | null;
/** Vide = plus de message. */
volunteer_message?: string | null;
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -107,6 +116,11 @@ export interface VolunteerRead {
/** Renseignés par le formulaire public d'inscription (voir schema forms). */ /** Renseignés par le formulaire public d'inscription (voir schema forms). */
has_photo: boolean; has_photo: boolean;
companion_wish_note: string | null; companion_wish_note: string | null;
/** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la
* coordination. */
badge_text: string | null;
/** Exemplaires de son badge imprimés sur les planches (1 par défaut). */
badge_count: number;
self_declared_available: boolean; self_declared_available: boolean;
willing_to_pay_fee: boolean; willing_to_pay_fee: boolean;
/** Paiement de la cotisation via SumUp (voir schema forms) — /** Paiement de la cotisation via SumUp (voir schema forms) —
@@ -157,6 +171,9 @@ export interface VolunteerUpdate {
wished_position_ids?: string[] | null; wished_position_ids?: string[] | null;
wished_companion_ids?: string[] | null; wished_companion_ids?: string[] | null;
companion_wish_note?: string | null; companion_wish_note?: string | null;
badge_text?: string | null;
/** 1 à 100. */
badge_count?: number | null;
self_declared_available?: boolean | null; self_declared_available?: boolean | null;
willing_to_pay_fee?: boolean | null; willing_to_pay_fee?: boolean | null;
} }
@@ -177,6 +194,9 @@ export interface VolunteerPlanningEntry {
* rattaché à une fiche bénévole (pas de téléphone à donner alors). */ * rattaché à une fiche bénévole (pas de téléphone à donner alors). */
referent_name: string | null; referent_name: string | null;
referent_phone: string | null; referent_phone: string | null;
/** Message du poste (voir `PositionRead.message`). */
position_message: string | null;
position_message_updated_at: string | null;
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -282,6 +302,10 @@ export interface PositionRead {
location: string | null; location: string | null;
category: PositionCategory; category: PositionCategory;
lead_account_id: string | null; lead_account_id: string | null;
/** Message du poste (consignes…), saisi par son référent, montré aux
* bénévoles affectés au poste. */
message: string | null;
message_updated_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -298,6 +322,8 @@ export interface PositionUpdate {
location?: string | null; location?: string | null;
category?: PositionCategory; category?: PositionCategory;
lead_account_id?: string | null; lead_account_id?: string | null;
/** Vide = plus de message. */
message?: string | null;
} }
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -344,10 +370,20 @@ export interface AssignmentRead {
shift_id: string; shift_id: string;
source: AssignmentSource; source: AssignmentSource;
locked: boolean; locked: boolean;
/** Pointage du référent (`null` = pas encore pointé) et son heure. */
attendance: AttendanceStatus | null;
attendance_marked_at: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export type AttendanceStatus = 'PRESENT' | 'ABSENT';
export interface AttendanceUpdate {
/** `null` = annuler le pointage. */
attendance: AttendanceStatus | null;
}
export interface AssignmentCreate { export interface AssignmentCreate {
volunteer_id: string; volunteer_id: string;
shift_id: string; shift_id: string;
@@ -419,11 +455,26 @@ export interface MatrixResponse {
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Badges (planches PDF) // // Badges (planches PDF) //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
export type BadgeFieldType = 'FULL_NAME' | 'POSITIONS' | 'CATEGORY' | 'ROLE'; /** `PHOTO` : photo du bénévole ajustée au cadre ; `FREE_TEXT` : texte libre
* de sa fiche (`VolunteerRead.badge_text`), saisi par la coordination. */
export type BadgeFieldType =
| 'FULL_NAME'
| 'POSITIONS'
| 'CATEGORY'
| 'ROLE'
| 'PHOTO'
| 'FREE_TEXT';
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT'; export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
/** Face du badge où se trouve un champ : chaque type de champ peut exister
* une fois par face. */
export type BadgeFace = 'FRONT' | 'BACK';
/** Forme du cadre de la photo (`PHOTO`). */
export type BadgePhotoShape = 'RECT' | 'OVAL';
export interface BadgeFieldIn { export interface BadgeFieldIn {
field_type: BadgeFieldType; field_type: BadgeFieldType;
/** Défaut `FRONT`. */
face: BadgeFace;
x_mm: number; x_mm: number;
y_mm: number; y_mm: number;
width_mm: number; width_mm: number;
@@ -432,6 +483,19 @@ export interface BadgeFieldIn {
align: BadgeAlign; align: BadgeAlign;
bold: boolean; bold: boolean;
color: string; color: string;
/** Fond de la zone du champ (`#rrggbb`), `null` = transparent. */
bg_color: string | null;
/** Réduit la taille du texte (par pas de 0,5 pt, jusqu'à 4 pt) quand il
* ne tient pas dans le cadre. */
auto_fit: boolean;
/** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */
photo_shape: BadgePhotoShape;
/** Ordre d'empilement sur la face : un champ recouvre ceux d'un
* `z_order` inférieur (photo dessous à égalité). */
z_order: number;
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
text?: string | null;
} }
export interface BadgeFieldsSet { export interface BadgeFieldsSet {
@@ -540,6 +604,28 @@ export interface PlanningNotificationResult {
recipients: string[]; recipients: string[];
} }
/** Résultat de l'email de test (`POST .../notifications/test`). */
export interface TestEmailResult {
status: 'sent' | 'disabled' | 'connection_failed' | 'rejected';
/** Message du serveur SMTP en cas d'échec. */
detail: string | null;
smtp_host: string;
smtp_from: string;
/** Bénévole dont les données ont rempli le modèle (`null` : exemple
* fictif). */
example_volunteer_id: string | null;
example_volunteer_name: string;
}
/** Email de test : le récapitulatif de planning en cours d'édition, rempli
* avec les données d'un bénévole, envoyé à `email`. */
export interface TestEmailRequest {
email: string;
subject?: string | null;
body?: string | null;
volunteer_id?: string | null;
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Formulaire public d'inscription // // Formulaire public d'inscription //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
@@ -777,4 +863,30 @@ export interface EditionStats {
by_position: StatsNeedPosition[]; by_position: StatsNeedPosition[];
volunteers_with_assignment: number; volunteers_with_assignment: number;
}; };
attendance: StatsAttendance;
}
/** Pointage d'un ensemble d'affectations : `assigned` = `present` +
* `absent` + `unmarked`. */
export interface StatsAttendanceCounts {
assigned: number;
present: number;
absent: number;
unmarked: number;
}
/** Présents / absents pointés, sur les créneaux terminés (`past`) et en
* cours (`live`) à `as_of` ; les créneaux à venir ne comptent pas. */
export interface StatsAttendance {
as_of: string;
past: StatsAttendanceCounts;
live: StatsAttendanceCounts;
live_shifts: number;
by_position: Array<{
position_id: string;
name: string;
past: StatsAttendanceCounts;
live: StatsAttendanceCounts;
live_shifts: number;
}>;
} }
+15 -1
View File
@@ -57,6 +57,9 @@ import type {
VolunteerRead, VolunteerRead,
VolunteerUpdate, VolunteerUpdate,
EditionStats, EditionStats,
AttendanceUpdate,
TestEmailRequest,
TestEmailResult,
} from './schema'; } from './schema';
/** /**
@@ -88,8 +91,13 @@ export interface PlanningApi {
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */ /** Planning enrichi (poste, lieu, référent) — écran bénévole. */
planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>; planning(editionId: string, id: string): Promise<VolunteerPlanningEntry[]>;
/** Photo transmise via le formulaire public, `null` si absente. */ /** Photo transmise via le formulaire public (ou ajoutée par la
* coordination), `null` si absente. */
photoBlob(editionId: string, id: string): Promise<Blob | null>; photoBlob(editionId: string, id: string): Promise<Blob | null>;
/** Ajoute ou remplace la photo (COORDINATION) — PNG/JPEG. */
setPhoto(editionId: string, id: string, file: File): Promise<VolunteerRead>;
/** Retire la photo (COORDINATION). */
removePhoto(editionId: string, id: string): Promise<VolunteerRead>;
/** Export Excel de la liste des bénévoles (COORDINATION). */ /** Export Excel de la liste des bénévoles (COORDINATION). */
exportXlsx(editionId: string): Promise<Blob>; exportXlsx(editionId: string): Promise<Blob>;
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF /** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF
@@ -103,6 +111,9 @@ export interface PlanningApi {
/** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */ /** Changement de rôle seulement (LEAD ↔ VOLUNTEER) — voir `AccountUpdate`. */
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>; update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
/** Révoque le lien de connexion permanent (récapitulatif de planning) :
* les liens déjà envoyés cessent de fonctionner. */
resetLoginLink(editionId: string, id: string): Promise<AccountRead>;
}; };
imports: { imports: {
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */ /** Phase 1 : lit le fichier, propose un mapping des colonnes. */
@@ -135,6 +146,7 @@ export interface PlanningApi {
create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>; create(editionId: string, body: AssignmentCreate): Promise<AssignmentWriteResult>;
update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>; update(editionId: string, id: string, body: AssignmentUpdate): Promise<AssignmentWriteResult>;
remove(editionId: string, id: string): Promise<void>; remove(editionId: string, id: string): Promise<void>;
setAttendance(editionId: string, id: string, body: AttendanceUpdate): Promise<AssignmentRead>;
validate( validate(
editionId: string, editionId: string,
body: AssignmentValidateRequest, body: AssignmentValidateRequest,
@@ -217,6 +229,8 @@ export interface PlanningApi {
editionId: string, editionId: string,
body: PlanningNotificationRequest, body: PlanningNotificationRequest,
): Promise<PlanningNotificationResult>; ): Promise<PlanningNotificationResult>;
/** Email de test, envoyé tout de suite (COORDINATION). */
sendTest(editionId: string, body: TestEmailRequest): Promise<TestEmailResult>;
}; };
auth: { auth: {
login(editionId: string, body: LoginRequest): Promise<TokenResponse>; login(editionId: string, body: LoginRequest): Promise<TokenResponse>;
+112 -32
View File
@@ -1,4 +1,5 @@
import { NavLink, Outlet } from 'react-router-dom'; import { useEffect, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { API_MODE } from '@/api'; import { API_MODE } from '@/api';
import { useVolunteers } from '@/api/hooks'; import { useVolunteers } from '@/api/hooks';
@@ -17,20 +18,30 @@ export function AppShell() {
const initials = account const initials = account
? account.email.slice(0, 2).toUpperCase() ? account.email.slice(0, 2).toUpperCase()
: '··'; : '··';
const hasNav = tabs.length > 1;
// Sous `lg:`, les onglets ne tiennent plus à côté du titre : ils passent
// sur une seconde ligne (tablette) ou dans un menu déroulant (mobile),
// refermé à chaque changement d'écran.
const [menuOpen, setMenuOpen] = useState(false);
const { pathname } = useLocation();
useEffect(() => setMenuOpen(false), [pathname]);
const currentTab = tabs.find((tab) => tab.to === pathname);
return ( return (
<div className="flex min-h-screen flex-col"> <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"> <header className="sticky top-0 z-30 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="flex items-stretch">
<div className="font-display text-[14px] font-black tracking-tight"> <div className="flex min-w-0 flex-col justify-center border-r-[3px] border-ink px-3 py-2 sm:px-4">
<div className="truncate font-display text-[13px] font-black tracking-tight sm:text-[14px]">
{t('header.title')} {t('header.title')}
</div> </div>
{isCoordinator ? ( {isCoordinator ? (
<div className="font-mono text-[9.5px] font-medium opacity-70"> <div className="truncate font-mono text-[9.5px] font-medium opacity-70">
{t('header.meta', { count: volunteers.data?.length ?? 0 })} {t('header.meta', { count: volunteers.data?.length ?? 0 })}
</div> </div>
) : ( ) : (
<div className="font-mono text-[9.5px] font-medium opacity-70"> <div className="truncate font-mono text-[9.5px] font-medium opacity-70">
{account ? t(roleLabelKey(account.role)) : ''} {account ? t(roleLabelKey(account.role)) : ''}
</div> </div>
)} )}
@@ -38,40 +49,22 @@ export function AppShell() {
{/* Rien à choisir avec un seul écran (bénévole, référent) : la {/* Rien à choisir avec un seul écran (bénévole, référent) : la
barre d'onglets n'a de sens que pour la coordination. */} barre d'onglets n'a de sens que pour la coordination. */}
<nav className={cx('flex overflow-x-auto', tabs.length <= 1 && 'hidden')}> {hasNav ? (
<nav className="hidden min-w-0 overflow-x-auto lg:flex">
{tabs.map((tab) => ( {tabs.map((tab) => (
<NavLink <TabLink key={tab.to} tab={tab} />
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> </nav>
) : null}
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]"> <div className="ml-auto flex flex-none items-center gap-2 border-l-[3px] border-ink px-2 sm:gap-[10px] sm:px-[14px]">
{API_MODE === 'mock' ? ( {API_MODE === 'mock' ? (
<div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider sm:block"> <div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider xl:block">
{t('header.mockMode')} {t('header.mockMode')}
</div> </div>
) : null} ) : null}
<div <div
className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold" className="hidden h-[30px] w-[30px] flex-none items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold sm:flex"
title={account?.email} title={account?.email}
> >
{initials} {initials}
@@ -79,11 +72,68 @@ export function AppShell() {
<button <button
type="button" type="button"
onClick={logout} onClick={logout}
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange" className="cursor-pointer border-2 border-ink bg-cream px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange sm:px-[10px]"
> >
{t('auth.logout')} {t('auth.logout')}
</button> </button>
{hasNav ? (
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
aria-expanded={menuOpen}
aria-label={t(menuOpen ? 'header.closeMenu' : 'header.openMenu')}
className="flex h-[30px] w-[34px] flex-none cursor-pointer items-center justify-center border-2 border-ink bg-ink font-display text-[15px] font-black text-yellow md:hidden"
>
{menuOpen ? '✕' : '☰'}
</button>
) : null}
</div> </div>
</div>
{hasNav ? (
<>
{/* Tablette : seconde ligne, onglets à parts égales. */}
<nav className="hidden overflow-x-auto border-t-[3px] border-ink md:flex lg:hidden">
{tabs.map((tab) => (
<TabLink key={tab.to} tab={tab} fill />
))}
</nav>
{/* Mobile : écran courant rappelé + menu déroulant. */}
<button
type="button"
onClick={() => setMenuOpen((open) => !open)}
className="flex w-full cursor-pointer items-center gap-2 border-t-[3px] border-ink bg-ink px-3 py-[7px] text-left font-display text-[11px] font-extrabold uppercase tracking-wide text-cream md:hidden"
>
{currentTab ? (
<span className="font-mono text-[8.5px] font-medium text-yellow">{currentTab.num}</span>
) : null}
<span className="flex-1 truncate">
{currentTab ? t(currentTab.labelKey) : t('header.openMenu')}
</span>
<span aria-hidden className="text-yellow">{menuOpen ? '▲' : '▼'}</span>
</button>
{menuOpen ? (
<nav className="flex flex-col border-t-[3px] border-ink bg-yellow md:hidden">
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className={({ isActive }) =>
cx(
'flex items-center gap-3 border-b-2 border-ink px-4 py-3 last:border-b-0',
'font-display text-[12px] font-bold uppercase tracking-wide',
isActive ? 'bg-ink text-yellow' : 'hover:bg-yellow-hover',
)
}
>
<span className="font-mono text-[9px] font-medium opacity-60">{tab.num}</span>
<span>{t(tab.labelKey)}</span>
</NavLink>
))}
</nav>
) : null}
</>
) : null}
</header> </header>
<main className="flex flex-1 flex-col"> <main className="flex flex-1 flex-col">
@@ -92,3 +142,33 @@ export function AppShell() {
</div> </div>
); );
} }
function TabLink({
tab,
fill = false,
}: {
tab: { to: string; num: string; labelKey: string };
fill?: boolean;
}) {
return (
<NavLink
to={tab.to}
className={({ isActive }) =>
cx(
'relative flex flex-none flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4 last:border-r-0',
'font-display text-[11.5px] font-bold uppercase tracking-wide',
fill ? 'min-w-[104px] flex-1 py-2' : 'min-w-[112px]',
isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
)
}
>
{({ isActive }) => (
<>
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
<span className="whitespace-nowrap">{t(tab.labelKey)}</span>
{isActive ? <span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" /> : null}
</>
)}
</NavLink>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { ApiError } from '@/api';
import { MESSAGE_MAX_LENGTH } from '@/api/schema';
import { Btn } from '@/components/ui';
import { cx } from '@/lib/cx';
import { messageDateLabel } from '@/lib/slotTimeline';
import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n';
/**
* Saisie d'un message montré aux bénévoles — celui de l'organisation
* (coordination, `ConfigMessage`) ou celui d'un poste (référent,
* `MobileLeadPage`). Enregistré sur demande ; vider le champ retire le
* message.
*/
export function MessageEditor({
value,
updatedAt,
placeholder,
saving,
error,
onSave,
dark = false,
}: {
value: string | null;
updatedAt: string | null;
placeholder: string;
saving: boolean;
error: unknown;
onSave: (text: string) => Promise<unknown>;
/** Sur fond sombre (en-tête de l'écran référent). */
dark?: boolean;
}) {
const [draft, setDraft] = useState(value ?? '');
const [saved, setSaved] = useState(false);
const now = useNowMinutes();
// Le message enregistré change (autre poste choisi, autre appareil) :
// le brouillon repart de lui.
useEffect(() => setDraft(value ?? ''), [value]);
const dirty = draft.trim() !== (value ?? '');
const tooLong = draft.length > MESSAGE_MAX_LENGTH;
return (
<div className="flex flex-col gap-[7px]">
<textarea
value={draft}
onChange={(e) => {
setDraft(e.target.value);
setSaved(false);
}}
rows={4}
placeholder={placeholder}
className="w-full resize-y border-2 border-ink bg-cream p-2 font-display text-[14px] font-semibold leading-snug text-ink"
/>
<div className="flex flex-wrap items-center gap-2">
<Btn
disabled={!dirty || tooLong || saving}
onClick={() => {
void onSave(draft.trim()).then(() => setSaved(true), () => undefined);
}}
>
{saving ? t('message.saving') : draft.trim() ? t('message.save') : t('message.remove')}
</Btn>
<span
className={cx(
'font-mono text-[10px] leading-snug',
tooLong ? 'font-bold text-orange' : 'opacity-65',
)}
>
{saved && !dirty
? t('message.saved')
: value && updatedAt && !dirty
? t('message.updatedAt', { when: messageDateLabel(updatedAt, now) })
: t('message.length', { count: draft.length, max: MESSAGE_MAX_LENGTH })}
</span>
</div>
{error ? (
<div className={cx('font-mono text-[10.5px] font-bold', dark ? 'text-orange' : 'text-orange-dark')}>
{error instanceof ApiError ? t(error.code) : t('error.generic')}
</div>
) : null}
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
import { t } from '@/i18n';
/** Repère horizontal « MAINTENANT », à insérer dans une liste triée
* chronologiquement (créneaux d'un bénévole, créneaux d'un poste…) au point
* correspondant à l'heure réelle. Purement visuel. */
export function NowLine() {
return (
<div className="flex items-center gap-2 px-1" aria-hidden="true">
<div className="h-[3px] flex-1 bg-orange" />
<div className="bg-orange px-[7px] py-[3px] font-mono text-[10px] font-bold text-white">
{t('common.now')}
</div>
<div className="h-[3px] flex-1 bg-orange" />
</div>
);
}
+262 -17
View File
@@ -5,7 +5,6 @@
"action.remove": "Retirer", "action.remove": "Retirer",
"action.add": "Ajouter", "action.add": "Ajouter",
"app.loading": "Chargement…", "app.loading": "Chargement…",
"common.now": "MAINTENANT",
"app.error.generic": "Une erreur est survenue", "app.error.generic": "Une erreur est survenue",
"app.error.reference": "Référence à communiquer : {id}", "app.error.reference": "Référence à communiquer : {id}",
"error.generic": "Erreur inattendue", "error.generic": "Erreur inattendue",
@@ -54,6 +53,8 @@
"header.title": "GESTION DES BÉNÉVOLES", "header.title": "GESTION DES BÉNÉVOLES",
"header.meta": "BÉNÉVOLAT · {count} INSCRITS", "header.meta": "BÉNÉVOLAT · {count} INSCRITS",
"header.mockMode": "DONNÉES DE DÉMO", "header.mockMode": "DONNÉES DE DÉMO",
"header.openMenu": "Menu",
"header.closeMenu": "Fermer le menu",
"tab.grille": "Grille des besoins", "tab.grille": "Grille des besoins",
"tab.volunteers": "Vue par bénévole", "tab.volunteers": "Vue par bénévole",
"tab.mobileVolunteer": "Bénévole · mobile", "tab.mobileVolunteer": "Bénévole · mobile",
@@ -105,28 +106,89 @@
"volunteers.filter.all": "TOUS", "volunteers.filter.all": "TOUS",
"volunteers.filter.unassigned": "SANS AFFECTATION", "volunteers.filter.unassigned": "SANS AFFECTATION",
"volunteers.legend.assigned": "AFFECTATION", "volunteers.legend.assigned": "AFFECTATION",
"volunteers.legend.overlap": "DEUX POSTES EN MÊME TEMPS",
"volunteers.legend.now": "MAINTENANT",
"volunteers.bar.title": "{position} · {time}",
"volunteers.bar.overlapTitle": "{position} · {time} — en même temps qu'un autre poste",
"volunteers.search.placeholder": "Rechercher un bénévole ou un poste…",
"volunteers.search.label": "Rechercher un bénévole ou un poste",
"volunteers.search.empty": "Aucun bénévole ni poste ne correspond à « {query} ».",
"volunteer.unnamed": "Sans nom",
"volunteers.count": "{count} BÉNÉVOLES", "volunteers.count": "{count} BÉNÉVOLES",
"volunteers.totalHours": "TOTAL H", "volunteers.totalHours": "TOTAL H",
"volunteers.sort.label": "TRIER PAR",
"volunteers.sort.name": "NOM",
"volunteers.sort.hoursAsc": "HEURES ↑",
"volunteers.sort.hoursDesc": "HEURES ↓",
"volunteers.sort.toggle": "Trier par total d'heures (croissant / décroissant)",
"volunteers.name": "NOM", "volunteers.name": "NOM",
"volunteers.cumulative": "CUMUL", "volunteers.cumulative": "CUMUL",
"volunteers.badge.unassigned": "○ OUBLIÉ", "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.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.",
"volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».", "volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
"volunteers.footnote": "Oublié = aucune affectation.", "volunteers.footnote": "Oublié = aucune affectation.",
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE", "mobileVol.greeting": "Salut {name}",
"mobileVol.greeting": "SALUT {name}", "mobileVol.summary.one": "{count} créneau · {duration} sur {days} jour",
"mobileVol.slots": "{count} CRÉNEAUX", "mobileVol.summary.other": "{count} créneaux · {duration} sur {days} jour(s)",
"mobileVol.hours": "{count} HEURES", "mobileVol.summary.none": "Pas encore de créneau.",
"mobileVol.location": "📍 {location}", "mobileVol.featured.now": "● EN COURS",
"mobileVol.referent": "👤 Responsable : {name}", "mobileVol.featured.next": "PROCHAIN CRÉNEAU",
"mobileVol.phone": "📞 {phone}", "mobileVol.featured.overlap": "⚠ Tu es aussi sur {position} de {start} à {end}. Vérifie avec la coordination.",
"mobileVol.featured.referent": "TON RÉFÉRENT",
"mobileVol.featured.call": "APPELER",
"mobileVol.allDone": "Festival terminé. Merci {name}, tu as assuré !",
"mobileVol.day.one": "{count} créneau · {duration}",
"mobileVol.day.other": "{count} créneaux · {duration}",
"mobileVol.slot.done": "TERMINÉ",
"mobileVol.slot.now": "EN COURS",
"mobileVol.slot.referent": " · réf. {name}",
"mobileVol.slot.overlap": "⚠ Chevauche {position} de {start} à {end}. Vérifie avec la coordination où tu dois être.",
"slot.duration.hours": "{h} h",
"slot.duration.hoursMinutes": "{h} h {m}",
"slot.countdown.endsAt": "FIN À {time}",
"slot.countdown.minutes": "DANS {count} MIN",
"slot.countdown.in": "DANS {duration}",
"slot.countdown.days": "DANS {count} J",
"mobileVol.orgMessage.title": "MESSAGE DE L'ORGANISATION",
"mobileVol.orgMessage.signature": "Coordination · {when}",
"message.save": "Enregistrer",
"message.remove": "Retirer le message",
"message.saving": "Enregistrement…",
"message.saved": "✓ Enregistré",
"message.updatedAt": "Publié {when}",
"message.length": "{count} / {max} caractères",
"message.date.today": "aujourd'hui {time}",
"message.date.yesterday": "hier {time}",
"message.date.other": "{day} · {time}",
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.", "mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
"mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.", "mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
"mobileLead.kicker": "RÉFÉRENT DE POSTE", "mobileLead.kicker": "Ton équipe",
"mobileLead.positionsCount": "{count} POSTE(S) DONT VOUS ÊTES RÉFÉRENT", "mobileLead.kickerNamed": "{name} · ton équipe",
"mobileLead.status.done": "terminé",
"mobileLead.status.now": "en cours",
"mobileLead.status.next": "à venir",
"mobileLead.count.present": "PRÉSENTS",
"mobileLead.count.waiting": "ATTENDUS",
"mobileLead.count.absent": "ABSENTS",
"mobileLead.absentAlert.one": "⚠ 1 absent sur le créneau — il manque du monde",
"mobileLead.absentAlert.other": "⚠ {count} absents sur le créneau — il manque du monde",
"mobileLead.roll": "POINTAGE · {count} BÉNÉVOLE(S)",
"mobileLead.when.now": "Créneau en cours",
"mobileLead.when.next": "Début {time}",
"mobileLead.when.done": "Créneau terminé",
"mobileLead.call": "APPEL",
"mobileLead.callLabel": "Appeler {name}",
"mobileLead.message.title": "MESSAGE À TON ÉQUIPE",
"mobileLead.message.audience": "{count} bénévole(s) le verront",
"mobileLead.message.help": "Consignes du poste (tenue, matériel, point de rendez-vous…), affichées sur l'écran de chaque bénévole affecté au poste.",
"mobileLead.message.placeholder": "Ex. Gilet jaune fourni à la base bénévoles. Rendez-vous 10 min avant le début.",
"mobileLead.view.team": "Mon équipe",
"mobileLead.view.planning": "Mon planning · {count}",
"mobileLead.you": "TOI",
"mobileLead.markedAt": "pointé à {time}",
"mobileLead.attendanceError": "Le pointage n'a pas pu être enregistré (réseau ?). Réessaie.",
"mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.", "mobileLead.noPosition": "Vous n'êtes actuellement référent d'aucun poste.",
"mobileLead.noShift": "Aucun créneau sur ce poste.", "mobileLead.noShift": "Aucun créneau sur ce poste.",
"mobileLead.present": "{present}/{total} POINTÉS PRÉSENTS",
"mobileLead.badgePresent": "✓ PRÉSENT", "mobileLead.badgePresent": "✓ PRÉSENT",
"mobileLead.badgeAbsent": "✕ ABSENT", "mobileLead.badgeAbsent": "✕ ABSENT",
"mobileLead.markPresent": "PRÉSENT", "mobileLead.markPresent": "PRÉSENT",
@@ -136,6 +198,31 @@
"config.reserved": "RÉSERVÉ À LA COORDINATION", "config.reserved": "RÉSERVÉ À LA COORDINATION",
"config.tab.grid": "POSTES & CRÉNEAUX", "config.tab.grid": "POSTES & CRÉNEAUX",
"config.tab.volunteers": "BÉNÉVOLES & IMPORT", "config.tab.volunteers": "BÉNÉVOLES & IMPORT",
"config.mail.test.title": "Tester l'envoi d'emails",
"config.mail.test.help": "Envoie tout de suite à l'adresse de votre choix le récapitulatif ci-contre tel que les bénévoles le recevront : sujet (précédé de « [Test] ») et texte en cours de saisie, variables remplies avec le planning d'un bénévole. Le lien de connexion y mène à la page de connexion, sans ouvrir le compte de ce bénévole.",
"config.mail.test.example": "Rempli avec le planning de",
"config.mail.test.exampleAuto": "Automatique (premier bénévole avec un créneau)",
"config.mail.test.withLink": "Avec le lien de connexion (comme « Envoyer avec lien de connexion »)",
"config.mail.test.filledWith": "Contenu rempli avec le planning de {name}.",
"config.mail.test.filledWithExample": "Aucun bénévole : contenu rempli avec un exemple fictif.",
"config.mail.test.email": "Adresse de test",
"config.mail.test.placeholder": "moi@exemple.fr",
"config.mail.test.send": "Envoyer un email de test",
"config.mail.test.sending": "Envoi en cours…",
"config.mail.test.result.sent": "✓ Email envoyé. Vérifiez la boîte de réception (et les indésirables).",
"config.mail.test.result.disabled": "L'envoi d'emails est désactivé sur ce serveur (EMAIL_ENABLED=false) : rien n'a été envoyé.",
"config.mail.test.result.connection_failed": "✕ Connexion au serveur d'envoi impossible (adresse, port ou identifiants SMTP).",
"config.mail.test.result.rejected": "✕ Le serveur d'envoi a refusé l'email (souvent : expéditeur non autorisé).",
"config.mail.test.serverSaid": "Réponse du serveur : {detail}",
"config.mail.test.config": "Serveur {host} · expéditeur {from}",
"notifications.invalid_email": "Adresse email invalide.",
"config.tab.message": "MESSAGE AUX BÉNÉVOLES",
"config.message.orgTitle": "Message de l'organisation",
"config.message.orgHelp": "Le même pour tous les bénévoles, affiché en bas de leur écran planning. Laissez vide pour ne rien afficher.",
"config.message.orgPlaceholder": "Ex. Badges et t-shirts à récupérer à l'accueil bénévoles dès 17:00.",
"config.message.positionsTitle": "Messages des postes",
"config.message.positionsHelp": "Saisis par chaque référent depuis son écran et montrés aux bénévoles du poste. Vous pouvez aussi les modifier ici.",
"config.message.none": "Pas de message",
"config.tab.mail": "MESSAGES", "config.tab.mail": "MESSAGES",
"config.tab.rules": "RÈGLES & ÉDITION", "config.tab.rules": "RÈGLES & ÉDITION",
"config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX", "config.grid.positionsHeader": "POSTES · {count} CRÉNEAUX",
@@ -176,7 +263,7 @@
"config.mail.subject": "OBJET", "config.mail.subject": "OBJET",
"config.mail.body": "CORPS DU MESSAGE", "config.mail.body": "CORPS DU MESSAGE",
"config.mail.variables": "VARIABLES DISPONIBLES", "config.mail.variables": "VARIABLES DISPONIBLES",
"config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère un lien de connexion personnel.", "config.mail.variablesHint": "Cliquer une variable l'ajoute au corps. {lien_connexion} insère le lien de connexion directe permanent du bénévole (un clic suffit, sans limite de durée ; crée son accès s'il n'en a pas encore) — révocable depuis sa fiche. {creneaux} insère le tableau de ses créneaux.",
"config.mail.send": "ENVOYER LE RÉCAPITULATIF", "config.mail.send": "ENVOYER LE RÉCAPITULATIF",
"config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION", "config.mail.sendWithLink": "ENVOYER AVEC LIEN DE CONNEXION",
"config.mail.sending": "ENVOI EN COURS…", "config.mail.sending": "ENVOI EN COURS…",
@@ -228,12 +315,27 @@
"config.vol.field.email": "Email (lien magique)", "config.vol.field.email": "Email (lien magique)",
"config.vol.field.phone": "Téléphone", "config.vol.field.phone": "Téléphone",
"config.vol.field.comment": "Commentaire", "config.vol.field.comment": "Commentaire",
"config.vol.field.badgeText": "Texte du badge",
"config.vol.field.badgeCount": "Nombre de badges",
"config.vol.field.badgeCount.help": "Nombre d'exemplaires de son badge imprimés sur les planches (1 à 100). 1 par défaut.",
"config.vol.field.badgeText.help": "Remplace, pour ce bénévole seulement, le texte libre commun saisi dans l'écran Badges. Vide = texte commun.",
"config.vol.photo.title": "Photo",
"config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.",
"config.vol.photo.none": "Aucune photo",
"config.vol.photo.add": "Ajouter une photo",
"config.vol.photo.replace": "Remplacer la photo",
"config.vol.photo.remove": "Retirer",
"config.vol.photo.removeConfirm": "Retirer la photo de ce bénévole ?",
"config.vol.access.title": "Accès de connexion", "config.vol.access.title": "Accès de connexion",
"config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.", "config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : il faut un accès donné ici, ou l'envoi d'un récapitulatif de planning avec {lien_connexion} (qui en crée un).",
"config.vol.access.grant": "Donner un accès de connexion", "config.vol.access.grant": "Donner un accès de connexion",
"config.vol.access.active": "Accès actif : {email}", "config.vol.access.active": "Accès actif : {email}",
"config.vol.access.revoke": "Révoquer", "config.vol.access.revoke": "Révoquer",
"config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?", "config.vol.access.revokeConfirm": "Révoquer l'accès de connexion de ce bénévole ?",
"config.vol.access.resetLink": "Renouveler le lien de connexion",
"config.vol.access.resetLinkHint": "Le lien de connexion des récapitulatifs de planning est permanent. Le renouveler rend inutilisables tous les liens déjà envoyés (email transféré, téléphone perdu…) ; le prochain récapitulatif en portera un nouveau.",
"config.vol.access.resetLinkConfirm": "Rendre inutilisables les liens de connexion déjà envoyés à ce bénévole ?",
"config.vol.access.resetLinkDone": "✓ Anciens liens révoqués",
"config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.", "config.vol.access.needEmail": "Renseignez un email ci-dessus pour pouvoir donner un accès.",
"config.vol.field.referentPositions": "Référent des postes", "config.vol.field.referentPositions": "Référent des postes",
"config.vol.referentHint": "Cocher un poste crée ou met à niveau son accès de connexion en référent : à sa prochaine connexion, il verra l'écran référent (son équipe, le pointage) au lieu de l'écran bénévole. Décocher retire seulement le poste, sans jamais rétrograder l'accès automatiquement.", "config.vol.referentHint": "Cocher un poste crée ou met à niveau son accès de connexion en référent : à sa prochaine connexion, il verra l'écran référent (son équipe, le pointage) au lieu de l'écran bénévole. Décocher retire seulement le poste, sans jamais rétrograder l'accès automatiquement.",
@@ -242,7 +344,6 @@
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE", "config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
"config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.", "config.vol.addHint": "Renseigner un email ne donne pas d'accès à lui seul : une fois le bénévole créé, utilisez « Donner un accès de connexion » sur sa fiche.",
"config.vol.create": "CRÉER LE BÉNÉVOLE", "config.vol.create": "CRÉER LE BÉNÉVOLE",
"config.vol.requiredNames": "Le prénom et le nom sont obligatoires.",
"config.vol.wiz.title": "IMPORTER UN FICHIER", "config.vol.wiz.title": "IMPORTER UN FICHIER",
"config.vol.wiz.step1": "1 · FICHIER", "config.vol.wiz.step1": "1 · FICHIER",
"config.vol.wiz.step2": "2 · COLONNES", "config.vol.wiz.step2": "2 · COLONNES",
@@ -338,18 +439,29 @@
"badges.tab.customize": "PERSONNALISER", "badges.tab.customize": "PERSONNALISER",
"badges.tab.generate": "GÉNÉRER", "badges.tab.generate": "GÉNÉRER",
"badges.front": "Recto", "badges.front": "Recto",
"badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.", "badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle, photo, texte libre). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.",
"badges.back": "Verso", "badges.back": "Verso",
"badges.back.help": "Une seule image, la même pour tous les badges de cette édition — ajustée au format défini par le recto.", "badges.back.help": "Une seule image, la même pour tous les badges de cette édition — ajustée au format défini par le recto.",
"badges.fields": "Champs du recto", "badges.fields": "Champs du recto",
"badges.field.fullName": "Prénom + nom", "badges.field.fullName": "Prénom + nom",
"badges.field.fullName.placeholder": "Prénom Nom", "badges.field.fullName.placeholder": "Prénom Nom",
"badges.field.positions": "Poste(s) affecté(s)", "badges.field.positions": "Créneaux affectés (tableau)",
"badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)", "badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
"badges.field.positions.sample1.day": "Vendredi 10",
"badges.field.positions.sample1.rows": "18:00–22:00|Bar central",
"badges.field.positions.sample2.day": "Samedi 11",
"badges.field.positions.sample2.rows": "09:00–13:00|Accueil\n23:00–01:00|Bar central",
"badges.field.category": "Catégorie", "badges.field.category": "Catégorie",
"badges.field.category.placeholder": "Bar", "badges.field.category.placeholder": "Bar",
"badges.field.role": "Rôle (référent / bénévole)", "badges.field.role": "Rôle (référent / bénévole)",
"badges.field.role.placeholder": "Référent", "badges.field.role.placeholder": "Référent",
"badges.field.photo": "Photo du bénévole",
"badges.field.photo.placeholder": "Photo",
"badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
"badges.field.freeText": "Texte libre",
"badges.field.freeText.placeholder": "Accès backstage",
"badges.field.freeText.help": "Texte saisi ci-dessous, imprimé sur tous les badges. Pour un bénévole en particulier, le « Texte du badge » de sa fiche (Configuration › Bénévoles) le remplace.",
"badges.field.freeText.input": "Texte à imprimer (ex. STAFF 2026)",
"badges.field.size": "Taille", "badges.field.size": "Taille",
"badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.", "badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
"badges.size.width": "Largeur du badge (mm)", "badges.size.width": "Largeur du badge (mm)",
@@ -362,13 +474,129 @@
"badges.saved": "Enregistré ✓", "badges.saved": "Enregistré ✓",
"badges.generate.title": "Génération de la planche PDF", "badges.generate.title": "Génération de la planche PDF",
"badges.generate.all": "Tous les bénévoles ({count})", "badges.generate.all": "Tous les bénévoles ({count})",
"badges.generate.total": "{count} badge(s) à imprimer",
"badges.generate.pick": "Choisir ({count} sélectionné(s))", "badges.generate.pick": "Choisir ({count} sélectionné(s))",
"badges.generate.action": "Générer le PDF", "badges.generate.action": "Générer le PDF",
"badges.generating": "Génération…", "badges.generating": "Génération…",
"badges.face.front": "Recto",
"badges.face.back": "Verso",
"badges.face.front.short": "recto",
"badges.face.back.short": "verso",
"badges.dims": "{width} × {height} mm",
"badges.drop.front": "Déposez ici le visuel du recto (PNG ou JPEG)",
"badges.drop.back": "Déposez ici le visuel du verso (PNG ou JPEG)",
"badges.format.title": "Format du badge",
"badges.format.current": "Format actuel",
"badges.preset.cr80Portrait": "Carte CR80 portrait",
"badges.preset.cr80Landscape": "Carte CR80 paysage",
"badges.preset.a7": "A7 tour de cou",
"badges.fieldsOf": "Champs du {face}",
"badges.fieldsShown": "{count} affiché(s)",
"badges.layer.toggle": "Afficher / masquer",
"badges.layer.hidden": "masqué",
"badges.layer.meta": "{size} pt · {weight}",
"badges.layer.bold": "gras",
"badges.layer.regular": "normal",
"badges.layer.rect": "rectangle",
"badges.layer.oval": "ovale",
"badges.layer.issueCount": "▲ {count}",
"badges.layer.issueTruncated.one": "{count} badge avec texte coupé",
"badges.layer.issueTruncated.other": "{count} badges avec texte coupé",
"badges.layer.issueReduced.one": "{count} badge réduit automatiquement",
"badges.layer.issueReduced.other": "{count} badges réduits automatiquement",
"badges.keys.hint": "Clic sur un champ pour le régler. Flèches : déplacer de 0,5 mm. Maj + flèches : 2 mm. Échap : désélectionner. Ctrl/Cmd + Z : annuler.",
"badges.undo": "↶ Annuler",
"badges.undo.title": "Ctrl/Cmd + Z",
"badges.dirty": "Modifications non enregistrées",
"badges.clean": "✓ Enregistré",
"badges.savedAt": "✓ Enregistré à {time}",
"badges.sample.firstName": "Prénom",
"badges.sample.lastName": "Nom",
"badges.preview.with": "Aperçu avec",
"badges.preview.prev": "Bénévole précédent",
"badges.preview.next": "Bénévole suivant",
"badges.preview.counter": "Badge {index} / {total} · {role}",
"badges.preview.noVolunteer": "Aucun bénévole : exemple fictif",
"badges.preview.longest": "Tester le nom le plus long",
"badges.preview.guides": "Repères",
"badges.preview.guides.help": "Affiche la marge de découpe (1,5 mm) et le cadre de chaque champ",
"badges.preview.zoomIn": "Agrandir l'aperçu",
"badges.preview.zoomOut": "Réduire l'aperçu",
"badges.preview.zoom": "{percent} %",
"badges.preview.noPhoto": "sans photo",
"badges.preview.truncated": "▲ TEXTE COUPÉ",
"badges.preview.dims": "{width} × {height} mm",
"badges.preview.reducedTo": " · réduit à {size} pt",
"badges.pt": "{size} pt",
"badges.color.white": "Blanc",
"badges.color.black": "Noir",
"badges.color.yellow": "Jaune",
"badges.color.coral": "Corail",
"badges.color.none": "Aucun",
"badges.color.custom": "Autre",
"badges.align.left": "Gauche",
"badges.align.center": "Centre",
"badges.align.right": "Droite",
"badges.inspector.kicker": "{face} · champ sélectionné",
"badges.inspector.content": "Contenu",
"badges.inspector.contentHelp": "Identique sur tous les badges — sauf pour un bénévole dont la fiche porte un « Texte du badge », qui le remplace.",
"badges.inspector.fontSize": "Taille du texte",
"badges.inspector.reducedHere": "Ici réduit à {size} pt",
"badges.inspector.capHeight": "Hauteur ≈ {mm} mm",
"badges.inspector.smaller": "Diminuer la taille",
"badges.inspector.larger": "Augmenter la taille",
"badges.inspector.autoFit": "Réduire automatiquement si le texte est trop long",
"badges.inspector.align": "Alignement",
"badges.inspector.weight": "Graisse",
"badges.inspector.regular": "Normal",
"badges.inspector.bold": "Gras",
"badges.inspector.textColor": "Couleur du texte",
"badges.inspector.background": "Fond de la zone",
"badges.inspector.lowContrast": "⚠ Contraste faible : le texte risque d'être illisible à l'impression.",
"badges.inspector.photoShape": "Forme de la photo",
"badges.inspector.photoRect": "▢ Rectangle",
"badges.inspector.photoOval": "◯ Ovale",
"badges.inspector.photoStat": "{count} photo(s) reçue(s) sur {total}. Sans photo, le cadre reste vide.",
"badges.inspector.geometry": "Position et taille (mm)",
"badges.inspector.x": "X",
"badges.inspector.y": "Y",
"badges.inspector.w": "L",
"badges.inspector.h": "H",
"badges.inspector.center": "↔ Centrer",
"badges.inspector.fullWidth": "⇤⇥ Pleine largeur",
"badges.inspector.stack": "Superposition",
"badges.inspector.stack.position": "{rank} / {total} · 1 = dessus",
"badges.inspector.stack.front": "⤒ Premier plan",
"badges.inspector.stack.forward": "↑ Avancer",
"badges.inspector.stack.backward": "↓ Reculer",
"badges.inspector.stack.back": "⤓ Arrière-plan",
"badges.inspector.stack.help": "Quand des champs se chevauchent, celui du dessus recouvre les autres, sur l'aperçu comme à l'impression.",
"badges.inspector.hide": "Masquer ce champ",
"badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.",
"badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.",
"badges.inspector.empty.drop": "Pour changer le visuel de fond, déposez une image directement sur le recto ou le verso.",
"badges.check.title": "Contrôle des {count} badges",
"badges.check.subtitle": "Avant génération des planches",
"badges.check.critical": "⛔ À corriger",
"badges.check.warning": "⚠ À vérifier",
"badges.check.fixAutoFit": "Activer la réduction auto",
"badges.check.showExample": "Voir un exemple",
"badges.check.ok": "Les {count} badges s'affichent sans texte coupé ni champ hors zone de découpe.",
"badges.check.estimate": "Estimation faite dans le navigateur : le PDF généré reste la référence.",
"badges.issue.where": "{field} ({face})",
"badges.issue.truncated.one": "{where} : texte coupé sur {count} badge.",
"badges.issue.truncated.other": "{where} : texte coupé sur {count} badges.",
"badges.issue.reduced.one": "{where} : réduit automatiquement sur {count} badge, jusqu'à {size} pt.",
"badges.issue.reduced.other": "{where} : réduit automatiquement sur {count} badges, jusqu'à {size} pt.",
"badges.issue.margin": "{where} : touche la marge de découpe (1,5 mm), risque d'être rogné.",
"badges.issue.contrast": "{where} : texte et fond de zone trop proches, illisible.",
"badges.issue.noPhoto.one": "{count} bénévole n'a pas de photo : le cadre restera vide sur son badge.",
"badges.issue.noPhoto.other": "{count} bénévoles n'ont pas de photo : le cadre restera vide sur leur badge.",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.", "badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.empty_image": "L'image est vide.", "badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.", "badge.image_too_large": "L'image est trop volumineuse.",
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.", "badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois par face.",
"badge.invalid_color": "Couleur invalide (format attendu : #rrggbb).",
"badge.invalid_size": "La taille du badge doit être strictement positive.", "badge.invalid_size": "La taille du badge doit être strictement positive.",
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.", "badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.", "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
@@ -379,6 +607,22 @@
"stats.kpi.collected": "Cotisations encaissées en ligne", "stats.kpi.collected": "Cotisations encaissées en ligne",
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}", "stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
"stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}", "stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}",
"stats.attendance.title": "Présence",
"stats.attendance.help": "Présents et absents pointés par les référents sur leur écran, par poste. « En ce moment » : créneaux commencés et pas encore finis ; « Créneaux terminés » : tout ce qui est fini. Les créneaux à venir ne comptent pas. Mis à jour toutes les 30 secondes.",
"stats.attendance.live": "En ce moment",
"stats.attendance.past": "Créneaux terminés",
"stats.attendance.asOf": "mis à jour à {time}",
"stats.attendance.noLive": "Aucun créneau en cours.",
"stats.attendance.noPast": "Aucun créneau terminé pour l'instant.",
"stats.attendance.liveSummary": "{shifts} créneau(x) en cours · {expected} bénévole(s) attendu(s)",
"stats.attendance.shiftsCount": "{count} en cours",
"stats.attendance.expected": "Attendus",
"stats.attendance.present": "Présents",
"stats.attendance.absent": "Absents",
"stats.attendance.unmarked": "Non pointés",
"stats.attendance.rate": "Présence",
"stats.attendance.rateTip": "{present} présent(s) sur {marked} pointé(s)",
"stats.attendance.unmarkedHint": "{count} affectation(s) sur des créneaux terminés n'ont jamais été pointées : le taux de présence ne porte que sur les bénévoles pointés.",
"stats.empty": "Aucun bénévole inscrit pour l'instant.", "stats.empty": "Aucun bénévole inscrit pour l'instant.",
"stats.showTable": "Voir le tableau", "stats.showTable": "Voir le tableau",
"stats.unit.hours": "h", "stats.unit.hours": "h",
@@ -533,6 +777,7 @@
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.", "form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
"form.invalid_payload": "Données du formulaire invalides.", "form.invalid_payload": "Données du formulaire invalides.",
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.", "form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
"volunteer.empty_photo": "Le fichier de la photo est vide.",
"form.photo_too_large": "La photo est trop volumineuse.", "form.photo_too_large": "La photo est trop volumineuse.",
"form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.", "form.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
"form.logo_empty": "Le logo est vide.", "form.logo_empty": "Le logo est vide.",
+2 -82
View File
@@ -2,7 +2,6 @@ import type {
ArtistRead, ArtistRead,
AssignmentRead, AssignmentRead,
DayPartRead, DayPartRead,
MatrixResponse,
PositionCategory, PositionCategory,
ShiftRead, ShiftRead,
Violation, Violation,
@@ -108,22 +107,6 @@ export function candidateChanceScore(params: {
return score; return score;
} }
/** Colonne de la grille : un créneau distinct (date, heure de début, heure de
* fin) — pas seulement une heure de début : deux créneaux qui démarrent à la
* même heure mais durent différemment ont chacun leur colonne. */
export interface LatticeColumn {
key: string; // `YYYY-MM-DD|HH:MM|HH:MM`
date: string;
time: string;
endTime: string;
label: string; // « 17:00 → 22:00 »
}
export interface LatticeDay {
date: string;
columns: LatticeColumn[];
}
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du plus /** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du plus
* long creux laissé par les créneaux types sur le cadran de 24 h (l'heure où * long creux laissé par les créneaux types sur le cadran de 24 h (l'heure où
* le dernier de la journée se termine) — même règle que * le dernier de la journée se termine) — même règle que
@@ -169,68 +152,6 @@ export function dayBoundaryFromDayParts(
return clockLabel(bestStart); return clockLabel(bestStart);
} }
/** Construit le treillis de colonnes à partir de créneaux (grille alignée).
* `dayBoundary` (`HH:MM`, défaut minuit) regroupe et trie les colonnes par
* « journée de festival » plutôt que par date calendaire brute : un créneau
* commençant avant cette heure (ex. 02:00, suite d'une soirée qui a passé
* minuit) est rattaché au jour précédent et trié après 23:00, pas avant
* 19:00 — voir CLAUDE.md § 3. */
export function buildLattice(
starts: Array<{ starts_at: string; ends_at: string }>,
dayBoundary = '00:00',
): {
days: LatticeDay[];
columns: LatticeColumn[];
} {
const boundaryMinutes = minutesOfDay(dayBoundary);
const seen = new Map<string, LatticeColumn & { festivalDay: string; sortMinutes: number }>();
for (const s of starts) {
const date = datePart(s.starts_at);
const time = timePart(s.starts_at);
const endTime = timePart(s.ends_at);
const key = `${date}|${time}|${endTime}`;
if (!seen.has(key)) {
const minutes = minutesOfDay(time);
const festivalDay = minutes < boundaryMinutes ? addDays(date, -1) : date;
const sortMinutes = (minutes - boundaryMinutes + 24 * 60) % (24 * 60);
seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}`, festivalDay, sortMinutes });
}
}
const ordered = [...seen.values()].sort(
(a, b) => a.festivalDay.localeCompare(b.festivalDay) || a.sortMinutes - b.sortMinutes,
);
const columns: LatticeColumn[] = ordered.map(({ key, date, time, endTime, label }) => ({
key,
date,
time,
endTime,
label,
}));
const days: LatticeDay[] = [];
for (const column of ordered) {
let day = days.find((d) => d.date === column.festivalDay);
if (!day) {
day = { date: column.festivalDay, columns: [] };
days.push(day);
}
day.columns.push({
key: column.key,
date: column.date,
time: column.time,
endTime: column.endTime,
label: column.label,
});
}
return { days, columns };
}
export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) {
return buildLattice(
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
dayBoundary,
);
}
/** /**
* Largeur (px) des tranches de 30 min d'une journée, en fonction de leur * Largeur (px) des tranches de 30 min d'une journée, en fonction de leur
* nombre : une journée courte (peu de tranches) reste lisible en large, * nombre : une journée courte (peu de tranches) reste lisible en large,
@@ -247,9 +168,8 @@ export function columnWidthPx(slotsInDay: number): number {
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, // // Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, //
// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés // // eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés //
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. // // dans cette grille fixe en fusionnant les cases qu'ils couvrent. Même axe //
// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », // // pour la « Vue par bénévole » (`VolunteerViewPage.tsx`). //
// une colonne par créneau distinct plutôt qu'un axe temporel continu.) //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
const TIME_GRID_SLOT_MINUTES = 30; const TIME_GRID_SLOT_MINUTES = 30;
+115
View File
@@ -0,0 +1,115 @@
// Aides des écrans bénévole et référent (`VolunteerHomeView`,
// `MobileLeadPage`) : statut d'un créneau par rapport à maintenant, durées,
// compte à rebours et frise horaire d'une journée. Tout en minutes
// « murales » (`naiveMinutes` / `nowNaiveMinutes`), jamais en `Date` locale.
import { t } from '@/i18n';
import { longDayLabel, naiveMinutes } from '@/lib/time';
export type SlotStatus = 'done' | 'now' | 'next';
export function slotStatus(start: number, end: number, now: number): SlotStatus {
if (now >= end) return 'done';
return now >= start ? 'now' : 'next';
}
/** `HH:MM` d'un instant en minutes naïves (passe minuit sans broncher). */
export function clockLabel(minutes: number): string {
const m = ((minutes % 1440) + 1440) % 1440;
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
}
/** « 9 h », « 2 h 30 ». */
export function durationLabel(minutes: number): string {
const total = Math.round(minutes);
const h = Math.floor(total / 60);
const m = total % 60;
return m ? t('slot.duration.hoursMinutes', { h, m: String(m).padStart(2, '0') }) : t('slot.duration.hours', { h });
}
/** Compte à rebours du créneau mis en avant : « FIN À 03:00 » s'il est en
* cours, sinon « DANS 20 MIN », « DANS 2 h 30 » ou « DANS 3 J ». */
export function countdownLabel(start: number, end: number, now: number): string {
if (now >= start) return t('slot.countdown.endsAt', { time: clockLabel(end) });
const diff = start - now;
if (diff < 60) return t('slot.countdown.minutes', { count: Math.max(1, Math.round(diff)) });
if (diff < 24 * 60) return t('slot.countdown.in', { duration: durationLabel(diff) });
return t('slot.countdown.days', { count: Math.round(diff / (24 * 60)) });
}
export interface Interval {
start: number;
end: number;
}
/** Plage commune à deux créneaux qui se chevauchent (`null` sinon) —
* jointifs (fin = début) : pas de chevauchement, comme `SHIFT_OVERLAP`. */
export function overlapOf(a: Interval, b: Interval): Interval | null {
const start = Math.max(a.start, b.start);
const end = Math.min(a.end, b.end);
return start < end ? { start, end } : null;
}
export interface RibbonBar {
/** Position et largeur en % de la frise. */
left: number;
width: number;
/** Ligne de la frise : deux créneaux qui se chevauchent ne se masquent
* pas, le second descend d'une ligne. */
lane: number;
}
export interface Ribbon {
bars: RibbonBar[];
lanes: number;
/** Repères horaires, en % de la frise. */
ticks: Array<{ left: number; label: string }>;
/** Nombre d'heures couvertes (une graduation par heure). */
hours: number;
/** Position de maintenant en %, `null` hors de la frise. */
now: number | null;
}
const MIN_RIBBON_HOURS = 4;
const MAX_TICKS = 4;
/** Frise d'une journée : de l'heure pleine qui précède le premier créneau à
* celle qui suit le dernier (4 h au moins), `slots` triés par début. */
export function buildRibbon(slots: Interval[], now: number): Ribbon {
const first = Math.floor(Math.min(...slots.map((s) => s.start)) / 60) * 60;
const last = Math.ceil(Math.max(...slots.map((s) => s.end)) / 60) * 60;
const hours = Math.max(MIN_RIBBON_HOURS, (last - first) / 60);
const span = hours * 60;
const pct = (minutes: number) => ((minutes - first) / span) * 100;
const laneEnds: number[] = [];
const bars = slots.map((s) => {
let lane = 0;
while (laneEnds[lane] !== undefined && laneEnds[lane] > s.start) lane++;
laneEnds[lane] = s.end;
return { left: pct(s.start), width: ((s.end - s.start) / span) * 100, lane };
});
const step = Math.ceil(hours / (MAX_TICKS - 1));
const ticks = [];
for (let h = 0; h <= hours; h += step) ticks.push({ left: (h / hours) * 100, label: clockLabel(first + h * 60) });
return {
bars,
lanes: Math.max(1, laneEnds.length),
ticks,
hours,
now: now >= first && now <= first + span ? pct(now) : null,
};
}
/** Date d'un message (organisation, poste) : « AUJOURD'HUI 18:42 »,
* « HIER 18:42 », sinon « Samedi 31 · 18:42 ». */
export function messageDateLabel(iso: string, now: number): string {
const at = naiveMinutes(iso);
const days = Math.floor(now / 1440) - Math.floor(at / 1440);
const time = clockLabel(at);
if (days === 0) return t('message.date.today', { time });
if (days === 1) return t('message.date.yesterday', { time });
return t('message.date.other', { day: longDayLabel(iso), time });
}
+17
View File
@@ -0,0 +1,17 @@
// Nom et initiales d'un bénévole à l'écran. Prénom et nom ne sont pas
// obligatoires à l'ajout manuel (étape 66) : un bénévole peut n'avoir ni
// l'un ni l'autre, il doit rester visible et sélectionnable partout.
import { t } from '@/i18n';
type Named = { first_name: string; last_name: string };
/** « Prénom Nom », ou « Sans nom » si les deux sont vides. */
export function volunteerName(v: Named): string {
return `${v.first_name} ${v.last_name}`.trim() || t('volunteer.unnamed');
}
/** Initiales (prénom puis nom), « ? » si les deux sont vides. */
export function volunteerInitials(v: Named): string {
return `${v.first_name.trim().charAt(0)}${v.last_name.trim().charAt(0)}`.toUpperCase() || '?';
}
+36 -503
View File
@@ -1,94 +1,22 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useMemo, useState } from 'react';
import { import { useGenerateBadges, useVolunteers } from '@/api/hooks';
useBadgeImageUrl,
useBadgeTemplate,
useGenerateBadges,
useSetBadgeFields,
useSetBadgeSize,
useUploadBadgeBackImage,
useUploadBadgeFrontImage,
useVolunteers,
} from '@/api/hooks';
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
import { ApiError } from '@/api'; import { ApiError } from '@/api';
import { EmptyNote, ErrorNote, Loading } from '@/components/state'; import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui'; import { Btn, Chip, Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { volunteerName } from '@/lib/volunteer';
// Le format du badge est dérivé de l'image de fond du recto (voir import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab';
// `backend/app/badges/pdf.py:image_size_mm`) — CR80 (85,6 × 54 mm) tant
// qu'aucune image n'est importée. `SCALE` fixe la taille d'édition à
// l'écran ; les coordonnées enregistrées restent en mm.
const DEFAULT_BADGE_W_MM = 85.6;
const DEFAULT_BADGE_H_MM = 54;
const SCALE = 4.6; // px par mm à l'écran
type FieldState = BadgeFieldIn & { enabled: boolean }; // L'onglet « Personnaliser » (éditeur recto/verso, aperçu avec les vraies
// données, contrôle avant génération) vit dans `./badges/`.
const FIELD_TYPES: BadgeFieldType[] = ['FULL_NAME', 'POSITIONS', 'CATEGORY', 'ROLE'];
const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName',
POSITIONS: 'badges.field.positions',
CATEGORY: 'badges.field.category',
ROLE: 'badges.field.role',
};
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName.placeholder',
POSITIONS: 'badges.field.positions.placeholder',
CATEGORY: 'badges.field.category.placeholder',
ROLE: 'badges.field.role.placeholder',
};
// Position/taille par défaut à l'activation d'un champ — empilés
// verticalement pour ne pas se superposer (repères pour un badge CR80 ;
// l'utilisateur ajuste ensuite à la souris pour son propre format). Postes
// affectés sur plusieurs lignes (un par créneau, avec horaire) : plus haut
// que les autres champs.
const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = {
FULL_NAME: {
x_mm: 5, y_mm: 4, width_mm: 70, height_mm: 10,
font_size: 13, align: 'LEFT', bold: true, color: '#000000',
},
POSITIONS: {
x_mm: 5, y_mm: 15, width_mm: 70, height_mm: 22,
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
},
CATEGORY: {
x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 7,
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
},
ROLE: {
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
},
};
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> {
const byType = new Map(saved.map((f) => [f.field_type, f]));
const out = {} as Record<BadgeFieldType, FieldState>;
for (const type of FIELD_TYPES) {
const existing = byType.get(type);
out[type] = existing
? { ...existing, enabled: true }
: { field_type: type, enabled: false, ...FIELD_DEFAULTS[type] };
}
return out;
}
export function BadgesPage() { export function BadgesPage() {
const [tab, setTab] = useState<'customize' | 'generate'>('customize'); const [tab, setTab] = useState<'customize' | 'generate'>('customize');
return ( const tabBar = (
<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('badges.admin')}</span> <span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span>
<Chip active={tab === 'customize'} onClick={() => setTab('customize')}> <Chip active={tab === 'customize'} onClick={() => setTab('customize')}>
{t('badges.tab.customize')} {t('badges.tab.customize')}
@@ -96,431 +24,21 @@ export function BadgesPage() {
<Chip active={tab === 'generate'} onClick={() => setTab('generate')}> <Chip active={tab === 'generate'} onClick={() => setTab('generate')}>
{t('badges.tab.generate')} {t('badges.tab.generate')}
</Chip> </Chip>
</div> </>
{tab === 'customize' ? <BadgeCustomizeTab /> : <BadgeGenerateTab />}
</div>
); );
}
// --------------------------------------------------------------------------- //
// Personnalisation //
// --------------------------------------------------------------------------- //
function BadgeCustomizeTab() {
const template = useBadgeTemplate();
const frontUrl = useBadgeImageUrl(
'front', template.data?.has_front_image ?? false, template.dataUpdatedAt,
);
const backUrl = useBadgeImageUrl(
'back', template.data?.has_back_image ?? false, template.dataUpdatedAt,
);
const uploadFront = useUploadBadgeFrontImage();
const uploadBack = useUploadBadgeBackImage();
const setFields = useSetBadgeFields();
const setSize = useSetBadgeSize();
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
useEffect(() => {
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
}, [template.data]);
if (template.isLoading || !fields) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
};
const save = () => {
const payload: BadgeFieldIn[] = FIELD_TYPES.filter((type) => fields[type].enabled).map(
(type) => {
const f = fields[type];
return {
field_type: f.field_type,
x_mm: f.x_mm,
y_mm: f.y_mm,
width_mm: f.width_mm,
height_mm: f.height_mm,
font_size: f.font_size,
align: f.align,
bold: f.bold,
color: f.color,
};
},
);
setFields.mutate({ fields: payload });
};
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4 lg:flex-row lg:items-start"> <div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-none flex-col gap-4"> {tab === 'customize' ? (
<Panel> <BadgeCustomizeTab tabBar={tabBar} />
<PanelHeader> ) : (
{t('badges.front')} <>
<HelpTip text={t('badges.front.help')} className="ml-[6px]" /> <div className="flex flex-wrap items-center gap-2 border-b-[3px] border-ink bg-sand px-3 py-2 sm:px-4">
</PanelHeader> {tabBar}
<div className="p-3">
<ImageUploadRow
url={frontUrl}
pending={uploadFront.isPending}
error={uploadFront.error}
onPick={(file) => uploadFront.mutate(file)}
/>
<BadgeSizeControl
badgeW={badgeW}
badgeH={badgeH}
pending={setSize.isPending}
onApply={(width_mm, height_mm) =>
setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm })
}
/>
<div
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
style={{
width: badgeW * SCALE,
height: badgeH * SCALE,
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
}}
onPointerDown={() => setSelected(null)}
>
{FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => (
<FieldBox
key={type}
field={fields[type]}
badgeW={badgeW}
badgeH={badgeH}
selected={selected === type}
onSelect={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)}
/>
))}
</div> </div>
<p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60"> <BadgeGenerateTab />
{t('badges.editor.hint', { </>
width: Math.round(badgeW * 10) / 10,
height: Math.round(badgeH * 10) / 10,
})}
</p>
</div>
</Panel>
<Panel>
<PanelHeader>
{t('badges.back')}
<HelpTip text={t('badges.back.help')} className="ml-[6px]" />
</PanelHeader>
<div className="p-3">
<ImageUploadRow
url={backUrl}
pending={uploadBack.isPending}
error={uploadBack.error}
onPick={(file) => uploadBack.mutate(file)}
/>
<div
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
style={{ width: badgeW * SCALE, height: badgeH * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
/>
</div>
</Panel>
</div>
<Panel className="min-w-[320px] flex-1">
<PanelHeader>{t('badges.fields')}</PanelHeader>
<div className="flex flex-col divide-y-2 divide-ink/10">
{FIELD_TYPES.map((type) => (
<FieldEditorRow
key={type}
type={type}
field={fields[type]}
active={selected === type}
onFocus={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)}
/>
))}
</div>
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
<Btn onClick={save} disabled={setFields.isPending}>
{setFields.isPending ? t('badges.saving') : t('badges.save')}
</Btn>
{setFields.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold text-green">{t('badges.saved')}</span>
) : null}
{setFields.isError ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
{errorMessage(setFields.error)}
</span>
) : null}
</div>
</Panel>
</div>
);
}
function ImageUploadRow({
url,
pending,
error,
onPick,
}: {
url: string | null;
pending: boolean;
error: unknown;
onPick: (file: File) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex items-center gap-[10px]">
<Btn variant="ghost" onClick={() => inputRef.current?.click()} disabled={pending}>
{pending ? t('badges.uploading') : url ? t('badges.image.replace') : t('badges.image.choose')}
</Btn>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onPick(file);
e.target.value = '';
}}
/>
{error ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">{errorMessage(error)}</span>
) : null}
</div>
);
}
/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de
* l'image importée). Sert à agrandir un badge devenu minuscule parce que
* l'image de fond n'était pas à une résolution d'impression (300 dpi
* supposés à l'import, voir `useBadgeTemplate`). */
function BadgeSizeControl({
badgeW,
badgeH,
pending,
onApply,
}: {
badgeW: number;
badgeH: number;
pending: boolean;
onApply: (width_mm: number, height_mm: number) => void;
}) {
const rounded = Math.round(badgeW * 10) / 10;
const [width, setWidth] = useState(String(rounded));
useEffect(() => setWidth(String(rounded)), [rounded]);
const apply = () => {
const w = Number(width);
if (!Number.isFinite(w) || w <= 0) {
setWidth(String(rounded));
return;
}
const ratio = badgeH / badgeW;
onApply(w, Math.round(w * ratio * 10) / 10);
};
return (
<div className="mt-2 flex items-center gap-[8px]">
<FieldLabel>{t('badges.size.width')}</FieldLabel>
<input
type="number"
min={5}
step={1}
value={width}
disabled={pending}
onChange={(e) => setWidth(e.target.value)}
onBlur={apply}
onKeyDown={(e) => {
if (e.key === 'Enter') apply();
}}
className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
/>
<span className="font-mono text-[9.5px] opacity-60">
{t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })}
</span>
</div>
);
}
function FieldBox({
field,
badgeW,
badgeH,
selected,
onSelect,
onChange,
}: {
field: FieldState;
badgeW: number;
badgeH: number;
selected: boolean;
onSelect: () => void;
onChange: (patch: Partial<FieldState>) => void;
}) {
const dragRef = useRef<{ startXMm: number; startYMm: number; pointerXPx: number; pointerYPx: number } | null>(
null,
);
const resizeRef = useRef<{ startWMm: number; startHMm: number; pointerXPx: number; pointerYPx: number } | null>(
null,
);
const startDrag = (e: React.PointerEvent) => {
e.stopPropagation();
onSelect();
(e.target as Element).setPointerCapture(e.pointerId);
dragRef.current = { startXMm: field.x_mm, startYMm: field.y_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
};
const onDragMove = (e: React.PointerEvent) => {
if (!dragRef.current) return;
const d = dragRef.current;
const dxMm = (e.clientX - d.pointerXPx) / SCALE;
const dyMm = (e.clientY - d.pointerYPx) / SCALE;
onChange({
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
});
};
const startResize = (e: React.PointerEvent) => {
e.stopPropagation();
onSelect();
(e.target as Element).setPointerCapture(e.pointerId);
resizeRef.current = { startWMm: field.width_mm, startHMm: field.height_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
};
const onResizeMove = (e: React.PointerEvent) => {
if (!resizeRef.current) return;
const d = resizeRef.current;
const dwMm = (e.clientX - d.pointerXPx) / SCALE;
const dhMm = (e.clientY - d.pointerYPx) / SCALE;
onChange({
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
});
};
return (
<div
onPointerDown={startDrag}
onPointerMove={onDragMove}
onPointerUp={() => (dragRef.current = null)}
className={cx(
'absolute flex cursor-move items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
selected ? 'border-blue-strong' : 'border-ink/40',
)} )}
style={{
left: field.x_mm * SCALE,
top: field.y_mm * SCALE,
width: field.width_mm * SCALE,
height: field.height_mm * SCALE,
}}
>
<span
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
style={{
fontSize: field.font_size * (SCALE / 2.83), // pt -> px approx. à l'échelle du badge
fontWeight: field.bold ? 800 : 400,
color: field.color,
width: '100%',
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}}
>
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
</span>
<div
onPointerDown={startResize}
onPointerMove={onResizeMove}
onPointerUp={() => (resizeRef.current = null)}
className="absolute bottom-0 right-0 h-[10px] w-[10px] cursor-nwse-resize border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
/>
</div>
);
}
function FieldEditorRow({
type,
field,
active,
onFocus,
onChange,
}: {
type: BadgeFieldType;
field: FieldState;
active: boolean;
onFocus: () => void;
onChange: (patch: Partial<FieldState>) => void;
}) {
return (
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
<label className="flex items-center gap-[8px]">
<input
type="checkbox"
checked={field.enabled}
onChange={(e) => onChange({ enabled: e.target.checked })}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
{t(FIELD_LABEL_KEY[type])}
</span>
</label>
{field.enabled ? (
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
<label className="flex items-center gap-[5px]">
<FieldLabel>{t('badges.field.size')}</FieldLabel>
<input
type="number"
min={6}
max={36}
value={field.font_size}
onChange={(e) => onChange({ font_size: Number(e.target.value) })}
className="w-[52px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
/>
</label>
<div className="flex items-center gap-[4px]">
{(['LEFT', 'CENTER', 'RIGHT'] as BadgeAlign[]).map((align) => (
<button
key={align}
type="button"
onClick={() => onChange({ align })}
className={cx(
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-mono text-[10px] font-bold',
field.align === align ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
)}
>
{align === 'LEFT' ? '⟵' : align === 'CENTER' ? '↔' : '⟶'}
</button>
))}
</div>
<button
type="button"
onClick={() => onChange({ bold: !field.bold })}
className={cx(
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-display text-[11px] font-black',
field.bold ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
)}
>
G
</button>
<input
type="color"
value={field.color}
onChange={(e) => onChange({ color: e.target.value })}
className="h-[24px] w-[30px] cursor-pointer border-2 border-ink p-0"
/>
</div>
) : null}
</div> </div>
); );
} }
@@ -570,7 +88,7 @@ function BadgeGenerateTab() {
const disabled = generate.isPending || (mode === 'pick' && picked.size === 0); const disabled = generate.isPending || (mode === 'pick' && picked.size === 0);
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4"> <div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4">
<Panel className="max-w-[560px]"> <Panel className="max-w-[560px]">
<PanelHeader>{t('badges.generate.title')}</PanelHeader> <PanelHeader>{t('badges.generate.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
@@ -583,6 +101,18 @@ function BadgeGenerateTab() {
</Chip> </Chip>
</div> </div>
{/* Un bénévole peut demander plusieurs exemplaires (fiche,
« Nombre de badges ») : le total imprimé peut dépasser le
nombre de bénévoles. */}
<span className="font-mono text-[10px] opacity-70">
{t('badges.generate.total', {
count: (mode === 'pick' ? sorted.filter((v) => picked.has(v.id)) : sorted).reduce(
(sum, v) => sum + v.badge_count,
0,
),
})}
</span>
{mode === 'pick' ? ( {mode === 'pick' ? (
<div className="max-h-[320px] overflow-auto border-2 border-ink"> <div className="max-h-[320px] overflow-auto border-2 border-ink">
{sorted.map((v) => ( {sorted.map((v) => (
@@ -596,7 +126,10 @@ function BadgeGenerateTab() {
onChange={() => toggle(v.id)} onChange={() => toggle(v.id)}
className="h-[14px] w-[14px] accent-ink" className="h-[14px] w-[14px] accent-ink"
/> />
{v.first_name} {v.last_name} {volunteerName(v)}
{v.badge_count > 1 ? (
<span className="ml-auto opacity-60">× {v.badge_count}</span>
) : null}
</label> </label>
))} ))}
</div> </div>
+4 -1
View File
@@ -4,16 +4,18 @@ import { Chip } from '@/components/ui';
import { ConfigArtists } from '@/pages/config/ConfigArtists'; import { ConfigArtists } from '@/pages/config/ConfigArtists';
import { ConfigGrid } from '@/pages/config/ConfigGrid'; import { ConfigGrid } from '@/pages/config/ConfigGrid';
import { ConfigMail } from '@/pages/config/ConfigMail'; import { ConfigMail } from '@/pages/config/ConfigMail';
import { ConfigMessage } from '@/pages/config/ConfigMessage';
import { ConfigRules } from '@/pages/config/ConfigRules'; import { ConfigRules } from '@/pages/config/ConfigRules';
import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers'; import { ConfigVolunteers } from '@/pages/config/ConfigVolunteers';
import { t } from '@/i18n'; import { t } from '@/i18n';
type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'mail' | 'rules'; type ConfigTab = 'grid' | 'volunteers' | 'artists' | 'message' | 'mail' | 'rules';
const TABS: Array<{ id: ConfigTab; labelKey: string }> = [ const TABS: Array<{ id: ConfigTab; labelKey: string }> = [
{ id: 'grid', labelKey: 'config.tab.grid' }, { id: 'grid', labelKey: 'config.tab.grid' },
{ id: 'volunteers', labelKey: 'config.tab.volunteers' }, { id: 'volunteers', labelKey: 'config.tab.volunteers' },
{ id: 'artists', labelKey: 'config.tab.artists' }, { id: 'artists', labelKey: 'config.tab.artists' },
{ id: 'message', labelKey: 'config.tab.message' },
{ id: 'mail', labelKey: 'config.tab.mail' }, { id: 'mail', labelKey: 'config.tab.mail' },
{ id: 'rules', labelKey: 'config.tab.rules' }, { id: 'rules', labelKey: 'config.tab.rules' },
]; ];
@@ -38,6 +40,7 @@ export function ConfigPage() {
{tab === 'grid' ? <ConfigGrid /> : null} {tab === 'grid' ? <ConfigGrid /> : null}
{tab === 'volunteers' ? <ConfigVolunteers /> : null} {tab === 'volunteers' ? <ConfigVolunteers /> : null}
{tab === 'artists' ? <ConfigArtists /> : null} {tab === 'artists' ? <ConfigArtists /> : null}
{tab === 'message' ? <ConfigMessage /> : null}
{tab === 'mail' ? <ConfigMail /> : null} {tab === 'mail' ? <ConfigMail /> : null}
{tab === 'rules' ? <ConfigRules /> : null} {tab === 'rules' ? <ConfigRules /> : null}
</div> </div>
+9 -9
View File
@@ -117,9 +117,9 @@ export function FormBuilderPage() {
}; };
return ( return (
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-4"> <div className="flex min-h-0 flex-1 flex-col overflow-auto p-3 sm:p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start"> <div className="flex flex-col gap-4 lg:flex-row lg:items-start">
<Panel className="max-w-[420px] flex-1"> <Panel className="flex-1 lg:max-w-[420px]">
<PanelHeader>{t('form.link.title')}</PanelHeader> <PanelHeader>{t('form.link.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
<p className="font-mono text-[10.5px] leading-relaxed opacity-70"> <p className="font-mono text-[10.5px] leading-relaxed opacity-70">
@@ -172,7 +172,7 @@ export function FormBuilderPage() {
</div> </div>
</Panel> </Panel>
<Panel className="min-w-[320px] flex-1"> <Panel className="min-w-0 flex-1 lg:min-w-[320px]">
<PanelHeader>{t('form.fields.title')}</PanelHeader> <PanelHeader>{t('form.fields.title')}</PanelHeader>
<div className="flex flex-col divide-y-2 divide-ink/10"> <div className="flex flex-col divide-y-2 divide-ink/10">
{FIELD_TYPES.map((type) => ( {FIELD_TYPES.map((type) => (
@@ -398,14 +398,14 @@ function PaymentOptionsForm() {
return ( return (
<div className="mt-2 flex flex-col gap-[6px]"> <div className="mt-2 flex flex-col gap-[6px]">
{rows.map((o) => ( {rows.map((o) => (
<div key={o.id} className="flex items-center gap-1"> <div key={o.id} className="flex flex-wrap items-center gap-1 border-b border-sand-line pb-2 sm:flex-nowrap sm:border-b-0 sm:pb-0">
<input <input
defaultValue={o.label} defaultValue={o.label}
onBlur={(e) => { onBlur={(e) => {
const v = e.target.value.trim(); const v = e.target.value.trim();
if (v && v !== o.label) update.mutate({ id: o.id, body: { label: v } }); if (v && v !== o.label) update.mutate({ id: o.id, body: { label: v } });
}} }}
className="min-w-0 flex-1 border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold" className="min-w-0 flex-1 basis-full border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold sm:basis-0"
/> />
<input <input
type="number" type="number"
@@ -449,19 +449,19 @@ function PaymentOptionsForm() {
type="button" type="button"
onClick={() => remove.mutate(o.id)} onClick={() => remove.mutate(o.id)}
aria-label={t('action.remove')} aria-label={t('action.remove')}
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange" className="ml-auto h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange sm:ml-0"
> >
✕ ✕
</button> </button>
</div> </div>
))} ))}
<div className="mt-1 flex items-center gap-1 border-t-2 border-ink pt-2"> <div className="mt-1 flex flex-wrap items-center gap-1 border-t-2 border-ink pt-2 sm:flex-nowrap">
<input <input
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
placeholder={t('form.customize.paymentOptions.label')} placeholder={t('form.customize.paymentOptions.label')}
className="min-w-0 flex-1 border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold" className="min-w-0 flex-1 basis-full border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold sm:basis-0"
/> />
<input <input
type="number" type="number"
@@ -519,7 +519,7 @@ function PaymentOptionsForm() {
}, },
) )
} }
className="h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50" className="ml-auto h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 sm:ml-0 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
> >
{t('form.customize.paymentOptions.add')} {t('form.customize.paymentOptions.add')}
</button> </button>
+27 -7
View File
@@ -88,7 +88,9 @@ export function GrillePage() {
: matrix.data.positions.filter((p) => p.category === categoryFilter); : matrix.data.positions.filter((p) => p.category === categoryFilter);
const gridTemplateColumns = [ const gridTemplateColumns = [
'230px', // Colonne des postes rétrécie sur petit écran, pour laisser voir au
// moins quelques tranches horaires à côté.
'clamp(132px, 36vw, 230px)',
...shownDays.flatMap((day) => { ...shownDays.flatMap((day) => {
const width = columnWidthPx(day.slotLabels.length); const width = columnWidthPx(day.slotLabels.length);
return day.slotLabels.map(() => `${width}px`); return day.slotLabels.map(() => `${width}px`);
@@ -134,8 +136,8 @@ export function GrillePage() {
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <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 flex-wrap items-center gap-[10px] border-b-2 border-ink bg-sand px-3 py-[9px] sm:gap-[14px] sm:px-4">
<div className="flex items-center gap-[6px]"> <div className="flex flex-wrap items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span> <span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}> <Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
{t('filter.all')} {t('filter.all')}
@@ -163,7 +165,7 @@ export function GrillePage() {
))} ))}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<button <button
type="button" type="button"
onClick={() => runSolver.mutate()} onClick={() => runSolver.mutate()}
@@ -199,7 +201,7 @@ export function GrillePage() {
) : null} ) : null}
</div> </div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-2 border-ink bg-cream px-[9px] py-[5px] lg:ml-auto">
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span> <span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
<LegendItem swatch="bg-hatch-orange" glyph="▲"> <LegendItem swatch="bg-hatch-orange" glyph="▲">
{t('grille.legend.critical')} {t('grille.legend.critical')}
@@ -220,7 +222,7 @@ export function GrillePage() {
</div> </div>
<div className="flex min-h-0 flex-1 items-stretch"> <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="max-h-[calc(100dvh-150px)] min-w-0 flex-1 overflow-auto lg:max-h-[calc(100vh-118px)]">
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}> <div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
<div <div
style={{ gridColumn: '1 / span 1', gridRow: 1 }} style={{ gridColumn: '1 / span 1', gridRow: 1 }}
@@ -278,7 +280,25 @@ export function GrillePage() {
</div> </div>
</div> </div>
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper"> {/* Sous `lg:`, pas la place d'un panneau à côté de la grille : il
s'ouvre par-dessus (plein écran sur mobile, tiroir à droite sur
tablette) seulement quand un créneau est choisi. */}
{selected ? (
<div
aria-hidden
onClick={() => setSelectedShiftId(null)}
className="fixed inset-0 z-40 bg-ink/40 lg:hidden"
/>
) : null}
<aside
className={cx(
'overflow-auto border-ink bg-paper',
'lg:static lg:z-auto lg:block lg:max-h-[calc(100vh-118px)] lg:w-[392px] lg:flex-none lg:border-l-[3px]',
selected
? 'fixed inset-y-0 right-0 z-50 block w-full sm:w-[420px] sm:border-l-[3px]'
: 'hidden',
)}
>
{selected ? ( {selected ? (
<AssignmentPanel <AssignmentPanel
key={selected.shift.shift_id} key={selected.shift.shift_id}
+356 -118
View File
@@ -1,33 +1,61 @@
import { Fragment, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ShiftRead } from '@/api/schema'; import type { PositionRead, ShiftRead } from '@/api/schema';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks'; import {
useAssignments,
useDayParts,
usePositions,
useSetAttendance,
useShifts,
useUpdatePosition,
useVolunteer,
useVolunteerPlanning,
useVolunteers,
} from '@/api/hooks';
import { useAuth } from '@/auth/auth-context'; import { useAuth } from '@/auth/auth-context';
import { MessageEditor } from '@/components/MessageEditor';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
import { NowLine } from '@/components/NowLine';
import { VolunteerAvatar } from '@/components/VolunteerAvatar'; import { VolunteerAvatar } from '@/components/VolunteerAvatar';
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time'; import { cx } from '@/lib/cx';
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
import { clockLabel, slotStatus, type SlotStatus } from '@/lib/slotTimeline';
import { longDayLabel, naiveMinutes } from '@/lib/time';
import { useNowMinutes } from '@/lib/useNowMinutes'; import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx';
import { VolunteerHomeView } from '@/pages/VolunteerHomeView'; import { VolunteerHomeView } from '@/pages/VolunteerHomeView';
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
// Page réellement consultée par le référent (accueil de son rôle LEAD, cf. // Page réellement consultée par le référent (accueil de son rôle LEAD, cf.
// `auth/roles.ts:homePath`) — page responsive (mobile d'abord, élargie à // `auth/roles.ts:homePath`), d'après la maquette `design/Planning bénévole
// partir de `sm:`), plus de maquette de téléphone. Un compte peut être // mobile.dc.html` : son poste, ses créneaux à choisir (celui en cours ou le
// référent de plusieurs postes (`Position.lead_account_id`) : chacun a sa // prochain par défaut), les compteurs présents / attendus / absents, puis
// propre carte, listant TOUS ses créneaux (pas seulement le plus proche) avec // l'équipe du créneau à pointer. Pensée pour le téléphone ; sur un écran
// un repère « MAINTENANT » pour se situer. Aucun repli vers « le premier // plus large, la même colonne, centrée. Un compte peut être référent de
// poste de l'édition » si le compte n'est référent d'aucun poste — ce serait // plusieurs postes (`Position.lead_account_id`) : on passe de l'un à l'autre
// montrer un poste qui n'est pas le sien ; s'il est par ailleurs rattaché à // par des onglets. Aucun repli vers « le premier poste de l'édition » si le
// une fiche bénévole (cas courant : « Donner un accès de connexion » promu // compte n'est référent d'aucun poste — ce serait montrer un poste qui n'est
// en référent), il voit alors sa vue de bénévole classique plutôt qu'un // pas le sien ; s'il est par ailleurs rattaché à une fiche bénévole (cas
// écran référent vide (`VolunteerHomeView`). // courant : « Donner un accès de connexion » promu en référent), il voit
// alors sa vue de bénévole classique (`VolunteerHomeView`).
// //
// Le pointage présent/absent reste local à l'écran (état React, jamais // Un référent peut aussi être affecté comme bénévole, y compris la même
// envoyé au serveur) : le pointage sur site est hors périmètre (CLAUDE.md §5). // soirée : s'il est rattaché à une fiche bénévole, deux onglets — « Mon
// équipe » (cet écran) et « Mon planning » (`VolunteerHomeView`, ses
// propres créneaux). On ouvre sur « Mon planning » quand il tient en ce
// moment un créneau de bénévole hors de ses postes et qu'aucun de ses
// postes n'a de créneau en cours ; sinon sur « Mon équipe ».
//
// Le pointage présent/absent est enregistré sur l'affectation
// (`Assignment.attendance`, `PUT .../assignments/{id}/attendance`) : il
// survit à un rechargement et se retrouve sur un autre téléphone.
type Presence = Record<string, 'present' | 'absent'>; type View = 'team' | 'planning';
const STATUS_KEY: Record<SlotStatus, string> = {
done: 'mobileLead.status.done',
now: 'mobileLead.status.now',
next: 'mobileLead.status.next',
};
export function MobileLeadPage() { export function MobileLeadPage() {
const { account } = useAuth(); const { account } = useAuth();
@@ -35,26 +63,25 @@ export function MobileLeadPage() {
const shifts = useShifts(); const shifts = useShifts();
const volunteers = useVolunteers(); const volunteers = useVolunteers();
const assignments = useAssignments(); const assignments = useAssignments();
const nowMinutes = useNowMinutes(); const dayParts = useDayParts();
const [presence, setPresence] = useState<Presence>({}); const me = useVolunteer(account?.volunteer_id);
const myPlanning = useVolunteerPlanning(account?.volunteer_id);
const myPositions = (positions.data ?? []).filter( const [view, setView] = useState<View | null>(null);
(p) => account && p.lead_account_id === account.id, const updatePosition = useUpdatePosition();
); const now = useNowMinutes();
const setAttendance = useSetAttendance();
const [positionId, setPositionId] = useState<string | null>(null);
const [shiftId, setShiftId] = useState<string | null>(null);
const boundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const shiftsByPosition = useMemo(() => { const shiftsByPosition = useMemo(() => {
const map = new Map<string, ShiftRead[]>(); const map = new Map<string, ShiftRead[]>();
for (const s of shifts.data ?? []) { for (const s of shifts.data ?? []) map.set(s.position_id, [...(map.get(s.position_id) ?? []), s]);
const list = map.get(s.position_id) ?? [];
list.push(s);
map.set(s.position_id, list);
}
for (const list of map.values()) { for (const list of map.values()) {
list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at)); list.sort((a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at));
} }
return map; return map;
}, [shifts.data]); }, [shifts.data]);
const volunteersById = useMemo( const volunteersById = useMemo(
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])), () => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
[volunteers.data], [volunteers.data],
@@ -64,6 +91,10 @@ export function MobileLeadPage() {
const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error; const error = positions.error ?? shifts.error ?? volunteers.error ?? assignments.error;
if (error) return <ErrorNote error={error} />; if (error) return <ErrorNote error={error} />;
const myPositions = (positions.data ?? []).filter(
(p) => account && p.lead_account_id === account.id,
);
if (myPositions.length === 0) { if (myPositions.length === 0) {
if (account?.volunteer_id) { if (account?.volunteer_id) {
return ( return (
@@ -82,149 +113,356 @@ export function MobileLeadPage() {
); );
} }
return ( // Par défaut : le poste dont un créneau est en cours ou arrive le plus tôt.
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col"> const nextEnd = (p: PositionRead) =>
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7"> (shiftsByPosition.get(p.id) ?? []).find((s) => now < naiveMinutes(s.ends_at));
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div> const position =
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]"> myPositions.find((p) => p.id === positionId) ??
{t('mobileLead.positionsCount', { count: myPositions.length })} [...myPositions].sort((a, b) => {
</div> const na = nextEnd(a);
</div> const nb = nextEnd(b);
if (!na || !nb) return na ? -1 : nb ? 1 : 0;
<div className="flex flex-1 flex-col gap-[22px] p-4 sm:p-6"> return naiveMinutes(na.starts_at) - naiveMinutes(nb.starts_at);
{myPositions.map((position) => { })[0];
const positionShifts = shiftsByPosition.get(position.id) ?? []; const positionShifts = shiftsByPosition.get(position.id) ?? [];
const nowIndex = positionShifts.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes); // Par défaut : le créneau en cours ou le prochain, sinon le dernier.
const nowInsertAt = nowIndex === -1 ? positionShifts.length : nowIndex; const shift =
positionShifts.find((s) => s.id === shiftId) ??
nextEnd(position) ??
positionShifts[positionShifts.length - 1] ??
null;
return ( const team = shift
<div key={position.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg"> ? (assignments.data ?? [])
<div className="bg-ink px-[14px] pb-[11px] pt-[11px] text-cream">
<div className="font-display text-[19px] font-black leading-tight tracking-tight">
{position.name.toUpperCase()}
</div>
{position.location ? (
<div className="font-display text-[13px] font-semibold opacity-80">
{position.location}
</div>
) : null}
</div>
<div className="flex flex-col gap-[14px] px-[14px] pb-[18px] pt-[13px]">
{positionShifts.length === 0 ? (
<div className="font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.noShift')}
</div>
) : null}
{positionShifts.map((shift, i) => {
const team = (assignments.data ?? [])
.filter((a) => a.shift_id === shift.id) .filter((a) => a.shift_id === shift.id)
.map((a) => { .map((a) => {
const volunteer = volunteersById.get(a.volunteer_id); const volunteer = volunteersById.get(a.volunteer_id);
return { return {
id: a.id, id: a.id,
volunteerId: a.volunteer_id, volunteerId: a.volunteer_id,
name: volunteer name: volunteer ? volunteerName(volunteer) : a.volunteer_id,
? `${volunteer.first_name} ${volunteer.last_name}` initials: volunteer ? volunteerInitials(volunteer) : '?',
: a.volunteer_id,
initials: volunteer
? `${volunteer.first_name[0]}${volunteer.last_name[0]}`
: '?',
hasPhoto: volunteer?.has_photo ?? false, hasPhoto: volunteer?.has_photo ?? false,
phone: volunteer?.phone ?? '', phone: volunteer?.phone ?? '',
present: presence[a.id] === 'present', state: a.attendance === 'PRESENT' ? 'present' : a.attendance === 'ABSENT' ? 'absent' : null,
absent: presence[a.id] === 'absent', markedAt: a.attendance_marked_at,
}; };
})
.sort((a, b) => a.name.localeCompare(b.name, 'fr'))
: [];
const presentCount = team.filter((m) => m.state === 'present').length;
const absentCount = team.filter((m) => m.state === 'absent').length;
// Un second appui sur le même bouton annule le pointage.
const mark = (member: (typeof team)[number], value: 'present' | 'absent') =>
setAttendance.mutate({
id: member.id,
attendance: member.state === value ? null : value === 'present' ? 'PRESENT' : 'ABSENT',
}); });
const presentCount = team.filter((m) => m.present).length;
// Bénévoles distincts affectés à au moins un créneau du poste.
const positionShiftIds = new Set(positionShifts.map((s) => s.id));
const teamSize = new Set(
(assignments.data ?? []).filter((a) => positionShiftIds.has(a.shift_id)).map((a) => a.volunteer_id),
).size;
const shiftStatus = shift
? slotStatus(naiveMinutes(shift.starts_at), naiveMinutes(shift.ends_at), now)
: null;
const myName = me.data ? `${me.data.first_name} ${me.data.last_name}` : null;
const inProgress = (start: string, end: string) =>
slotStatus(naiveMinutes(start), naiveMinutes(end), now) === 'now';
const ledShiftIds = new Set(myPositions.flatMap((p) => (shiftsByPosition.get(p.id) ?? []).map((s) => s.id)));
const leadingNow = (shifts.data ?? []).some((s) => ledShiftIds.has(s.id) && inProgress(s.starts_at, s.ends_at));
const myEntries = myPlanning.data ?? [];
const volunteeringNow = myEntries.some(
(e) => !ledShiftIds.has(e.shift_id) && inProgress(e.starts_at, e.ends_at),
);
const currentView: View = view ?? (volunteeringNow && !leadingNow ? 'planning' : 'team');
const tabs = account?.volunteer_id ? (
<div className="mx-auto grid w-full max-w-[640px] grid-cols-2 border-b-[3px] border-ink">
<ViewTab active={currentView === 'team'} live={leadingNow} onClick={() => setView('team')}>
{t('mobileLead.view.team')}
</ViewTab>
<ViewTab active={currentView === 'planning'} live={volunteeringNow} onClick={() => setView('planning')}>
{t('mobileLead.view.planning', { count: myEntries.length })}
</ViewTab>
</div>
) : null;
if (currentView === 'planning' && account?.volunteer_id) {
return (
<div className="flex flex-1 flex-col">
{tabs}
<VolunteerHomeView volunteerId={account.volunteer_id} fallbackName={account.email.split('@')[0]} />
</div>
);
}
return ( return (
<Fragment key={shift.id}> <div className="flex flex-1 flex-col">
{i === nowInsertAt ? <NowLine /> : null} {tabs}
<div className="border-2 border-ink"> <div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
<div className="flex flex-wrap items-stretch gap-2 border-b-2 border-ink bg-sand px-[11px] py-[9px]"> <div className="bg-ink px-4 pb-4 pt-[15px] text-cream sm:px-6">
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink"> {myPositions.length > 1 ? (
{longDayLabel(shift.starts_at)} ·{' '} <div className="mb-3 flex flex-wrap gap-[6px]">
{hourRangeLabel(shift.starts_at, shift.ends_at)} {myPositions.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setPositionId(p.id);
setShiftId(null);
}}
className={cx(
'min-h-[36px] cursor-pointer border-2 border-cream px-[10px] font-display text-[11.5px] font-extrabold uppercase',
p.id === position.id ? 'bg-yellow text-ink' : 'bg-transparent text-cream',
)}
>
{p.name}
</button>
))}
</div> </div>
<div className="border-2 border-ink bg-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold"> ) : null}
{t('mobileLead.present', { present: presentCount, total: team.length })} <div className="flex items-center gap-3">
{me.data ? (
<VolunteerAvatar
volunteerId={me.data.id}
hasPhoto={me.data.has_photo}
initials={volunteerInitials(me.data)}
className="h-[60px] w-[60px] !border-blue text-[19px]"
/>
) : null}
<div className="min-w-0 flex-1">
<div className="font-mono text-[10.5px] font-medium uppercase leading-none text-blue">
{myName ? t('mobileLead.kickerNamed', { name: myName }) : t('mobileLead.kicker')}
</div>
<div className="mt-[5px] break-words font-display text-[27px] font-black leading-[1.05] tracking-tight">
{position.name.toUpperCase()}
</div>
{position.location ? (
<div className="mt-[3px] font-display text-[13px] font-semibold leading-snug text-sand-dark">
{position.location}
</div>
) : null}
</div> </div>
</div> </div>
<div className="flex flex-col gap-[11px] p-[11px]"> {positionShifts.length ? (
<div className="mt-[13px] grid grid-cols-2 gap-[6px]">
{positionShifts.map((s) => {
const on = s.id === shift?.id;
const status = slotStatus(naiveMinutes(s.starts_at), naiveMinutes(s.ends_at), now);
return (
<button
key={s.id}
type="button"
onClick={() => setShiftId(s.id)}
aria-pressed={on}
className={cx(
'min-h-[50px] cursor-pointer border-2 border-cream px-2 py-[6px] text-left',
on ? 'bg-blue text-ink' : 'bg-transparent text-cream',
)}
>
<span className="block font-display text-[12px] font-black leading-tight">
{longDayLabel(`${festivalDay(s.starts_at, boundary)}T12:00`)}
</span>
<span className="block font-mono text-[11px] font-semibold leading-snug">
{clockLabel(naiveMinutes(s.starts_at))}–{clockLabel(naiveMinutes(s.ends_at))} ·{' '}
{t(STATUS_KEY[status])}
</span>
</button>
);
})}
</div>
) : (
<div className="mt-3 font-mono text-[11px] leading-relaxed opacity-70">{t('mobileLead.noShift')}</div>
)}
</div>
{shift ? (
<>
<div className="grid grid-cols-3 border-b-[3px] border-ink">
<Counter value={presentCount} label={t('mobileLead.count.present')} className="border-r-2 bg-mint" />
<Counter
value={team.length - presentCount - absentCount}
label={t('mobileLead.count.waiting')}
className="border-r-2 bg-paper"
/>
<Counter
value={absentCount}
label={t('mobileLead.count.absent')}
className={absentCount ? 'bg-orange' : 'bg-paper'}
/>
</div>
{absentCount ? (
<div className="border-b-[3px] border-ink bg-[repeating-linear-gradient(45deg,#FF5C39_0_9px,#E0421F_9px_18px)] px-[14px] py-[11px] font-display text-[15px] font-black leading-snug">
{t(absentCount > 1 ? 'mobileLead.absentAlert.other' : 'mobileLead.absentAlert.one', {
count: absentCount,
})}
</div>
) : null}
<div className="flex flex-col gap-[11px] px-[14px] pb-7 pt-[14px] sm:px-6">
{setAttendance.isError ? (
<div className="border-[3px] border-ink bg-orange px-3 py-2 font-display text-[12.5px] font-bold leading-snug">
{t('mobileLead.attendanceError')}
</div>
) : null}
<div className="flex items-baseline gap-2">
<span className="font-display text-[12px] font-black tracking-wider">
{t('mobileLead.roll', { count: team.length })}
</span>
<span className="ml-auto font-mono text-[10px] font-medium uppercase opacity-65">
{shiftStatus === 'now'
? t('mobileLead.when.now')
: shiftStatus === 'next'
? t('mobileLead.when.next', { time: clockLabel(naiveMinutes(shift.starts_at)) })
: t('mobileLead.when.done')}
</span>
</div>
{team.map((member) => ( {team.map((member) => (
<div <div
key={member.id} key={member.id}
className="border-[3px] border-ink bg-cream shadow-brutal-sm" className={cx(
'border-[3px] border-ink shadow-brutal-md',
member.state === 'absent' ? 'bg-orange-blush' : member.state === 'present' ? 'bg-mint-pale' : 'bg-paper',
)}
> >
<div className="flex items-center gap-[10px] px-[11px] py-[10px]"> <div className="flex items-center gap-[10px] px-[10px] py-[9px]">
<VolunteerAvatar <VolunteerAvatar
volunteerId={member.volunteerId} volunteerId={member.volunteerId}
hasPhoto={member.hasPhoto} hasPhoto={member.hasPhoto}
initials={member.initials} initials={member.initials}
className="h-[38px] w-[38px] text-[13px]" className="h-12 w-12 !border-2 text-[15px]"
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block font-display text-[17px] font-extrabold leading-tight"> <span className="block break-words font-display text-[16px] font-extrabold leading-tight">
{member.name} {member.name}
</span> {member.volunteerId === account?.volunteer_id ? (
<span className="block font-mono text-[10.5px] opacity-65"> <span className="ml-[6px] inline-block border-2 border-ink bg-blue px-[5px] py-[2px] align-middle font-display text-[9.5px] font-black leading-none">
{member.phone} {t('mobileLead.you')}
</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> </span>
) : null} ) : 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> </span>
{member.phone ? (
<span className="block font-mono text-[11px] font-medium leading-snug opacity-70">
{member.phone}
</span>
) : null}
{member.state && member.markedAt ? (
<span className="block font-mono text-[10px] font-medium leading-snug opacity-60">
{t('mobileLead.markedAt', { time: clockLabel(naiveMinutes(member.markedAt)) })}
</span>
) : null}
</span>
{member.phone ? (
<a
href={`tel:${member.phone.replace(/\s+/g, '')}`}
aria-label={t('mobileLead.callLabel', { name: member.name })}
className="flex h-[46px] w-[46px] flex-none items-center justify-center border-2 border-ink bg-cream font-display text-[10px] font-black"
>
{t('mobileLead.call')}
</a>
) : null} ) : null}
</div> </div>
<div className="grid grid-cols-2 border-t-[3px] border-ink"> <div className="grid grid-cols-2 border-t-[3px] border-ink">
<button <button
type="button" type="button"
onClick={() => onClick={() => mark(member, 'present')}
setPresence((p) => ({ ...p, [member.id]: 'present' })) aria-pressed={member.state === 'present'}
}
className={cx( className={cx(
'min-h-[52px] cursor-pointer border-r-[3px] border-ink bg-paper font-display text-[14px] font-black hover:bg-green hover:text-white', 'min-h-[52px] cursor-pointer border-r-[3px] border-ink font-display text-[15px] font-black',
member.state === 'present' ? 'bg-green text-white' : 'bg-cream',
)} )}
> >
{t('mobileLead.markPresent')} {t(member.state === 'present' ? 'mobileLead.badgePresent' : 'mobileLead.markPresent')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => onClick={() => mark(member, 'absent')}
setPresence((p) => ({ ...p, [member.id]: 'absent' })) aria-pressed={member.state === 'absent'}
} className={cx(
className="min-h-[52px] cursor-pointer bg-paper font-display text-[14px] font-black hover:bg-orange" 'min-h-[52px] cursor-pointer font-display text-[15px] font-black',
member.state === 'absent' ? 'bg-orange' : 'bg-cream',
)}
> >
{t('mobileLead.markAbsent')} {t(member.state === 'absent' ? 'mobileLead.badgeAbsent' : 'mobileLead.markAbsent')}
</button> </button>
</div> </div>
</div> </div>
))} ))}
{team.length === 0 ? ( {team.length === 0 ? (
<div className="font-mono text-[11px] leading-relaxed opacity-70"> <div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileLead.emptyTeam')} {t('mobileLead.emptyTeam')}
</div> </div>
) : null} ) : null}
</div> </div>
</div> </>
</Fragment>
);
})}
{nowInsertAt === positionShifts.length && positionShifts.length > 0 ? (
<NowLine />
) : null} ) : null}
{/* Message du poste : montré à chaque bénévole affecté, sur son
écran (créneau mis en avant et liste des créneaux). */}
<div className="border-t-[3px] border-ink bg-sand px-[14px] pb-7 pt-[14px] sm:px-6">
<div className="flex items-baseline gap-2">
<span className="font-display text-[12px] font-black tracking-wider">
{t('mobileLead.message.title')}
</span>
<span className="ml-auto font-mono text-[10px] font-medium opacity-65">
{t('mobileLead.message.audience', { count: teamSize })}
</span>
</div> </div>
<p className="mb-[9px] mt-1 font-mono text-[10.5px] leading-relaxed opacity-70">
{t('mobileLead.message.help')}
</p>
<MessageEditor
key={position.id}
value={position.message}
updatedAt={position.message_updated_at}
placeholder={t('mobileLead.message.placeholder')}
saving={updatePosition.isPending}
error={updatePosition.error}
onSave={(text) => updatePosition.mutateAsync({ id: position.id, body: { message: text } })}
/>
</div> </div>
);
})}
</div> </div>
</div> </div>
); );
} }
function Counter({ value, label, className }: { value: number; label: string; className: string }) {
return (
<div className={cx('border-ink px-3 py-[10px]', className)}>
<div className="font-mono text-[24px] font-bold leading-none">{value}</div>
<div className="mt-[3px] font-display text-[9.5px] font-extrabold leading-tight tracking-wider">{label}</div>
</div>
);
}
function ViewTab({
active,
live,
onClick,
children,
}: {
active: boolean;
/** Un créneau de cette vue est en cours : pastille orange. */
live: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cx(
'flex min-h-[46px] cursor-pointer items-center justify-center gap-[7px] border-r-[3px] border-ink px-2 font-display text-[12px] font-black uppercase tracking-wide last:border-r-0',
active ? 'bg-ink text-yellow' : 'bg-paper text-ink hover:bg-yellow-soft',
)}
>
{live ? <span className="h-[9px] w-[9px] flex-none border-2 border-ink bg-orange" aria-hidden="true" /> : null}
{children}
</button>
);
}
+6 -6
View File
@@ -191,7 +191,7 @@ function PaymentConfirmationView({ token }: { token: string }) {
}; };
return ( return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6"> <div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<Panel> <Panel>
<PanelHeader>{t('publicForm.confirmation.title')}</PanelHeader> <PanelHeader>{t('publicForm.confirmation.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-4"> <div className="flex flex-col gap-3 p-4">
@@ -273,7 +273,7 @@ function MecenatInfoStep({
}; };
return ( return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6"> <div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} /> <FormLogo url={logoUrl} />
<Panel> <Panel>
<PanelHeader>{t('publicForm.mecenat.title')}</PanelHeader> <PanelHeader>{t('publicForm.mecenat.title')}</PanelHeader>
@@ -333,7 +333,7 @@ function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string
}; };
return ( return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6"> <div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} /> <FormLogo url={logoUrl} />
<Panel> <Panel>
<PanelHeader>{t('publicForm.retry.title')}</PanelHeader> <PanelHeader>{t('publicForm.retry.title')}</PanelHeader>
@@ -406,7 +406,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
if (volunteerId) { if (volunteerId) {
return ( return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6"> <div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} /> <FormLogo url={logoUrl} />
<Panel> <Panel>
<PanelHeader>{t('publicForm.success.title')}</PanelHeader> <PanelHeader>{t('publicForm.success.title')}</PanelHeader>
@@ -437,7 +437,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
if (!config.data.is_open) { if (!config.data.is_open) {
return ( return (
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6"> <div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
<FormLogo url={logoUrl} /> <FormLogo url={logoUrl} />
<Panel> <Panel>
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader> <PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
@@ -524,7 +524,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
}; };
return ( return (
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4"> <div className="mx-auto flex max-w-[560px] flex-col gap-4 p-3 sm:p-4">
<FormLogo url={logoUrl} /> <FormLogo url={logoUrl} />
<Panel> <Panel>
<PanelHeader>{t('publicForm.title')}</PanelHeader> <PanelHeader>{t('publicForm.title')}</PanelHeader>
+101 -2
View File
@@ -1,5 +1,5 @@
import { useStats } from '@/api/hooks'; import { useStats } from '@/api/hooks';
import type { EditionStats } from '@/api/schema'; import type { EditionStats, StatsAttendanceCounts } from '@/api/schema';
import { import {
BarList, BarList,
CumulativeLineChart, CumulativeLineChart,
@@ -15,7 +15,8 @@ import { ErrorNote, Loading } from '@/components/state';
import { HelpTip, Panel, PanelHeader, StatCard } from '@/components/ui'; import { HelpTip, Panel, PanelHeader, StatCard } from '@/components/ui';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { foldAccents } from '@/lib/planning'; import { foldAccents } from '@/lib/planning';
import { longDayLabel, shortDayLabel } from '@/lib/time'; import { longDayLabel, naiveMinutes, shortDayLabel } from '@/lib/time';
import { clockLabel } from '@/lib/slotTimeline';
const fr = (n: number, digits = 0) => const fr = (n: number, digits = 0) =>
n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 }); n.toLocaleString('fr-FR', { maximumFractionDigits: digits, minimumFractionDigits: 0 });
@@ -62,6 +63,9 @@ export function StatsPage() {
/> />
</div> </div>
{/* Pendant le festival, la présence en direct passe en tête. */}
{s.attendance.live_shifts > 0 ? <AttendanceSection stats={s} /> : null}
{s.registrations.total === 0 ? ( {s.registrations.total === 0 ? (
<Panel> <Panel>
<p className="p-4 font-mono text-[12px]">{t('stats.empty')}</p> <p className="p-4 font-mono text-[12px]">{t('stats.empty')}</p>
@@ -76,6 +80,7 @@ export function StatsPage() {
</> </>
)} )}
<NeedsSection stats={s} /> <NeedsSection stats={s} />
{s.attendance.live_shifts === 0 ? <AttendanceSection stats={s} /> : null}
</div> </div>
); );
} }
@@ -309,3 +314,97 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
</Section> </Section>
); );
} }
function attendanceSegments(c: StatsAttendanceCounts) {
return [
{ label: t('stats.attendance.present'), value: c.present, color: SERIES.aqua },
{ label: t('stats.attendance.absent'), value: c.absent, color: SERIES.orange },
{ label: t('stats.attendance.unmarked'), value: c.unmarked, color: SERIES.muted },
];
}
/** Ligne de tableau : attendus, présents, absents, non pointés, et part de
* présents parmi les bénévoles déjà pointés. */
function attendanceCells(c: StatsAttendanceCounts) {
const marked = c.present + c.absent;
return [
fr(c.assigned),
fr(c.present),
c.absent ? <b key="a">{fr(c.absent)}</b> : '0',
fr(c.unmarked),
<span key="m" className="flex justify-end">
<Meter
value={c.present}
max={marked}
label={marked ? `${pct(c.present, marked)} %` : '—'}
title={t('stats.attendance.rateTip', { present: fr(c.present), marked: fr(marked) })}
/>
</span>,
];
}
function AttendanceSection({ stats }: { stats: EditionStats }) {
const a = stats.attendance;
const head = [
t('stats.col.position'),
t('stats.attendance.expected'),
t('stats.attendance.present'),
t('stats.attendance.absent'),
t('stats.attendance.unmarked'),
t('stats.attendance.rate'),
];
const liveRows = a.by_position.filter((p) => p.live_shifts > 0);
const pastRows = a.by_position.filter((p) => p.past.assigned > 0);
return (
<Section title={t('stats.attendance.title')} help={t('stats.attendance.help')}>
<div className="flex flex-wrap items-baseline gap-2">
<SubTitle>{t('stats.attendance.live')}</SubTitle>
<span className="ml-auto font-mono text-[10px] opacity-60">
{t('stats.attendance.asOf', { time: clockLabel(naiveMinutes(a.as_of)) })}
</span>
</div>
{a.live_shifts === 0 ? (
<p className="font-mono text-[11px] opacity-60">{t('stats.attendance.noLive')}</p>
) : (
<>
<p className="font-mono text-[11px]">
{t('stats.attendance.liveSummary', { shifts: fr(a.live_shifts), expected: fr(a.live.assigned) })}
</p>
<ShareBar segments={attendanceSegments(a.live)} />
<div className="overflow-x-auto">
<DataTable
head={head}
rows={liveRows.map((p) => [
`${p.name} · ${t('stats.attendance.shiftsCount', { count: p.live_shifts })}`,
...attendanceCells(p.live),
])}
/>
</div>
</>
)}
<SubTitle>{t('stats.attendance.past')}</SubTitle>
{a.past.assigned === 0 ? (
<p className="font-mono text-[11px] opacity-60">{t('stats.attendance.noPast')}</p>
) : (
<>
<ShareBar segments={attendanceSegments(a.past)} />
<div className="overflow-x-auto">
<DataTable
head={head}
rows={[
...pastRows.map((p) => [p.name, ...attendanceCells(p.past)]),
[<b key="t">{t('stats.payments.total')}</b>, ...attendanceCells(a.past)],
]}
/>
</div>
{a.past.unmarked > 0 ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('stats.attendance.unmarkedHint', { count: fr(a.past.unmarked) })}
</p>
) : null}
</>
)}
</Section>
);
}
+374 -74
View File
@@ -1,20 +1,36 @@
import { Fragment, useMemo } from 'react'; import { useMemo } from 'react';
import { useVolunteer, useVolunteerPlanning } from '@/api/hooks'; import { useDayParts, useEdition, useVolunteer, useVolunteerPlanning } from '@/api/hooks';
import type { VolunteerPlanningEntry } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state'; import { ErrorNote, Loading } from '@/components/state';
import { NowLine } from '@/components/NowLine';
import { VolunteerAvatar } from '@/components/VolunteerAvatar'; import { VolunteerAvatar } from '@/components/VolunteerAvatar';
import { hourRangeLabel, longDayLabel, naiveMinutes } from '@/lib/time'; import { cx } from '@/lib/cx';
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
import {
buildRibbon,
clockLabel,
countdownLabel,
durationLabel,
messageDateLabel,
overlapOf,
slotStatus,
type SlotStatus,
} from '@/lib/slotTimeline';
import { longDayLabel, naiveMinutes } from '@/lib/time';
import { useNowMinutes } from '@/lib/useNowMinutes'; import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { volunteerInitials } from '@/lib/volunteer';
/** /**
* Contenu de l'écran bénévole (planning : poste, jour, horaire, lieu, * Contenu de l'écran bénévole (maquette `design/Planning bénévole
* responsable et téléphone à contacter, repère « MAINTENANT ») — extrait de * mobile.dc.html`) : le créneau en cours ou le prochain mis en avant (compte
* `MobileVolunteerPage` pour être également montré à un compte RÉFÉRENT * à rebours, lieu, référent à appeler), puis le planning par journée de
* (`LEAD`) qui n'est actuellement désigné référent d'aucun poste : dans ce * festival (§ 3 — une nuit reste sur le jour où elle a commencé), chacune
* cas il doit voir sa vue de bénévole classique plutôt qu'un écran référent * avec sa frise horaire et ses créneaux (terminé / en cours / à venir,
* vide (voir `MobileLeadPage.tsx`). * chevauchements signalés). Pensé pour le téléphone ; sur un écran plus
* large, la même colonne, centrée. Extrait de `MobileVolunteerPage` pour
* être également montré à un compte RÉFÉRENT (`LEAD`) qui n'est
* actuellement désigné référent d'aucun poste (voir `MobileLeadPage.tsx`).
*/ */
export function VolunteerHomeView({ export function VolunteerHomeView({
volunteerId, volunteerId,
@@ -26,13 +42,20 @@ export function VolunteerHomeView({
}) { }) {
const meVolunteer = useVolunteer(volunteerId); const meVolunteer = useVolunteer(volunteerId);
const planning = useVolunteerPlanning(volunteerId); const planning = useVolunteerPlanning(volunteerId);
const nowMinutes = useNowMinutes(); const dayParts = useDayParts();
const edition = useEdition();
const now = useNowMinutes();
const boundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
// Hooks appelés inconditionnellement avant les retours anticipés ci-dessous. // Hooks appelés inconditionnellement avant les retours anticipés ci-dessous.
const slots = useMemo( const slots = useMemo(
() => () =>
[...(planning.data ?? [])].sort( (planning.data ?? [])
(a, b) => naiveMinutes(a.starts_at) - naiveMinutes(b.starts_at), .map((entry) => ({
), entry,
start: naiveMinutes(entry.starts_at),
end: naiveMinutes(entry.ends_at),
}))
.sort((a, b) => a.start - b.start),
[planning.data], [planning.data],
); );
@@ -41,91 +64,368 @@ export function VolunteerHomeView({
const firstName = meVolunteer.data?.first_name ?? fallbackName ?? ''; const firstName = meVolunteer.data?.first_name ?? fallbackName ?? '';
const initials = meVolunteer.data const initials = meVolunteer.data
? `${meVolunteer.data.first_name[0]}${meVolunteer.data.last_name[0]}` ? volunteerInitials(meVolunteer.data)
: (firstName[0] ?? '?'); : (firstName[0] ?? '?');
const totalHours = const days = new Map<string, Slot[]>();
Math.round( for (const slot of slots) {
slots.reduce((sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)) / 60, 0) * const day = festivalDay(slot.entry.starts_at, boundary);
10, days.set(day, [...(days.get(day) ?? []), slot]);
) / 10; }
const totalMinutes = slots.reduce((sum, s) => sum + s.end - s.start, 0);
// Le repère « maintenant » se place juste avant le premier créneau pas const featured = slots.find((s) => now < s.end) ?? null;
// encore commencé (le prochain à venir) ; en fin de liste si tous sont déjà
// passés.
const nowIndex = slots.findIndex((s) => naiveMinutes(s.starts_at) > nowMinutes);
const nowInsertAt = nowIndex === -1 ? slots.length : nowIndex;
return ( return (
<div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col"> <div className="mx-auto flex w-full max-w-[640px] flex-1 flex-col">
<div className="bg-ink px-4 py-5 text-cream sm:px-6 sm:py-7"> <div className="flex items-center gap-[14px] bg-ink p-4 text-cream sm:px-6 sm:py-6">
<div className="flex items-center gap-[12px]">
<VolunteerAvatar <VolunteerAvatar
volunteerId={volunteerId} volunteerId={volunteerId}
hasPhoto={meVolunteer.data?.has_photo ?? false} hasPhoto={meVolunteer.data?.has_photo ?? false}
initials={initials} initials={initials}
className="h-[52px] w-[52px] text-[18px]" className="h-[76px] w-[76px] !border-yellow !bg-orange text-[24px]"
/> />
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div> <div className="font-display text-[30px] font-black leading-none tracking-tight">
<div className="mt-[5px] font-display text-[28px] font-black leading-tight tracking-tight sm:text-[34px]"> {t('mobileVol.greeting', { name: firstName })}
{t('mobileVol.greeting', { name: firstName.toUpperCase() })}
</div> </div>
</div> <div className="mt-[6px] font-display text-[14px] font-semibold leading-snug text-sand-dark">
</div> {slots.length
<div className="mt-3 flex flex-wrap gap-2"> ? t(slots.length > 1 ? 'mobileVol.summary.other' : 'mobileVol.summary.one', {
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink"> count: slots.length,
{t('mobileVol.slots', { count: slots.length })} duration: durationLabel(totalMinutes),
</div> days: days.size,
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold"> })
{t('mobileVol.hours', { count: totalHours })} : t('mobileVol.summary.none')}
</div> </div>
</div> </div>
</div> </div>
<div className="flex flex-1 flex-col gap-[14px] p-4 sm:p-6"> {slots.length ? (
{slots.map((entry, i) => ( <div className="px-[14px] pt-4 sm:px-6">
<Fragment key={entry.assignment_id}> {featured ? (
{i === nowInsertAt ? <NowLine /> : null} <FeaturedSlot slot={featured} all={slots} now={now} boundary={boundary} />
<div 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"> <div className="border-[3px] border-ink bg-green p-4 font-display text-[19px] font-black leading-snug text-white shadow-brutal-lg">
<span className="font-display text-[15px] font-black"> {t('mobileVol.allDone', { name: firstName })}
{longDayLabel(entry.starts_at)}
</span>
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
{hourRangeLabel(entry.starts_at, entry.ends_at)}
</span>
</div> </div>
<div className="p-[11px]"> )}
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
{entry.position_name.toUpperCase()}
</div>
{entry.position_location ? (
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
{t('mobileVol.location', { location: entry.position_location })}
</div> </div>
) : null} ) : null}
{entry.referent_name ? (
<div className="mt-[9px] font-mono text-[12px]"> <div className="flex flex-1 flex-col gap-[22px] px-[14px] pb-7 pt-[22px] sm:px-6">
{t('mobileVol.referent', { name: entry.referent_name })} {[...days.entries()].map(([day, daySlots]) => (
</div> <DaySection
) : null} key={day}
{entry.referent_phone ? ( day={day}
<div className="mt-[3px] font-mono text-[12px]"> slots={daySlots}
{t('mobileVol.phone', { phone: entry.referent_phone })} all={slots}
</div> now={now}
) : null} featuredId={featured?.entry.assignment_id ?? null}
</div> />
</div>
</Fragment>
))} ))}
{nowInsertAt === slots.length && slots.length > 0 ? <NowLine /> : null}
{slots.length === 0 ? ( {slots.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70"> <div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
{t('mobileVol.noSlots')} {t('mobileVol.noSlots')}
</div> </div>
) : null} ) : null}
{edition.data?.volunteer_message ? (
<div className="border-[3px] border-ink bg-blue p-3">
<div className="font-display text-[12px] font-black leading-none tracking-wider">
{t('mobileVol.orgMessage.title')}
</div>
<div className="mt-[7px] whitespace-pre-line break-words font-display text-[14px] font-semibold leading-normal [text-wrap:pretty]">
{edition.data.volunteer_message}
</div>
{edition.data.volunteer_message_updated_at ? (
<div className="mt-2 font-mono text-[10.5px] font-medium uppercase leading-none">
{t('mobileVol.orgMessage.signature', {
when: messageDateLabel(edition.data.volunteer_message_updated_at, now),
})}
</div>
) : null}
</div>
) : null}
</div> </div>
</div> </div>
); );
} }
interface Slot {
entry: VolunteerPlanningEntry;
start: number;
end: number;
}
/** Premier autre créneau du bénévole qui chevauche `slot`, et sur quelle
* plage. */
function findOverlap(slot: Slot, all: Slot[]) {
for (const other of all) {
if (other.entry.assignment_id === slot.entry.assignment_id) continue;
const range = overlapOf(slot, other);
if (range) return { other, range };
}
return null;
}
function FeaturedSlot({
slot,
all,
now,
boundary,
}: {
slot: Slot;
all: Slot[];
now: number;
boundary: string;
}) {
const running = slotStatus(slot.start, slot.end, now) === 'now';
const overlap = findOverlap(slot, all.filter((s) => now < s.end));
const { entry } = slot;
return (
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div
className={cx(
'flex items-center gap-2 border-b-[3px] border-ink px-3 py-[9px]',
running ? 'bg-orange' : 'bg-yellow',
)}
>
<span className="font-display text-[12px] font-black tracking-wider">
{t(running ? 'mobileVol.featured.now' : 'mobileVol.featured.next')}
</span>
<span className="ml-auto font-mono text-[12px] font-bold">
{countdownLabel(slot.start, slot.end, now)}
</span>
</div>
<div className="px-[13px] pb-[14px] pt-[13px]">
<div className="font-display text-[13px] font-bold opacity-70">
{longDayLabel(`${festivalDay(entry.starts_at, boundary)}T12:00`)}
</div>
<div className="mt-[5px] break-words font-display text-[30px] font-black leading-[1.05] tracking-tight">
{entry.position_name.toUpperCase()}
</div>
<div className="mt-2 font-mono text-[24px] font-bold leading-none">
{clockLabel(slot.start)} → {clockLabel(slot.end)}
</div>
{entry.position_location || entry.position_message ? (
<div className="mt-[11px] flex flex-col gap-1 border-t-2 border-ink pt-[10px]">
{entry.position_location ? (
<div className="font-display text-[14px] font-bold leading-snug">{entry.position_location}</div>
) : null}
{entry.position_message ? <PositionMessage entry={entry} /> : null}
</div>
) : null}
{overlap ? (
<div className="mt-[11px] border-2 border-ink bg-yellow-soft px-[10px] py-2 font-display text-[12.5px] font-bold leading-snug">
{t('mobileVol.featured.overlap', {
position: overlap.other.entry.position_name,
start: clockLabel(overlap.range.start),
end: clockLabel(overlap.range.end),
})}
</div>
) : null}
{entry.referent_name ? <ReferentContact entry={entry} /> : null}
</div>
</div>
);
}
/** Message du poste (consignes du référent). */
function PositionMessage({ entry }: { entry: VolunteerPlanningEntry }) {
return (
<div className="whitespace-pre-line break-words font-mono text-[12px] font-medium leading-snug opacity-75">
{entry.position_message}
</div>
);
}
function ReferentContact({ entry }: { entry: VolunteerPlanningEntry }) {
const content = (
<>
<span className="min-w-0 flex-1">
<span className="block font-mono text-[10.5px] font-medium leading-snug opacity-70">
{t('mobileVol.featured.referent')}
</span>
<span className="block font-display text-[15px] font-extrabold leading-tight">
{entry.referent_name}
</span>
</span>
{entry.referent_phone ? (
<span className="flex-none bg-ink px-3 py-[10px] font-display text-[13px] font-black leading-none text-yellow">
{t('mobileVol.featured.call')}
</span>
) : null}
</>
);
const className =
'mt-3 flex min-h-[58px] items-center gap-[10px] border-[3px] border-ink bg-cream px-3 py-[6px]';
return entry.referent_phone ? (
<a href={`tel:${entry.referent_phone.replace(/\s+/g, '')}`} className={className}>
{content}
</a>
) : (
<div className={className}>{content}</div>
);
}
const BAR_COLOR: Record<SlotStatus, string> = {
done: 'bg-sand-mid',
now: 'bg-orange',
next: 'bg-ink',
};
const LANE_PX = 18;
function DaySection({
day,
slots,
all,
now,
featuredId,
}: {
day: string;
slots: Slot[];
all: Slot[];
now: number;
/** Créneau déjà mis en avant plus haut (son message y figure déjà). */
featuredId: string | null;
}) {
const ribbon = buildRibbon(slots, now);
const minutes = slots.reduce((sum, s) => sum + s.end - s.start, 0);
return (
<div>
<div className="flex items-baseline gap-2 border-b-[3px] border-ink pb-[6px]">
<span className="font-display text-[18px] font-black leading-none tracking-tight">
{longDayLabel(`${day}T12:00`)}
</span>
<span className="ml-auto font-mono text-[12px] font-semibold leading-none">
{t(slots.length > 1 ? 'mobileVol.day.other' : 'mobileVol.day.one', {
count: slots.length,
duration: durationLabel(minutes),
})}
</span>
</div>
<div
className="relative mb-1 mt-[10px]"
aria-hidden="true"
style={{
height: ribbon.lanes * LANE_PX,
// Une graduation par heure.
backgroundImage: `repeating-linear-gradient(90deg, transparent 0 calc(100% / ${ribbon.hours} - 1px), #CFC7B4 calc(100% / ${ribbon.hours} - 1px) calc(100% / ${ribbon.hours}))`,
}}
>
{ribbon.bars.map((bar, i) => (
<span
key={slots[i].entry.assignment_id}
className={cx('absolute h-[14px] border-2 border-ink', BAR_COLOR[slotStatus(slots[i].start, slots[i].end, now)])}
style={{ left: `${bar.left}%`, width: `${bar.width}%`, top: bar.lane * LANE_PX }}
/>
))}
{ribbon.now !== null ? (
<span className="absolute -bottom-1 -top-1 w-[3px] bg-orange" style={{ left: `${ribbon.now}%` }} />
) : null}
</div>
<div className="relative mb-[10px] h-[10px] font-mono text-[9.5px] font-medium leading-none opacity-65" aria-hidden="true">
{ribbon.ticks.map((tick, i) => (
<span
key={tick.label + i}
className="absolute"
style={{
left: `${tick.left}%`,
transform: tick.left === 0 ? undefined : tick.left >= 100 ? 'translateX(-100%)' : 'translateX(-50%)',
}}
>
{tick.label}
</span>
))}
</div>
<div className="flex flex-col gap-[9px]">
{slots.map((slot) => (
<SlotCard
key={slot.entry.assignment_id}
slot={slot}
all={all}
now={now}
showMessage={slot.entry.assignment_id !== featuredId}
/>
))}
</div>
</div>
);
}
function SlotCard({
slot,
all,
now,
showMessage,
}: {
slot: Slot;
all: Slot[];
now: number;
showMessage: boolean;
}) {
const status = slotStatus(slot.start, slot.end, now);
const overlap = status === 'done' ? null : findOverlap(slot, all);
const { entry } = slot;
return (
<div
className={cx(
'border-[3px] border-ink',
status === 'now' ? 'bg-orange-pale' : 'bg-paper',
status === 'done' && 'opacity-55',
)}
>
<div className="flex items-stretch">
<div
className={cx(
'flex w-[76px] flex-none flex-col items-center justify-center gap-[5px] border-r-[3px] border-ink py-[10px] font-mono text-[16px] font-bold leading-none',
status === 'now' ? 'bg-orange text-ink' : status === 'done' ? 'bg-sand-dark text-ink' : 'bg-ink text-yellow',
)}
>
<span>{clockLabel(slot.start)}</span>
<span className="h-[9px] w-[2px] bg-current opacity-60" aria-hidden="true" />
<span>{clockLabel(slot.end)}</span>
</div>
<div className="min-w-0 flex-1 px-[11px] py-[10px]">
<div className="flex items-start gap-[6px]">
<span className="min-w-0 flex-1 break-words font-display text-[18px] font-black leading-tight">
{entry.position_name}
</span>
{status === 'done' ? (
<span className="flex-none border-2 border-ink px-[6px] py-[3px] font-display text-[10px] font-black leading-none">
{t('mobileVol.slot.done')}
</span>
) : null}
{status === 'now' ? (
<span className="flex-none border-2 border-ink bg-orange px-[6px] py-[3px] font-display text-[10px] font-black leading-none">
{t('mobileVol.slot.now')}
</span>
) : null}
</div>
{entry.position_location ? (
<div className="mt-1 font-display text-[13px] font-semibold leading-snug">{entry.position_location}</div>
) : null}
<div className="mt-[2px] font-mono text-[11px] font-medium leading-snug opacity-70">
{durationLabel(slot.end - slot.start)}
{entry.referent_name ? t('mobileVol.slot.referent', { name: entry.referent_name }) : null}
</div>
{showMessage && status !== 'done' && entry.position_message ? (
<div className="mt-[6px] border-l-[3px] border-ink pl-2">
<PositionMessage entry={entry} />
</div>
) : null}
</div>
</div>
{overlap ? (
<div className="border-t-[3px] border-ink bg-yellow-soft px-[11px] py-2 font-display text-[12.5px] font-bold leading-snug">
{t('mobileVol.slot.overlap', {
position: overlap.other.entry.position_name,
start: clockLabel(overlap.range.start),
end: clockLabel(overlap.range.end),
})}
</div>
) : null}
</div>
);
}
+278 -92
View File
@@ -1,24 +1,78 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } from '@/api/hooks'; import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema'; import type { VolunteerRead } from '@/api/schema';
import { Chip, HelpTip } from '@/components/ui'; import { Chip, HelpTip } from '@/components/ui';
import { EmptyNote, ErrorNote, Loading } from '@/components/state'; import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer } from '@/lib/planning'; import {
import { datePart, shortDayLabel, timePart } from '@/lib/time'; buildTimeGrid,
columnWidthPx,
dayBoundaryFromDayParts,
festivalDay,
foldAccents,
hoursByVolunteer,
placeInTimeGrid,
type TimeGridDay,
} from '@/lib/planning';
import { clockLabel } from '@/lib/slotTimeline';
import { minutesOfDay, naiveMinutes, shortDayLabel, timePart } from '@/lib/time';
import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
import { volunteerName } from '@/lib/volunteer';
type Filter = 'all' | 'unassigned'; type Filter = 'all' | 'unassigned';
/** Ordre des lignes : celui de la liste des bénévoles (par nom), ou par
* total d'heures affectées, croissant ou décroissant. */
type Sort = 'name' | 'hoursAsc' | 'hoursDesc';
const NAME_W = 230;
const TOTAL_W = 84;
const SLOT_MINUTES = 30;
/** Hauteur d'une ligne de poste dans la rangée d'un bénévole : deux postes
* en même temps ⇒ deux lignes, la rangée s'agrandit. */
const LANE_H = 26;
const ROW_PAD = 3;
interface Bar {
assignmentId: string;
positionName: string;
start: number;
end: number;
/** Position horizontale (px) dans la piste des créneaux. */
left: number;
width: number;
lane: number;
/** Chevauche un autre créneau du même bénévole. */
overlap: boolean;
/** Le poste correspond à la recherche (barre mise en avant). */
match: boolean;
label: string;
}
/**
* Vue par bénévole (COORDINATION) : un vrai axe des temps, comme la grille
* des besoins (une colonne = 30 min, bornée par jour de festival à la plage
* utilisée — `buildTimeGrid`). Chaque affectation s'étire sur toute la
* durée de son créneau, avec le nom du poste ; deux postes tenus en même
* temps s'empilent sur deux lignes (la rangée s'agrandit) et sont signalés.
* Ligne rouge verticale à l'heure actuelle quand elle tombe dans la grille.
*/
export function VolunteerViewPage() { export function VolunteerViewPage() {
const volunteers = useVolunteers(); const volunteers = useVolunteers();
const assignments = useAssignments(); const assignments = useAssignments();
const shifts = useShifts(); const shifts = useShifts();
const positions = usePositions(); const positions = usePositions();
const dayParts = useDayParts(); const dayParts = useDayParts();
const now = useNowMinutes();
const [filter, setFilter] = useState<Filter>('all'); const [filter, setFilter] = useState<Filter>('all');
const [sort, setSort] = useState<Sort>('name');
// Recherche sur le nom du bénévole ou le nom d'un de ses postes, sans
// tenir compte des accents ni de la casse.
const [search, setSearch] = useState('');
const query = foldAccents(search.trim());
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const shiftsById = useMemo( const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])), () => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data], [shifts.data],
@@ -27,56 +81,90 @@ export function VolunteerViewPage() {
() => new Map((positions.data ?? []).map((p) => [p.id, p])), () => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data], [positions.data],
); );
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const lattice = useMemo(
() => buildLattice(shifts.data ?? [], dayBoundary),
[shifts.data, dayBoundary],
);
const hours = useMemo( const hours = useMemo(
() => hoursByVolunteer(assignments.data ?? [], shiftsById), () => hoursByVolunteer(assignments.data ?? [], shiftsById),
[assignments.data, shiftsById], [assignments.data, shiftsById],
); );
// Axe des temps : jours de festival, largeur de tranche par jour et
// décalage (px) de chaque jour dans la piste.
const axis = useMemo(() => {
const days = buildTimeGrid(shifts.data ?? [], dayBoundary);
let offset = 0;
const layout = days.map((day) => {
const slotW = columnWidthPx(day.slotLabels.length);
const entry = { day, slotW, offset, width: slotW * day.slotLabels.length };
offset += entry.width;
return entry;
});
return { days, layout, width: offset };
}, [shifts.data, dayBoundary]);
const rows = useMemo(() => { const rows = useMemo(() => {
const all = assignments.data ?? []; const byVolunteer = new Map<string, Array<{ id: string; shiftId: string }>>();
const byVolunteer = new Map<string, AssignmentRead[]>(); for (const a of assignments.data ?? []) {
for (const a of all) { byVolunteer.set(a.volunteer_id, [...(byVolunteer.get(a.volunteer_id) ?? []), { id: a.id, shiftId: a.shift_id }]);
const list = byVolunteer.get(a.volunteer_id) ?? [];
list.push(a);
byVolunteer.set(a.volunteer_id, list);
} }
return (volunteers.data ?? []) const layoutByDay = new Map(axis.layout.map((l) => [l.day.date, l]));
.filter((v) => { const built = (volunteers.data ?? [])
const own = byVolunteer.get(v.id) ?? []; .filter((v) => filter === 'all' || !(byVolunteer.get(v.id)?.length))
if (filter === 'unassigned') return own.length === 0;
return true;
})
.map((v) => { .map((v) => {
const own = byVolunteer.get(v.id) ?? []; const items = (byVolunteer.get(v.id) ?? []).flatMap(({ id, shiftId }) => {
const cellByKey = new Map<string, AssignmentRead & { shift: ShiftRead }>(); const shift = shiftsById.get(shiftId);
for (const a of own) { return shift ? [{ id, shift, starts_at: shift.starts_at, ends_at: shift.ends_at }] : [];
const shift = shiftsById.get(a.shift_id); });
if (shift) const bars: Bar[] = [];
cellByKey.set( for (const [day, placements] of placeInTimeGrid(axis.days, items, dayBoundary)) {
`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`, const l = layoutByDay.get(day);
{ ...a, shift }, if (!l) continue;
); for (const p of placements) {
const { shift } = p.item;
bars.push({
assignmentId: p.item.id,
positionName: positionsById.get(shift.position_id)?.name ?? '?',
start: naiveMinutes(shift.starts_at),
end: naiveMinutes(shift.ends_at),
left: l.offset + p.columnStart * l.slotW,
width: p.columnSpan * l.slotW,
lane: 0,
overlap: false,
match: false,
label: `${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`,
});
} }
}
// Lignes : un créneau prend la première ligne libre à son début.
bars.sort((a, b) => a.start - b.start || a.end - b.end);
const laneEnds: number[] = [];
for (const bar of bars) {
let lane = laneEnds.findIndex((end) => end <= bar.start);
if (lane === -1) lane = laneEnds.length;
laneEnds[lane] = bar.end;
bar.lane = lane;
bar.overlap = bars.some((o) => o !== bar && o.start < bar.end && bar.start < o.end);
}
const nameMatch = !query || foldAccents(`${v.first_name} ${v.last_name}`).includes(query);
for (const bar of bars) bar.match = Boolean(query) && foldAccents(bar.positionName).includes(query);
return { return {
volunteer: v, volunteer: v,
cellByKey, visible: nameMatch || bars.some((b) => b.match),
bars,
lanes: Math.max(1, laneEnds.length),
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10, total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
empty: own.length === 0, empty: items.length === 0,
}; };
}); });
}, [volunteers.data, assignments.data, shiftsById, hours, filter]); const shown = built.filter((r) => r.visible);
if (sort === 'name') return shown;
const direction = sort === 'hoursAsc' ? 1 : -1;
// `sort` est stable : à total égal, l'ordre par nom est conservé.
return shown.sort((a, b) => direction * (a.total - b.total));
}, [volunteers.data, assignments.data, shiftsById, positionsById, hours, axis, dayBoundary, filter, sort, query]);
if ( // Position (px) de l'heure actuelle dans la piste, `null` hors grille.
volunteers.isLoading || const nowX = useMemo(() => nowPosition(axis.layout, now, dayBoundary), [axis.layout, now, dayBoundary]);
shifts.isLoading ||
assignments.isLoading || if (volunteers.isLoading || shifts.isLoading || assignments.isLoading || positions.isLoading)
positions.isLoading
)
return <Loading />; return <Loading />;
const error = volunteers.error ?? shifts.error ?? assignments.error ?? positions.error; const error = volunteers.error ?? shifts.error ?? assignments.error ?? positions.error;
if (error) return <ErrorNote error={error} />; if (error) return <ErrorNote error={error} />;
@@ -89,12 +177,19 @@ export function VolunteerViewPage() {
); );
} }
const gridTemplateColumns = `230px repeat(${lattice.columns.length}, 62px) 84px`;
const abbr = (positionId: string) => (positionsById.get(positionId)?.name ?? positionId).slice(0, 4).toUpperCase();
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <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 flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
<div className="relative w-full sm:w-[260px]">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('volunteers.search.placeholder')}
aria-label={t('volunteers.search.label')}
className="w-full border-2 border-ink bg-cream px-[9px] py-[6px] font-mono text-[12px]"
/>
</div>
<div className="flex items-center gap-[6px]"> <div className="flex items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('volunteers.filter.show')}</span> <span className="font-mono text-[9px] opacity-60">{t('volunteers.filter.show')}</span>
<Chip active={filter === 'all'} onClick={() => setFilter('all')}> <Chip active={filter === 'all'} onClick={() => setFilter('all')}>
@@ -104,58 +199,101 @@ export function VolunteerViewPage() {
{t('volunteers.filter.unassigned')} {t('volunteers.filter.unassigned')}
</Chip> </Chip>
</div> </div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]"> <div className="flex items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('volunteers.sort.label')}</span>
<Chip active={sort === 'name'} onClick={() => setSort('name')}>
{t('volunteers.sort.name')}
</Chip>
<Chip active={sort === 'hoursAsc'} onClick={() => setSort('hoursAsc')}>
{t('volunteers.sort.hoursAsc')}
</Chip>
<Chip active={sort === 'hoursDesc'} onClick={() => setSort('hoursDesc')}>
{t('volunteers.sort.hoursDesc')}
</Chip>
</div>
<div className="ml-auto flex flex-wrap 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> <span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
<LegendItem swatch="bg-blue border-ink">{t('volunteers.legend.assigned')}</LegendItem> <LegendItem swatch="bg-blue border-ink">{t('volunteers.legend.assigned')}</LegendItem>
<LegendItem swatch="bg-yellow border-ink">{t('volunteers.legend.overlap')}</LegendItem>
<LegendItem swatch="bg-orange border-orange !w-[3px]">{t('volunteers.legend.now')}</LegendItem>
</div> </div>
</div> </div>
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto"> <div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyNote>{t('volunteers.empty')}</EmptyNote> <EmptyNote>{query ? t('volunteers.search.empty', { query: search.trim() }) : t('volunteers.empty')}</EmptyNote>
) : ( ) : (
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}> <div className="w-max border-b-[3px] border-ink">
<div className="sticky left-0 top-0 z-[7] flex items-center gap-[6px] 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"> {/* En-tête : jours, puis tranches de 30 min. */}
<div className="sticky top-0 z-[7] flex h-[27px]">
<div
className="sticky left-0 z-[8] flex flex-none items-center gap-[6px] border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 font-display text-[10px] font-extrabold uppercase tracking-wider text-cream"
style={{ width: NAME_W }}
>
{t('volunteers.count', { count: rows.length })} {t('volunteers.count', { count: rows.length })}
<HelpTip text={t('volunteers.footnote')} side="bottom" /> <HelpTip text={t('volunteers.footnote')} side="bottom" />
</div> </div>
{lattice.days.map((day) => ( {axis.layout.map(({ day, width }) => (
<div <div
key={day.date} key={day.date}
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" className="flex flex-none items-center border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] text-cream"
style={{ gridColumn: `span ${day.columns.length}` }} style={{ width }}
> >
<span className="font-display text-[12px] font-black"> <span className="font-display text-[12px] font-black">{shortDayLabel(`${day.date}T12:00`)}</span>
{shortDayLabel(`${day.date}T12:00`)}
</span>
</div> </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"> <button
type="button"
onClick={() => setSort(sort === 'hoursDesc' ? 'hoursAsc' : 'hoursDesc')}
title={t('volunteers.sort.toggle')}
className="sticky right-0 z-[8] flex flex-none cursor-pointer items-center gap-1 border-b-2 border-l-[3px] border-ink bg-yellow px-2 font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-yellow-soft"
style={{ width: TOTAL_W }}
>
{t('volunteers.totalHours')} {t('volunteers.totalHours')}
{sort === 'hoursAsc' ? ' ↑' : sort === 'hoursDesc' ? ' ↓' : null}
</button>
</div> </div>
<div className="sticky top-[27px] z-[6] flex">
<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]"> <div
className="sticky left-0 z-[8] flex flex-none items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px]"
style={{ width: NAME_W }}
>
{t('volunteers.name')} {t('volunteers.name')}
</div> </div>
{lattice.columns.map((column) => ( <div className="relative flex flex-none border-b-[3px] border-ink bg-sand" style={{ width: axis.width }}>
{axis.layout.map(({ day, slotW }) =>
day.slotLabels.map((label, i) => (
<div <div
key={column.key} key={`${day.date}-${i}`}
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]" className={cx(
'flex-none overflow-hidden whitespace-nowrap border-r border-ink py-[5px] text-center font-mono text-[9.5px]',
i === day.slotLabels.length - 1 && 'border-r-[3px]',
)}
style={{ width: slotW }}
> >
{column.time} {label}
</div> </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]"> )}
{nowX !== null ? (
<span
className="absolute top-1/2 z-[1] -translate-x-1/2 -translate-y-1/2 bg-orange px-[5px] py-[2px] font-mono text-[9.5px] font-bold text-white"
style={{ left: nowX }}
>
{clockLabel(now)}
</span>
) : null}
</div>
<div
className="sticky right-0 z-[8] flex flex-none items-center justify-center border-b-[3px] border-l-[3px] border-ink bg-yellow-soft text-center font-mono text-[8.5px]"
style={{ width: TOTAL_W }}
>
{t('volunteers.cumulative')} {t('volunteers.cumulative')}
</div> </div>
</div>
{rows.map((row) => ( {rows.map((row) => (
<VolunteerRow <VolunteerRow key={row.volunteer.id} row={row} layout={axis.layout} width={axis.width} nowX={nowX} />
key={row.volunteer.id}
row={row}
columns={lattice.columns}
abbr={abbr}
/>
))} ))}
</div> </div>
)} )}
@@ -164,6 +302,20 @@ export function VolunteerViewPage() {
); );
} }
function nowPosition(
layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>,
now: number,
dayBoundary: string,
): number | null {
const iso = new Date(now * 60_000).toISOString().slice(0, 16);
const entry = layout.find((l) => l.day.date === festivalDay(iso, dayBoundary));
if (!entry) return null;
const boundary = minutesOfDay(dayBoundary);
const offset = ((minutesOfDay(timePart(iso)) - boundary + 1440) % 1440) - entry.day.startOffset;
if (offset < 0 || offset > entry.day.slotLabels.length * SLOT_MINUTES) return null;
return entry.offset + (offset / SLOT_MINUTES) * entry.slotW;
}
function LegendItem({ swatch, children }: { swatch: string; children: React.ReactNode }) { function LegendItem({ swatch, children }: { swatch: string; children: React.ReactNode }) {
return ( return (
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold"> <span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
@@ -173,27 +325,26 @@ function LegendItem({ swatch, children }: { swatch: string; children: React.Reac
); );
} }
type Row = {
volunteer: VolunteerRead;
cellByKey: Map<string, AssignmentRead & { shift: ShiftRead }>;
total: number;
empty: boolean;
};
function VolunteerRow({ function VolunteerRow({
row, row,
columns, layout,
abbr, width,
nowX,
}: { }: {
row: Row; row: { volunteer: VolunteerRead; bars: Bar[]; lanes: number; total: number; empty: boolean };
columns: { key: string }[]; layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>;
abbr: (positionId: string) => string; width: number;
nowX: number | null;
}) { }) {
const height = row.lanes * LANE_H + ROW_PAD * 2;
return ( return (
<> <div className="flex" style={{ height }}>
<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"> <div
className="sticky left-0 z-[4] flex flex-none items-center gap-[6px] border-b border-r-[3px] border-ink bg-cream px-2"
style={{ width: NAME_W }}
>
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold"> <span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
{row.volunteer.first_name} {row.volunteer.last_name} {volunteerName(row.volunteer)}
</span> </span>
{row.empty ? ( {row.empty ? (
<span className="flex-none border-[1.5px] border-ink bg-blue px-1 py-[2px] font-display text-[8px] font-extrabold"> <span className="flex-none border-[1.5px] border-ink bg-blue px-1 py-[2px] font-display text-[8px] font-extrabold">
@@ -201,21 +352,56 @@ function VolunteerRow({
</span> </span>
) : null} ) : null}
</div> </div>
{columns.map((column) => {
const cell = row.cellByKey.get(column.key); <div className="relative flex-none border-b border-ink bg-sand" style={{ width }}>
if (!cell) return <div key={column.key} className="h-8 border-b border-r border-ink bg-sand" />; {/* Tranches de 30 min, filet épais entre deux jours. */}
return ( {layout.map((l) => (
<span
key={l.day.date}
className="absolute inset-y-0 border-r-[3px] border-ink"
style={{
left: l.offset,
width: l.width,
backgroundImage: `repeating-linear-gradient(90deg, transparent 0 ${l.slotW - 1}px, rgba(20,17,14,.35) ${l.slotW - 1}px ${l.slotW}px)`,
}}
aria-hidden="true"
/>
))}
{row.bars.map((bar) => (
<div <div
key={column.key} key={bar.assignmentId}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold" title={
bar.overlap
? t('volunteers.bar.overlapTitle', { position: bar.positionName, time: bar.label })
: t('volunteers.bar.title', { position: bar.positionName, time: bar.label })
}
className={cx(
'absolute z-[2] flex items-center overflow-hidden border-2 border-ink px-[5px] font-display text-[10px] font-extrabold leading-none',
bar.overlap ? 'bg-yellow' : 'bg-blue',
// Poste trouvé par la recherche : cerné d'orange.
bar.match && 'outline outline-[3px] outline-offset-0 outline-orange',
)}
style={{
left: bar.left + 1,
width: Math.max(4, bar.width - 2),
top: ROW_PAD + bar.lane * LANE_H,
height: LANE_H - 3,
}}
> >
{abbr(cell.shift.position_id)} <span className="truncate">{bar.positionName}</span>
</div> </div>
); ))}
})} {nowX !== null ? (
<div className="sticky right-0 z-[2] flex items-center justify-center border-b border-l-[3px] border-ink bg-paper font-mono text-[13px] font-bold"> <span className="absolute inset-y-0 z-[3] w-[2px] -translate-x-1/2 bg-orange" style={{ left: nowX }} aria-hidden="true" />
) : null}
</div>
<div
className="sticky right-0 z-[4] flex flex-none items-center justify-center border-b border-l-[3px] border-ink bg-paper font-mono text-[13px] font-bold"
style={{ width: TOTAL_W }}
>
{row.total} {row.total}
</div> </div>
</> </div>
); );
} }
+477
View File
@@ -0,0 +1,477 @@
import { useRef, useState } from 'react';
import type { BadgeFace, VolunteerRead } from '@/api/schema';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
import {
CELL_PAD_X_MM,
CELL_PAD_Y_MM,
FIRST_BASELINE,
LINE_HEIGHT,
MM_PER_PT,
type TableLayout,
type TextLayout,
} from './layout';
import {
FACE_LABEL_KEY,
fmt,
isVerticallyCentered,
renderField,
SAFE_MARGIN_MM,
stackOrder,
type BadgeData,
type FieldState,
} from './model';
/** Pas d'accroche du glisser-déposer (mm). */
const DRAG_SNAP_MM = 0.5;
/** Plus petit cadre possible au redimensionnement (mm). */
const MIN_W_MM = 3;
const MIN_H_MM = 2;
/** Décalage entre la ligne de base d'une ligne CSS (interligne 1,25, police
* Helvetica/Arial) et celle du PDF (0,85 × taille sous le haut du cadre). */
const CSS_BASELINE = 0.97;
const FONT_STACK = 'Helvetica, Arial, sans-serif';
function snap(value: number): number {
return Math.round(value / DRAG_SNAP_MM) * DRAG_SNAP_MM;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export interface BadgeCanvasProps {
face: BadgeFace;
fields: FieldState[];
data: BadgeData;
volunteer: VolunteerRead | null;
photoUrl: string | null;
imageUrl: string | null;
badgeW: number;
badgeH: number;
scale: number;
showGuides: boolean;
selectedType: string | null;
uploading: boolean;
onSelect: (field: FieldState) => void;
/** `record` : enregistrer l'état actuel dans l'historique d'annulation
* avant d'appliquer `patch` (une seule fois par glisser-déposer). */
onChange: (field: FieldState, patch: Partial<FieldState>, record: boolean) => void;
onDropImage: (file: File) => void;
}
/** Aperçu d'une face du badge, éditable (glisser, redimensionner) et zone de
* dépôt de son image de fond. */
export function BadgeCanvas({
face,
fields,
data,
volunteer,
photoUrl,
imageUrl,
badgeW,
badgeH,
scale,
showGuides,
selectedType,
uploading,
onSelect,
onChange,
onDropImage,
}: BadgeCanvasProps) {
const [dropping, setDropping] = useState(false);
// Du dessous vers le dessus, comme le PDF (`z_order`).
const shown = stackOrder(fields, face);
const isFaceSelected = shown.some((f) => f.field_type === selectedType);
return (
<div className="flex min-w-0 flex-col gap-[9px]">
<div className="flex items-baseline gap-2">
<span
className={cx(
'border-2 border-ink px-2 py-1 font-display text-[11px] font-black uppercase tracking-wider',
isFaceSelected ? 'bg-ink text-yellow' : 'bg-paper text-ink',
)}
>
{t(FACE_LABEL_KEY[face])}
</span>
<span className="font-mono text-[10px] opacity-70">
{t('badges.dims', { width: fmt(badgeW), height: fmt(badgeH) })}
</span>
</div>
<div
className={cx(
'relative flex-none select-none border-[3px] border-ink bg-sand-dark bg-contain bg-center bg-no-repeat shadow-brutal-lg',
dropping && 'outline outline-[3px] outline-offset-2 outline-blue-strong',
)}
// + 6 : bordure de 3 px de chaque côté (les champs se placent dans
// la boîte intérieure, à l'échelle exacte du badge).
style={{
width: badgeW * scale + 6,
height: badgeH * scale + 6,
backgroundImage: imageUrl ? `url(${imageUrl})` : undefined,
}}
onDragOver={(e) => {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
setDropping(true);
}}
onDragLeave={() => setDropping(false)}
onDrop={(e) => {
e.preventDefault();
setDropping(false);
const file = e.dataTransfer.files[0];
if (file) onDropImage(file);
}}
>
{/* Invitation à déposer une image : seulement sur une face encore
vide (elle passerait sous les champs sinon), ou par-dessus tout
pendant un dépôt / un envoi. */}
{uploading || dropping || (!imageUrl && shown.length === 0) ? (
<div
className={cx(
'pointer-events-none absolute inset-0 flex items-center justify-center p-3 text-center font-mono text-[10px] font-medium leading-relaxed',
uploading || dropping ? 'z-20 bg-blue/80 text-ink' : 'text-ink/60',
)}
>
{uploading
? t('badges.uploading')
: t(face === 'FRONT' ? 'badges.drop.front' : 'badges.drop.back')}
</div>
) : null}
{showGuides ? (
<div
className="pointer-events-none absolute border-[1.5px] border-dashed border-yellow opacity-90"
style={{ inset: SAFE_MARGIN_MM * scale }}
/>
) : null}
{shown.map((field, i) => (
<FieldBox
key={field.field_type}
field={field}
layer={i + 1}
data={data}
volunteer={volunteer}
photoUrl={photoUrl}
badgeW={badgeW}
badgeH={badgeH}
scale={scale}
showGuides={showGuides}
selected={selectedType === field.field_type}
onSelect={() => onSelect(field)}
onChange={(patch, record) => onChange(field, patch, record)}
/>
))}
</div>
</div>
);
}
function FieldBox({
field,
layer,
data,
volunteer,
photoUrl,
badgeW,
badgeH,
scale,
showGuides,
selected,
onSelect,
onChange,
}: {
field: FieldState;
/** Rang dans la pile de la face (`z-index`). */
layer: number;
data: BadgeData;
volunteer: VolunteerRead | null;
photoUrl: string | null;
badgeW: number;
badgeH: number;
scale: number;
showGuides: boolean;
selected: boolean;
onSelect: () => void;
onChange: (patch: Partial<FieldState>, record: boolean) => void;
}) {
const dragRef = useRef<{
mode: 'move' | 'resize';
x: number;
y: number;
w: number;
h: number;
px: number;
py: number;
moved: boolean;
} | null>(null);
const start = (mode: 'move' | 'resize') => (e: React.PointerEvent) => {
e.stopPropagation();
e.preventDefault();
onSelect();
(e.currentTarget as Element).setPointerCapture(e.pointerId);
dragRef.current = {
mode,
x: field.x_mm,
y: field.y_mm,
w: field.width_mm,
h: field.height_mm,
px: e.clientX,
py: e.clientY,
moved: false,
};
};
const move = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
e.stopPropagation();
const dx = (e.clientX - d.px) / scale;
const dy = (e.clientY - d.py) / scale;
if (!d.moved && Math.abs(dx) < 0.2 && Math.abs(dy) < 0.2) return;
const record = !d.moved;
d.moved = true;
if (d.mode === 'move') {
// Bornes jamais négatives : un champ plus grand que le badge reste
// saisissable (voir `fitToBadge`).
onChange(
{
x_mm: clamp(snap(d.x + dx), 0, Math.max(0, badgeW - d.w)),
y_mm: clamp(snap(d.y + dy), 0, Math.max(0, badgeH - d.h)),
},
record,
);
} else {
const maxW = Math.max(0, badgeW - d.x);
const maxH = Math.max(0, badgeH - d.y);
onChange(
{
width_mm: clamp(snap(d.w + dx), Math.min(MIN_W_MM, maxW), maxW),
height_mm: clamp(snap(d.h + dy), Math.min(MIN_H_MM, maxH), maxH),
},
record,
);
}
};
const end = (e: React.PointerEvent) => {
if (dragRef.current) e.stopPropagation();
dragRef.current = null;
};
const rendered = volunteer ? renderField(field, volunteer, data) : null;
const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null;
const truncated = layout?.truncated ?? false;
const geometry = {
left: field.x_mm * scale,
top: field.y_mm * scale,
width: field.width_mm * scale,
height: field.height_mm * scale,
};
const handlers = {
onPointerDown: start('move'),
onPointerMove: move,
onPointerUp: end,
onPointerCancel: end,
};
return (
<>
{/* Contenu à son rang dans la pile, comme sur le badge imprimé. */}
<div
{...(selected ? {} : handlers)}
className={cx('absolute touch-none', selected ? 'pointer-events-none' : 'cursor-move')}
style={{
...geometry,
outline: selected
? 'none'
: truncated
? '2px dashed #FF5C39'
: showGuides
? '1px dashed rgba(20,17,14,.45)'
: 'none',
zIndex: layer,
}}
>
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} />
) : rendered?.kind === 'table' ? (
<TableContent field={field} layout={rendered.layout} scale={scale} />
) : rendered?.kind === 'text' ? (
<TextContent field={field} layout={rendered.layout} scale={scale} />
) : null}
{truncated && !selected ? <TruncatedTag /> : null}
</div>
{/* Cadre de sélection par-dessus toute la pile : un champ recouvert
(ex. la photo sous un texte) reste saisissable, sans que l'aperçu
change l'ordre d'impression. */}
{selected ? (
<div
{...handlers}
className="absolute z-[15] cursor-move touch-none"
style={{ ...geometry, outline: '2px solid #2A6BFF' }}
>
{truncated ? <TruncatedTag /> : null}
<span className="pointer-events-none absolute -bottom-[19px] left-0 z-10 whitespace-nowrap bg-blue-strong px-[5px] py-[2px] font-mono text-[9px] font-semibold leading-tight text-white">
{t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })}
{layout?.reduced
? t('badges.preview.reducedTo', { size: fmt(layout.fontSize) })
: null}
</span>
<span
onPointerDown={start('resize')}
onPointerMove={move}
onPointerUp={end}
onPointerCancel={end}
className="absolute -bottom-[7px] -right-[7px] h-[15px] w-[15px] cursor-nwse-resize touch-none border-2 border-ink bg-yellow lg:h-[13px] lg:w-[13px]"
/>
</div>
) : null}
</>
);
}
function TruncatedTag() {
return (
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none">
{t('badges.preview.truncated')}
</span>
);
}
function TextContent({ field, layout, scale }: { field: FieldState; layout: TextLayout; scale: number }) {
const fontPx = layout.fontSize * MM_PER_PT * scale;
const lineHeightPx = fontPx * LINE_HEIGHT;
// Bandeaux (texte libre, rôle) centrés verticalement, les autres en haut —
// même calcul que le PDF : bloc = interlignes + une hauteur de caractère.
const blockPx = Math.max(0, layout.visible.length - 1) * lineHeightPx + fontPx;
const boxPx = field.height_mm * scale;
const offset = isVerticallyCentered(field.field_type) ? Math.max(0, (boxPx - blockPx) / 2) : 0;
const top = offset + (FIRST_BASELINE - CSS_BASELINE) * fontPx;
return (
<div
className="pointer-events-none relative h-full w-full overflow-hidden"
style={{ background: field.bg_color ?? 'transparent' }}
>
<div
className="absolute inset-x-0"
style={{
top,
color: field.color,
fontFamily: FONT_STACK,
fontSize: fontPx,
lineHeight: `${lineHeightPx}px`,
fontWeight: field.bold ? 700 : 400,
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}}
>
{layout.visible.map((line, i) => (
<div key={i} className="whitespace-pre">
{line || ' '}
</div>
))}
</div>
</div>
);
}
/** Tableau des créneaux, mêmes colonnes, hauteurs de ligne et lignes
* retenues que le PDF (`_draw_table`). */
function TableContent({ field, layout, scale }: { field: FieldState; layout: TableLayout; scale: number }) {
const fontPx = layout.fontSize * MM_PER_PT * scale;
const lineHeightPx = fontPx * LINE_HEIGHT;
const rule = `${Math.max(0.5, 0.4 * MM_PER_PT * scale)}px solid ${field.color}`;
const padX = CELL_PAD_X_MM * scale;
const padY = CELL_PAD_Y_MM * scale;
const fullWidth = layout.widthsMm.reduce((a, b) => a + b, 0) * scale;
return (
<div
className="pointer-events-none h-full w-full overflow-hidden"
style={{ background: field.bg_color ?? 'transparent' }}
>
{layout.rows.length ? (
<div
style={{
width: fullWidth,
border: rule,
color: field.color,
fontFamily: FONT_STACK,
fontSize: fontPx,
lineHeight: `${lineHeightPx}px`,
boxSizing: 'border-box',
}}
>
{layout.rows.map((row, i) => (
<div
key={i}
className="flex"
style={{
height: row.heightMm * scale,
borderTop: i > 0 ? rule : undefined,
fontWeight: row.isTitle ? 700 : 400,
}}
>
{row.cells.map((lines, col) => (
<div
key={col}
className="flex-none overflow-hidden"
style={{
width: row.isTitle ? '100%' : layout.widthsMm[col] * scale,
padding: `${padY}px ${padX}px`,
borderLeft: col > 0 ? rule : undefined,
boxSizing: 'border-box',
}}
>
{lines.map((line, j) => (
<div key={j} className="whitespace-pre">
{line || ' '}
</div>
))}
</div>
))}
</div>
))}
</div>
) : null}
</div>
);
}
function PhotoContent({
field,
volunteer,
photoUrl,
}: {
field: FieldState;
volunteer: VolunteerRead | null;
photoUrl: string | null;
}) {
const radius = field.photo_shape === 'OVAL' ? '50%' : 0;
if (volunteer?.has_photo && photoUrl) {
return (
<img
src={photoUrl}
alt=""
draggable={false}
className="pointer-events-none h-full w-full object-cover"
style={{ borderRadius: radius }}
/>
);
}
// Sans photo, rien n'est imprimé : juste un repère pointillé dans
// l'éditeur (pas sur le badge) pour savoir où est le cadre.
return (
<div
className="pointer-events-none flex h-full w-full items-center justify-center border border-dashed border-ink/40 text-center font-mono text-[9px] leading-tight text-ink/50"
style={{ borderRadius: radius }}
>
{t('badges.preview.noPhoto')}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,469 @@
import { useEffect, useState, type ReactNode } from 'react';
import type { BadgeAlign, BadgeFieldType } from '@/api/schema';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
import { contrastRatio, FONT_STEP_PT, MAX_FONT_PT, MIN_CONTRAST, MIN_FONT_PT, MM_PER_PT } from './layout';
import { FACE_LABEL_KEY, FIELD_LABEL_KEY, fitToBadge, fmt, round1, type FieldState, type StackMove } from './model';
const TEXT_COLORS: Array<{ hex: string; key: string }> = [
{ hex: '#FFFFFF', key: 'badges.color.white' },
{ hex: '#14110E', key: 'badges.color.black' },
{ hex: '#FFD23F', key: 'badges.color.yellow' },
{ hex: '#FF5C39', key: 'badges.color.coral' },
];
const BG_COLORS: Array<{ hex: string | null; key: string }> = [
{ hex: null, key: 'badges.color.none' },
{ hex: '#FFFFFF', key: 'badges.color.white' },
{ hex: '#14110E', key: 'badges.color.black' },
{ hex: '#FFD23F', key: 'badges.color.yellow' },
];
const ALIGNS: Array<{ value: BadgeAlign; key: string; items: string }> = [
{ value: 'LEFT', key: 'badges.align.left', items: 'items-start' },
{ value: 'CENTER', key: 'badges.align.center', items: 'items-center' },
{ value: 'RIGHT', key: 'badges.align.right', items: 'items-end' },
];
function sameColor(a: string | null, b: string | null): boolean {
return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
}
function Section({ title, aside, children }: { title: string; aside?: ReactNode; children: ReactNode }) {
return (
<div>
<div className="mb-[6px] flex items-baseline">
<span className="font-display text-[9.5px] font-extrabold uppercase tracking-widest">{title}</span>
{aside ? <span className="ml-auto font-mono text-[9px] uppercase opacity-60">{aside}</span> : null}
</div>
{children}
</div>
);
}
function Segment({
active,
onClick,
children,
className,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cx(
'cursor-pointer border-0 transition-colors',
active ? 'bg-ink text-yellow' : 'bg-cream text-ink hover:bg-yellow-soft',
className,
)}
>
{children}
</button>
);
}
function Swatch({
hex,
label,
active,
onClick,
}: {
hex: string | null;
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className="flex cursor-pointer flex-col items-center gap-1 border-0 bg-transparent p-0"
>
<span
className={cx(
'flex h-[30px] w-full items-center justify-center border-2 border-ink font-display text-[12px] font-black',
hex === null && 'bg-hatch-sand',
hex === '#14110E' ? 'text-yellow' : 'text-ink',
)}
style={{
background: hex ?? undefined,
boxShadow: active ? '0 0 0 3px #2A6BFF' : undefined,
}}
>
{active ? '✓' : ''}
</span>
<span className="font-display text-[9.5px] font-semibold">{label}</span>
</button>
);
}
/** Saisie d'une coordonnée en mm, appliquée à la validation (sortie du
* champ ou Entrée) pour ne pas bousculer la saisie en cours. */
function MmInput({ label, value, onCommit }: { label: string; value: number; onCommit: (v: number) => void }) {
const [text, setText] = useState(fmt(value));
useEffect(() => setText(fmt(value)), [value]);
const commit = () => {
const n = Number(text.replace(',', '.'));
if (Number.isFinite(n) && round1(n) !== round1(value)) onCommit(Math.max(0, n));
else setText(fmt(value));
};
return (
<label className="flex items-stretch border-2 border-ink bg-cream">
<span className="flex w-[30px] flex-none items-center justify-center border-r-2 border-ink bg-sand font-display text-[11px] font-extrabold">
{label}
</span>
<input
inputMode="decimal"
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
}}
className="min-w-0 flex-1 border-0 bg-transparent px-[7px] py-2 font-mono text-[13px] font-semibold"
/>
</label>
);
}
export function BadgeInspector({
field,
badgeW,
badgeH,
effectiveFontSize,
photoCount,
volunteerCount,
stack,
onChange,
onRestack,
onHide,
}: {
field: FieldState;
badgeW: number;
badgeH: number;
/** Taille réellement utilisée sur le badge affiché (réduction auto). */
effectiveFontSize: number | null;
photoCount: number;
volunteerCount: number;
/** Types des champs activés de la face, du dessous vers le dessus. */
stack: BadgeFieldType[];
onChange: (patch: Partial<FieldState>) => void;
onRestack: (move: StackMove) => void;
onHide: () => void;
}) {
const isPhoto = field.field_type === 'PHOTO';
const lowContrast = Boolean(field.bg_color) && contrastRatio(field.color, field.bg_color as string) < MIN_CONTRAST;
const reduced = effectiveFontSize !== null && effectiveFontSize < field.font_size;
const setGeometry = (patch: Partial<FieldState>) =>
onChange(fitToBadge({ ...field, ...patch }, badgeW, badgeH));
const fontSize = (delta: number) =>
onChange({
font_size: Math.min(MAX_FONT_PT, Math.max(MIN_FONT_PT, round1(field.font_size + delta))),
});
return (
<div>
<div className="bg-ink px-3 py-[10px] text-cream">
<div className="font-mono text-[9.5px] uppercase text-yellow">
{t('badges.inspector.kicker', { face: t(FACE_LABEL_KEY[field.face]) })}
</div>
<div className="mt-[5px] font-display text-[17px] font-black leading-tight tracking-tight">
{t(FIELD_LABEL_KEY[field.field_type])}
</div>
</div>
<div className="flex flex-col gap-[15px] p-3">
{field.field_type === 'FREE_TEXT' ? (
<label className="block">
<span className="mb-[6px] block font-display text-[9.5px] font-extrabold uppercase tracking-widest">
{t('badges.inspector.content')}
</span>
<textarea
value={field.text ?? ''}
onChange={(e) => onChange({ text: e.target.value })}
rows={2}
placeholder={t('badges.field.freeText.input')}
className="w-full resize-y border-2 border-ink bg-cream p-2 font-display text-[13px] font-semibold leading-snug"
/>
<span className="mt-1 block font-mono text-[9px] leading-normal opacity-60">
{t('badges.inspector.contentHelp')}
</span>
</label>
) : null}
{!isPhoto ? (
<>
<Section
title={t('badges.inspector.fontSize')}
aside={
reduced
? t('badges.inspector.reducedHere', { size: fmt(effectiveFontSize as number) })
: t('badges.inspector.capHeight', { mm: fmt(field.font_size * MM_PER_PT) })
}
>
<div className="flex items-stretch border-2 border-ink">
<button
type="button"
onClick={() => fontSize(-FONT_STEP_PT)}
aria-label={t('badges.inspector.smaller')}
className="h-10 w-11 cursor-pointer border-0 border-r-2 border-ink bg-cream font-display text-[16px] font-black hover:bg-yellow"
>
−
</button>
<span className="flex flex-1 items-center justify-center font-mono text-[17px] font-bold">
{t('badges.pt', { size: fmt(field.font_size) })}
</span>
<button
type="button"
onClick={() => fontSize(FONT_STEP_PT)}
aria-label={t('badges.inspector.larger')}
className="h-10 w-11 cursor-pointer border-0 border-l-2 border-ink bg-cream font-display text-[16px] font-black hover:bg-yellow"
>
+
</button>
</div>
<button
type="button"
role="switch"
aria-checked={field.auto_fit}
onClick={() => onChange({ auto_fit: !field.auto_fit })}
className="mt-[7px] flex w-full cursor-pointer items-center gap-[9px] border-0 bg-transparent px-0 py-[3px] text-left"
>
<span
className={cx(
'relative h-5 w-[34px] flex-none border-2 border-ink',
field.auto_fit ? 'bg-yellow' : 'bg-sand-dark',
)}
>
<span
className="absolute top-[1px] h-[14px] w-[14px] bg-ink"
style={{ left: field.auto_fit ? 15 : 1 }}
/>
</span>
<span className="font-display text-[11.5px] font-bold leading-snug">
{t('badges.inspector.autoFit')}
</span>
</button>
</Section>
<Section title={t('badges.inspector.align')}>
<div className="grid grid-cols-3 border-2 border-ink">
{ALIGNS.map((a, i) => (
<Segment
key={a.value}
active={field.align === a.value}
onClick={() => onChange({ align: a.value })}
className={cx(
'flex h-[46px] flex-col items-center justify-center gap-1',
i < 2 && 'border-r-2 border-ink',
)}
>
<span className={cx('flex w-[18px] flex-col gap-[2px]', a.items)}>
<span className="h-[2px] w-[18px] bg-current" />
<span className="h-[2px] w-[11px] bg-current" />
<span className="h-[2px] w-[15px] bg-current" />
</span>
<span className="font-display text-[9.5px] font-bold">{t(a.key)}</span>
</Segment>
))}
</div>
</Section>
<Section title={t('badges.inspector.weight')}>
<div className="grid grid-cols-2 border-2 border-ink">
<Segment
active={!field.bold}
onClick={() => onChange({ bold: false })}
className="h-[38px] border-r-2 border-ink font-display text-[13px] font-medium"
>
{t('badges.inspector.regular')}
</Segment>
<Segment
active={field.bold}
onClick={() => onChange({ bold: true })}
className="h-[38px] font-display text-[13px] font-black"
>
{t('badges.inspector.bold')}
</Segment>
</div>
</Section>
<Section title={t('badges.inspector.textColor')}>
<div className="grid grid-cols-5 gap-[6px]">
{TEXT_COLORS.map((c) => (
<Swatch
key={c.hex}
hex={c.hex}
label={t(c.key)}
active={sameColor(field.color, c.hex)}
onClick={() => onChange({ color: c.hex })}
/>
))}
<label className="flex cursor-pointer flex-col items-center gap-1">
<input
type="color"
value={field.color}
onChange={(e) => onChange({ color: e.target.value })}
className={cx(
'h-[30px] w-full cursor-pointer border-2 border-ink bg-cream p-0',
!TEXT_COLORS.some((c) => sameColor(c.hex, field.color)) &&
'shadow-[0_0_0_3px_#2A6BFF]',
)}
/>
<span className="font-display text-[9.5px] font-semibold">
{t('badges.color.custom')}
</span>
</label>
</div>
</Section>
<Section title={t('badges.inspector.background')}>
<div className="grid grid-cols-4 gap-[6px]">
{BG_COLORS.map((c) => (
<Swatch
key={c.key}
hex={c.hex}
label={t(c.key)}
active={sameColor(field.bg_color, c.hex)}
onClick={() => onChange({ bg_color: c.hex })}
/>
))}
</div>
{lowContrast ? (
<div className="mt-[7px] border-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
{t('badges.inspector.lowContrast')}
</div>
) : null}
</Section>
</>
) : (
<Section title={t('badges.inspector.photoShape')}>
<div className="grid grid-cols-2 border-2 border-ink">
<Segment
active={field.photo_shape === 'RECT'}
onClick={() => onChange({ photo_shape: 'RECT' })}
className="h-10 border-r-2 border-ink font-display text-[12px] font-extrabold"
>
{t('badges.inspector.photoRect')}
</Segment>
<Segment
active={field.photo_shape === 'OVAL'}
onClick={() => onChange({ photo_shape: 'OVAL' })}
className="h-10 font-display text-[12px] font-extrabold"
>
{t('badges.inspector.photoOval')}
</Segment>
</div>
<div className="mt-[7px] font-mono text-[9.5px] leading-relaxed opacity-65">
{t('badges.inspector.photoStat', { count: photoCount, total: volunteerCount })}
</div>
</Section>
)}
<div className="border-t-2 border-ink pt-3">
<div className="mb-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-widest">
{t('badges.inspector.geometry')}
</div>
<div className="grid grid-cols-2 gap-[7px]">
<MmInput label={t('badges.inspector.x')} value={field.x_mm} onCommit={(v) => setGeometry({ x_mm: v })} />
<MmInput label={t('badges.inspector.y')} value={field.y_mm} onCommit={(v) => setGeometry({ y_mm: v })} />
<MmInput
label={t('badges.inspector.w')}
value={field.width_mm}
onCommit={(v) => setGeometry({ width_mm: Math.max(1, v) })}
/>
<MmInput
label={t('badges.inspector.h')}
value={field.height_mm}
onCommit={(v) => setGeometry({ height_mm: Math.max(1, v) })}
/>
</div>
<div className="mt-[7px] grid grid-cols-2 gap-[7px]">
<button
type="button"
onClick={() => setGeometry({ x_mm: (badgeW - field.width_mm) / 2 })}
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft"
>
{t('badges.inspector.center')}
</button>
<button
type="button"
onClick={() => setGeometry({ x_mm: 0, width_mm: badgeW })}
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft"
>
{t('badges.inspector.fullWidth')}
</button>
</div>
</div>
<StackSection field={field} stack={stack} onRestack={onRestack} />
<button
type="button"
onClick={onHide}
className="cursor-pointer border-2 border-ink bg-paper p-[10px] font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-orange"
>
{t('badges.inspector.hide')}
</button>
</div>
</div>
);
}
const STACK_MOVES: Array<{ move: StackMove; key: string; atTop: boolean }> = [
{ move: 'front', key: 'badges.inspector.stack.front', atTop: true },
{ move: 'forward', key: 'badges.inspector.stack.forward', atTop: true },
{ move: 'backward', key: 'badges.inspector.stack.backward', atTop: false },
{ move: 'back', key: 'badges.inspector.stack.back', atTop: false },
];
/** Ordre de superposition du champ parmi ceux de sa face — l'ordre dans
* lequel le PDF les dessine (`BadgeField.z_order`). */
function StackSection({
field,
stack,
onRestack,
}: {
field: FieldState;
stack: BadgeFieldType[];
onRestack: (move: StackMove) => void;
}) {
const index = stack.indexOf(field.field_type);
const isTop = index === stack.length - 1;
const isBottom = index === 0;
return (
<Section
title={t('badges.inspector.stack')}
aside={t('badges.inspector.stack.position', { rank: stack.length - index, total: stack.length })}
>
<div className="grid grid-cols-2 gap-[7px]">
{STACK_MOVES.map(({ move, key, atTop }) => {
const disabled = stack.length < 2 || (atTop ? isTop : isBottom);
return (
<button
key={move}
type="button"
disabled={disabled}
onClick={() => onRestack(move)}
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft disabled:cursor-default disabled:opacity-40 disabled:hover:bg-cream"
>
{t(key)}
</button>
);
})}
</div>
<div className="mt-[7px] font-mono text-[9.5px] leading-relaxed opacity-65">
{t('badges.inspector.stack.help')}
</div>
</Section>
);
}
@@ -0,0 +1,32 @@
// Métriques de la police Helvetica (et Helvetica-Bold) des 14 polices
// standard PDF, telles que ReportLab les utilise pour `stringWidth`
// (`backend/app/badges/pdf.py:wrap_text`) : largeur de chaque caractère du
// codage WinAnsi, en millièmes de la taille de police. Recopiées des fichiers
// `reportlab/pdfbase/_fontdata_widths_helvetica*.py` — mesurer avec ces mêmes
// chiffres (plutôt qu'avec la police que le navigateur trouve sous le nom
// « Helvetica ») fait tomber les retours à la ligne de l'aperçu au même
// endroit que dans le PDF, et marche aussi hors navigateur (smoke).
const CHARS = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ";
const REGULAR = [278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556,1015,667,667,722,722,667,611,778,722,278,500,667,556,833,722,778,667,778,722,667,611,722,667,944,667,667,611,278,278,278,469,556,333,556,556,500,556,556,278,556,556,222,222,500,222,833,556,556,556,556,333,500,278,556,500,722,500,500,500,334,260,334,584,350,556,222,556,333,1000,556,556,333,1000,667,333,1000,611,222,222,333,333,350,556,1000,333,1000,500,333,944,500,667,278,333,556,556,556,556,260,556,333,737,370,556,584,333,737,333,400,584,333,333,333,556,537,278,333,333,365,556,834,834,834,611,667,667,667,667,667,667,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,500,556,556,556,556,278,278,278,278,556,556,556,556,556,556,556,584,611,556,556,556,556,500,556,500];
const BOLD = [278,333,474,556,556,889,722,238,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,333,333,584,584,584,611,975,722,722,722,722,667,611,778,722,278,556,722,611,833,722,778,667,778,722,667,611,722,667,944,667,667,611,333,278,333,584,556,333,556,611,556,611,556,333,611,611,278,278,556,278,889,611,611,611,611,389,556,333,611,556,778,556,556,500,389,280,389,584,350,556,278,556,500,1000,556,556,333,1000,667,333,1000,611,278,278,500,500,350,556,1000,333,1000,556,333,944,500,667,278,333,556,556,556,556,280,556,333,737,370,556,584,333,737,333,400,584,333,333,333,611,556,278,333,333,365,556,834,834,834,611,722,722,722,722,722,722,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,556,556,556,556,556,278,278,278,278,611,611,611,611,611,611,611,584,611,611,611,611,611,556,611,556];
/** Largeur (millièmes d'em) d'un caractère hors WinAnsi : ReportLab le
* remplacerait par une police de substitution, on prend une largeur moyenne. */
const FALLBACK_WIDTH = 556;
const regularWidths = new Map<string, number>();
const boldWidths = new Map<string, number>();
for (let i = 0; i < CHARS.length; i++) {
regularWidths.set(CHARS[i], REGULAR[i]);
boldWidths.set(CHARS[i], BOLD[i]);
}
/** Largeur de `text` en points, à la taille `size` (pt) — équivalent de
* `reportlab.pdfbase.pdfmetrics.stringWidth(text, "Helvetica[-Bold]", size)`. */
export function stringWidthPt(text: string, bold: boolean, size: number): number {
const widths = bold ? boldWidths : regularWidths;
let total = 0;
for (const ch of text) total += widths.get(ch) ?? FALLBACK_WIDTH;
return (total * size) / 1000;
}
+127
View File
@@ -0,0 +1,127 @@
// Contrôle avant génération : passe chaque champ affiché sur TOUS les
// bénévoles (estimation de mise en page, voir `layout.ts`) et relève ce qui
// risque de mal s'imprimer.
import type { BadgeFace, BadgeFieldType } from '@/api/schema';
import { contrastRatio, MIN_CONTRAST } from './layout';
import { renderField, SAFE_MARGIN_MM, type BadgeData, type FieldRef, type FieldState } from './model';
export interface FieldStats {
/** Bénévoles dont le texte a été réduit automatiquement (sans être coupé). */
reduced: number;
/** Bénévoles dont le texte reste coupé. */
truncated: number;
/** Plus petite taille atteinte par la réduction automatique. */
minPt: number;
/** Index (dans `BadgeData.volunteers`) d'un exemple de chaque cas. */
reducedExample: number | null;
truncatedExample: number | null;
}
export type IssueKind = 'truncated' | 'reduced' | 'margin' | 'contrast' | 'noPhoto';
export interface BadgeIssue {
kind: IssueKind;
critical: boolean;
ref: FieldRef;
count: number;
minPt: number;
/** Bénévole à afficher pour « Voir un exemple » (null : pas d'exemple). */
exampleIndex: number | null;
/** « Activer la réduction auto » proposé (texte coupé, réduction coupée). */
canEnableAutoFit: boolean;
}
export function statsKey(face: BadgeFace, type: BadgeFieldType): string {
return `${face}:${type}`;
}
export function computeIssues(
fields: FieldState[],
data: BadgeData,
badgeW: number,
badgeH: number,
): { issues: BadgeIssue[]; stats: Map<string, FieldStats> } {
const stats = new Map<string, FieldStats>();
const issues: BadgeIssue[] = [];
for (const field of fields) {
if (!field.enabled) continue;
const ref = { face: field.face, type: field.field_type };
const base = { ref, count: 0, minPt: field.font_size, exampleIndex: null, canEnableAutoFit: false };
if (field.field_type !== 'PHOTO') {
const s: FieldStats = {
reduced: 0,
truncated: 0,
minPt: field.font_size,
reducedExample: null,
truncatedExample: null,
};
data.volunteers.forEach((volunteer, index) => {
const rendered = renderField(field, volunteer, data);
if (rendered.kind === 'photo') return;
const { truncated, reduced, fontSize } = rendered.layout;
if (truncated) {
s.truncated += 1;
if (s.truncatedExample === null) s.truncatedExample = index;
} else if (reduced) {
s.reduced += 1;
if (fontSize < s.minPt) {
s.minPt = fontSize;
s.reducedExample = index;
}
}
});
stats.set(statsKey(field.face, field.field_type), s);
if (s.truncated) {
issues.push({
...base,
kind: 'truncated',
critical: true,
count: s.truncated,
exampleIndex: s.truncatedExample,
canEnableAutoFit: !field.auto_fit,
});
} else if (s.reduced) {
issues.push({
...base,
kind: 'reduced',
critical: false,
count: s.reduced,
minPt: s.minPt,
exampleIndex: s.reducedExample,
});
}
if (field.bg_color && contrastRatio(field.color, field.bg_color) < MIN_CONTRAST) {
issues.push({ ...base, kind: 'contrast', critical: true });
}
} else {
const missing = data.volunteers.findIndex((v) => !v.has_photo);
if (missing >= 0) {
issues.push({
...base,
kind: 'noPhoto',
critical: false,
count: data.volunteers.filter((v) => !v.has_photo).length,
exampleIndex: missing,
});
}
}
// Un bandeau coloré (fond de zone) peut volontairement aller jusqu'au
// bord : seul un champ sans fond qui touche la marge est signalé.
const bleed = field.bg_color !== null && field.field_type !== 'PHOTO';
const m = SAFE_MARGIN_MM - 1e-6;
const touchesEdge =
field.x_mm < m ||
field.y_mm < m ||
field.x_mm + field.width_mm > badgeW - m ||
field.y_mm + field.height_mm > badgeH - m;
if (touchesEdge && !bleed) issues.push({ ...base, kind: 'margin', critical: false });
}
issues.sort((a, b) => Number(b.critical) - Number(a.critical));
return { issues, stats };
}
+238
View File
@@ -0,0 +1,238 @@
// Estimation de la mise en page d'un champ du badge, côté navigateur — même
// algorithme que la génération PDF (`backend/app/badges/pdf.py` : `wrap_text`,
// `_draw_table`, mêmes métriques Helvetica via `stringWidthPt`). Sert à
// l'aperçu (retours à la ligne, réduction automatique, lignes coupées) et au
// contrôle avant génération. Ce n'est qu'une estimation : le PDF reste la
// référence.
import { stringWidthPt } from './helveticaMetrics';
/** 1 pt = 25,4 / 72 mm. */
export const MM_PER_PT = 25.4 / 72;
/** Interligne, en multiple de la taille de police (`_LINE_HEIGHT_FACTOR`). */
export const LINE_HEIGHT = 1.25;
/** Position de la première ligne de base sous le haut du cadre, en multiple
* de la taille de police (`font_size * 0.85` dans le PDF). */
export const FIRST_BASELINE = 0.85;
export const MIN_FONT_PT = 4;
export const MAX_FONT_PT = 30;
export const FONT_STEP_PT = 0.5;
/** Marges intérieures des cellules du tableau des créneaux (mm). */
export const CELL_PAD_X_MM = 0.8;
export const CELL_PAD_Y_MM = 0.5;
/** Découpe `text` en lignes de largeur au plus `maxWidthPt` — portage de
* `wrap_text` : les `\n` restent des retours à la ligne, puis passage à la
* ligne entre deux mots, ou au milieu d'un mot qui seul ne tient pas. */
export function wrapText(text: string, bold: boolean, size: number, maxWidthPt: number): string[] {
const fits = (candidate: string) => stringWidthPt(candidate, bold, size) <= maxWidthPt;
const lines: string[] = [];
for (const paragraph of text.split('\n')) {
let current = '';
for (const word of paragraph.split(' ')) {
const candidate = current ? `${current} ${word}` : word;
if (fits(candidate)) {
current = candidate;
continue;
}
if (current) lines.push(current.trimEnd());
current = word;
while (current && !fits(current)) {
let cut = current.length - 1;
while (cut > 1 && !fits(current.slice(0, cut))) cut -= 1;
lines.push(current.slice(0, cut));
current = current.slice(cut);
}
}
lines.push(current.trimEnd());
}
return lines;
}
/** Nombre de lignes qui tiennent dans `heightMm` : une ligne est dessinée
* tant que sa ligne de base reste dans le cadre (règle du PDF). */
export function linesThatFit(size: number, heightMm: number): number {
const sizeMm = size * MM_PER_PT;
if (FIRST_BASELINE * sizeMm > heightMm) return 0;
return Math.floor((heightMm - FIRST_BASELINE * sizeMm) / (LINE_HEIGHT * sizeMm) + 1e-9) + 1;
}
export interface TextLayout {
/** Taille réellement utilisée (réduite si `reduced`). */
fontSize: number;
/** Toutes les lignes après retour à la ligne. */
lines: string[];
/** Lignes imprimées (les suivantes débordent du cadre). */
visible: string[];
reduced: boolean;
truncated: boolean;
}
interface FitOptions {
fontSize: number;
autoFit: boolean;
}
/** Réduction automatique commune au texte et au tableau : à la taille
* demandée si tout tient ; sinon, si `autoFit`, par pas de 0,5 pt jusqu'à
* 4 pt ; le texte reste coupé s'il ne tient toujours pas. */
function withAutoFit<L>(
{ fontSize, autoFit }: FitOptions,
layoutAt: (size: number) => L,
isComplete: (layout: L) => boolean,
): { layout: L; fontSize: number; reduced: boolean; truncated: boolean } {
let size = fontSize;
let layout = layoutAt(size);
if (isComplete(layout)) return { layout, fontSize: size, reduced: false, truncated: false };
if (!autoFit) return { layout, fontSize: size, reduced: false, truncated: true };
while (size - FONT_STEP_PT >= MIN_FONT_PT - 1e-9) {
size = Math.round((size - FONT_STEP_PT) * 10) / 10;
layout = layoutAt(size);
if (isComplete(layout)) return { layout, fontSize: size, reduced: true, truncated: false };
}
return { layout, fontSize: size, reduced: size < fontSize, truncated: true };
}
export function layoutText(
text: string,
box: { width_mm: number; height_mm: number; bold: boolean; font_size: number; auto_fit: boolean },
): TextLayout {
const result = withAutoFit(
{ fontSize: box.font_size, autoFit: box.auto_fit },
(size) => {
const lines = text ? wrapText(text, box.bold, size, box.width_mm / MM_PER_PT) : [];
return { lines, capacity: linesThatFit(size, box.height_mm) };
},
({ lines, capacity }) => lines.length <= capacity,
);
const { lines, capacity } = result.layout;
return {
fontSize: result.fontSize,
lines,
visible: lines.slice(0, capacity),
reduced: result.reduced,
truncated: result.truncated,
};
}
// --------------------------------------------------------------------------- //
// Tableau des créneaux (`POSITIONS`) //
// --------------------------------------------------------------------------- //
export interface TableSection {
title: string;
rows: string[][];
}
export interface TableRowLayout {
/** Lignes de texte de chaque cellule (une seule cellule pour un en-tête). */
cells: string[][];
isTitle: boolean;
heightMm: number;
}
export interface TableLayout {
fontSize: number;
/** Largeur de chaque colonne (mm). */
widthsMm: number[];
/** Lignes du tableau qui tiennent dans le cadre. */
rows: TableRowLayout[];
reduced: boolean;
truncated: boolean;
}
/** Portage de `_column_widths` : une colonne courte garde sa largeur
* naturelle, les plus longues se partagent le reste. */
function columnWidths(natural: number[], available: number): number[] {
const widths = natural.map(() => 0);
const pending = natural.map((_, i) => i).sort((a, b) => natural[a] - natural[b]);
let remaining = available;
while (pending.length) {
const share = remaining / pending.length;
if (natural[pending[0]] > share) {
for (const i of pending) widths[i] = share;
return widths;
}
const i = pending.shift() as number;
widths[i] = natural[i];
remaining -= natural[i];
}
widths[widths.length - 1] += remaining;
return widths;
}
export function layoutTable(
sections: TableSection[],
box: { width_mm: number; height_mm: number; font_size: number; auto_fit: boolean },
): TableLayout {
const total = sections.reduce((n, s) => n + 1 + s.rows.length, 0);
const result = withAutoFit(
{ fontSize: box.font_size, autoFit: box.auto_fit },
(size) => {
const body = sections.flatMap((s) => s.rows);
const columns = body.length ? Math.max(...body.map((r) => r.length)) : 1;
const natural = body.length
? Array.from({ length: columns }, (_, col) =>
Math.max(...body.map((r) => stringWidthPt(r[col] ?? '', false, size) * MM_PER_PT)) +
2 * CELL_PAD_X_MM,
)
: [0];
const widthsMm = columnWidths(natural, box.width_mm);
const fullWidth = widthsMm.reduce((a, b) => a + b, 0);
const lineMm = size * MM_PER_PT * LINE_HEIGHT;
const rows: TableRowLayout[] = [];
let used = 0;
outer: for (const section of sections) {
const entries: Array<{ cells: string[]; isTitle: boolean }> = [
{ cells: [section.title], isTitle: true },
...section.rows.map((cells) => ({ cells, isTitle: false })),
];
for (const entry of entries) {
const widths = entry.isTitle ? [fullWidth] : widthsMm;
const cells = entry.cells.map((text, col) =>
wrapText(
text,
entry.isTitle,
size,
Math.max(1, (widths[col] - 2 * CELL_PAD_X_MM) / MM_PER_PT),
),
);
const heightMm = Math.max(...cells.map((c) => c.length)) * lineMm + 2 * CELL_PAD_Y_MM;
if (used + heightMm > box.height_mm + 0.01 * MM_PER_PT) break outer;
rows.push({ cells, isTitle: entry.isTitle, heightMm });
used += heightMm;
}
}
return { widthsMm, rows };
},
(layout) => layout.rows.length >= total,
);
return {
fontSize: result.fontSize,
widthsMm: result.layout.widthsMm,
rows: result.layout.rows,
reduced: result.reduced,
truncated: result.truncated,
};
}
// --------------------------------------------------------------------------- //
// Contraste //
// --------------------------------------------------------------------------- //
function luminance(hex: string): number {
const n = parseInt(hex.slice(1, 7), 16);
const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => {
const x = c / 255;
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/** Rapport de contraste WCAG entre deux couleurs `#rrggbb`. */
export function contrastRatio(a: string, b: string): number {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
/** En dessous, le texte risque d'être illisible à l'impression. */
export const MIN_CONTRAST = 3;
+318
View File
@@ -0,0 +1,318 @@
// État de l'éditeur de badges et contenu réel de chaque champ pour un
// bénévole donné — mêmes règles que la génération PDF
// (`backend/app/api/badges.py:_field_text`, `_positions_table`).
import type {
BadgeFace,
BadgeFieldIn,
BadgeFieldRead,
BadgeFieldType,
PositionRead,
ShiftRead,
VolunteerRead,
} from '@/api/schema';
import { t } from '@/i18n';
import { categoryLabel, festivalDay } from '@/lib/planning';
import { longDayLabel, timePart } from '@/lib/time';
import { layoutTable, layoutText, type TableLayout, type TableSection, type TextLayout } from './layout';
export const DEFAULT_BADGE_W_MM = 85.6;
export const DEFAULT_BADGE_H_MM = 54;
/** Marge de découpe (mm) : un champ qui la touche risque d'être rogné. */
export const SAFE_MARGIN_MM = 1.5;
export const FACES: BadgeFace[] = ['FRONT', 'BACK'];
export const FIELD_TYPES: BadgeFieldType[] = [
'PHOTO',
'FULL_NAME',
'POSITIONS',
'CATEGORY',
'ROLE',
'FREE_TEXT',
];
export const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName',
POSITIONS: 'badges.field.positions',
CATEGORY: 'badges.field.category',
ROLE: 'badges.field.role',
PHOTO: 'badges.field.photo',
FREE_TEXT: 'badges.field.freeText',
};
export const FACE_LABEL_KEY: Record<BadgeFace, string> = {
FRONT: 'badges.face.front',
BACK: 'badges.face.back',
};
/** Formats prédéfinis (mm). */
export const BADGE_PRESETS: Array<{ key: string; width: number; height: number }> = [
{ key: 'badges.preset.cr80Portrait', width: 54, height: 85.6 },
{ key: 'badges.preset.cr80Landscape', width: 85.6, height: 54 },
{ key: 'badges.preset.a7', width: 74, height: 105 },
];
export type FieldState = BadgeFieldIn & { enabled: boolean };
export interface FieldRef {
face: BadgeFace;
type: BadgeFieldType;
}
export function sameRef(a: FieldRef | null, b: FieldRef | null): boolean {
return Boolean(a && b && a.face === b.face && a.type === b.type);
}
type FieldDefaults = Omit<BadgeFieldIn, 'field_type' | 'face'>;
const COMMON = { bg_color: null, auto_fit: true, photo_shape: 'RECT', z_order: 0 } as const;
// Position/taille par défaut à l'activation d'un champ — empilés
// verticalement pour ne pas se superposer (repères pour un badge CR80
// paysage, ramenés dans le badge par `fitToBadge` pour un autre format).
const FIELD_DEFAULTS: Record<BadgeFieldType, FieldDefaults> = {
FULL_NAME: {
...COMMON, x_mm: 5, y_mm: 4, width_mm: 50, height_mm: 11,
font_size: 11, align: 'LEFT', bold: true, color: '#14110E',
},
POSITIONS: {
...COMMON, x_mm: 5, y_mm: 16, width_mm: 52, height_mm: 22,
font_size: 6, align: 'LEFT', bold: false, color: '#14110E',
},
CATEGORY: {
...COMMON, x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 5,
font_size: 7, align: 'LEFT', bold: true, color: '#14110E',
},
ROLE: {
...COMMON, x_mm: 5, y_mm: 45, width_mm: 30, height_mm: 5,
font_size: 7, align: 'CENTER', bold: true, color: '#14110E', bg_color: '#FFD23F',
},
// Format portrait d'identité, en haut à droite. Taille de police/couleur
// sans effet : la photo est une image.
PHOTO: {
...COMMON, x_mm: 60, y_mm: 4, width_mm: 21, height_mm: 27,
font_size: 8, align: 'CENTER', bold: false, color: '#14110E',
},
FREE_TEXT: {
...COMMON, x_mm: 40, y_mm: 45, width_mm: 40, height_mm: 5,
font_size: 7, align: 'CENTER', bold: true, color: '#FFFFFF', bg_color: '#14110E',
},
};
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/** Ramène un champ à l'intérieur du badge (réduit s'il est plus grand,
* décalé s'il dépasse). Sur un badge plus étroit que le CR80 paysage des
* positions par défaut, un champ activé pouvait tomber hors du badge —
* invisible, et les bornes négatives du glisser-déposer le bloquaient (bug
* corrigé une fois : photo impossible à déplacer ni redimensionner). */
export function fitToBadge<F extends BadgeFieldIn>(field: F, badgeW: number, badgeH: number): F {
const width_mm = Math.min(field.width_mm, badgeW);
const height_mm = Math.min(field.height_mm, badgeH);
return {
...field,
width_mm,
height_mm,
x_mm: clamp(field.x_mm, 0, badgeW - width_mm),
y_mm: clamp(field.y_mm, 0, badgeH - height_mm),
};
}
/** Les 12 champs possibles (6 types × 2 faces), activés ou non, dans
* l'ordre d'affichage. */
export function fieldsFromServer(
saved: BadgeFieldRead[],
badgeW: number,
badgeH: number,
): FieldState[] {
const out: FieldState[] = [];
for (const face of FACES) {
for (const type of FIELD_TYPES) {
const existing = saved.find((f) => (f.face ?? 'FRONT') === face && f.field_type === type);
const field: FieldState = existing
? {
field_type: type,
face,
x_mm: existing.x_mm,
y_mm: existing.y_mm,
width_mm: existing.width_mm,
height_mm: existing.height_mm,
font_size: existing.font_size,
align: existing.align,
bold: existing.bold,
color: existing.color,
bg_color: existing.bg_color ?? null,
auto_fit: existing.auto_fit ?? true,
photo_shape: existing.photo_shape ?? 'RECT',
z_order: existing.z_order ?? 0,
text: existing.text ?? null,
enabled: true,
}
: { field_type: type, face, ...FIELD_DEFAULTS[type], text: null, enabled: false };
out.push(fitToBadge(field, badgeW, badgeH));
}
}
return out;
}
/** Corps du `PUT .../badges/template/fields` : les champs activés des deux
* faces. */
export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
const rank = stackRanks(fields);
return fields
.filter((f) => f.enabled)
.map((f) => ({
field_type: f.field_type,
face: f.face,
x_mm: round1(f.x_mm),
y_mm: round1(f.y_mm),
width_mm: round1(f.width_mm),
height_mm: round1(f.height_mm),
font_size: f.font_size,
align: f.align,
bold: f.bold,
color: f.color,
bg_color: f.bg_color,
auto_fit: f.auto_fit,
photo_shape: f.photo_shape,
z_order: rank.get(f) ?? 0,
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
}));
}
// --------------------------------------------------------------------------- //
// Ordre d'empilement //
// --------------------------------------------------------------------------- //
/** Champs activés d'une face, du dessous vers le dessus — l'ordre de dessin
* du PDF. À `z_order` égal, la photo passe dessous, puis l'ordre de
* `FIELD_TYPES` (le serveur ne départage que la photo, mais les `z_order`
* envoyés sont toujours distincts : voir `stackRanks`). */
export function stackOrder(fields: FieldState[], face: BadgeFace): FieldState[] {
return fields
.filter((f) => f.enabled && f.face === face)
.sort(
(a, b) =>
a.z_order - b.z_order ||
FIELD_TYPES.indexOf(a.field_type) - FIELD_TYPES.indexOf(b.field_type),
);
}
/** Rang de chaque champ activé dans la pile de sa face (0 = dessous). */
export function stackRanks(fields: FieldState[]): Map<FieldState, number> {
const ranks = new Map<FieldState, number>();
for (const face of FACES) stackOrder(fields, face).forEach((f, i) => ranks.set(f, i));
return ranks;
}
export type StackMove = 'front' | 'forward' | 'backward' | 'back';
/** Déplace un champ dans la pile de sa face et renumérote la face (0…n-1). */
export function restack(fields: FieldState[], ref: FieldRef, move: StackMove): FieldState[] {
const stack = stackOrder(fields, ref.face);
const from = stack.findIndex((f) => f.field_type === ref.type);
if (from < 0) return fields;
const to = {
front: stack.length - 1,
forward: Math.min(from + 1, stack.length - 1),
backward: Math.max(from - 1, 0),
back: 0,
}[move];
const [moved] = stack.splice(from, 1);
stack.splice(to, 0, moved);
const rank = new Map(stack.map((f, i) => [f, i]));
return fields.map((f) => (rank.has(f) ? { ...f, z_order: rank.get(f) as number } : f));
}
/** `z_order` d'un champ qu'on active : au-dessus des autres — sauf la
* photo, dessous, pour ne pas masquer un texte déjà placé. */
export function zOrderOnEnable(fields: FieldState[], ref: FieldRef): number {
const stack = stackOrder(fields, ref.face);
if (!stack.length) return 0;
return ref.type === 'PHOTO' ? stack[0].z_order - 1 : stack[stack.length - 1].z_order + 1;
}
export function round1(value: number): number {
return Math.round(value * 10) / 10;
}
/** Nombre français à une décimale au plus (« 8,5 »). */
export function fmt(value: number): string {
return String(round1(value)).replace('.', ',');
}
// --------------------------------------------------------------------------- //
// Contenu réel d'un champ pour un bénévole //
// --------------------------------------------------------------------------- //
export interface BadgeData {
/** Dans l'ordre d'impression : nom puis prénom (comme le PDF). */
volunteers: VolunteerRead[];
/** Créneaux affectés de chaque bénévole, dans l'ordre chronologique. */
assignments: Map<string, Array<{ position: PositionRead; shift: ShiftRead }>>;
/** Bénévoles dont le compte est référent (`LEAD`). */
leadVolunteerIds: Set<string>;
/** Début de « journée de festival » (`HH:MM`). */
dayBoundary: string;
}
export function fullNameText(volunteer: VolunteerRead): string {
return `${volunteer.first_name}\n${volunteer.last_name}`;
}
/** Texte d'un champ (hors `POSITIONS`, un tableau, et `PHOTO`). */
export function fieldText(field: FieldState, volunteer: VolunteerRead, data: BadgeData): string {
switch (field.field_type) {
case 'FULL_NAME':
return fullNameText(volunteer);
case 'FREE_TEXT':
// Texte commun saisi dans l'éditeur, remplacé pour un bénévole par le
// « Texte du badge » de sa fiche s'il en a un.
return volunteer.badge_text || field.text || '';
case 'CATEGORY': {
const rows = data.assignments.get(volunteer.id) ?? [];
return [...new Set(rows.map((r) => categoryLabel(r.position.category)))].sort().join(', ');
}
case 'ROLE':
return data.leadVolunteerIds.has(volunteer.id) ? t('role.lead') : t('role.volunteer');
default:
return '';
}
}
/** Créneaux regroupés par jour de festival : le jour en en-tête, puis une
* ligne horaire / poste par créneau. */
export function positionsTable(volunteer: VolunteerRead, data: BadgeData): TableSection[] {
const byDay = new Map<string, string[][]>();
for (const { position, shift } of data.assignments.get(volunteer.id) ?? []) {
const day = festivalDay(shift.starts_at, data.dayBoundary);
const rows = byDay.get(day) ?? [];
rows.push([`${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`, position.name]);
byDay.set(day, rows);
}
return [...byDay.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, rows]) => ({ title: longDayLabel(day), rows }));
}
export type FieldRender =
| { kind: 'text'; layout: TextLayout }
| { kind: 'table'; layout: TableLayout }
| { kind: 'photo' };
/** Mise en page estimée d'un champ pour un bénévole. */
export function renderField(field: FieldState, volunteer: VolunteerRead, data: BadgeData): FieldRender {
if (field.field_type === 'PHOTO') return { kind: 'photo' };
if (field.field_type === 'POSITIONS') {
return { kind: 'table', layout: layoutTable(positionsTable(volunteer, data), field) };
}
return { kind: 'text', layout: layoutText(fieldText(field, volunteer, data), field) };
}
/** Champs dont le texte est centré verticalement dans le cadre (bandeaux) ;
* les autres partent du haut. */
export function isVerticallyCentered(type: BadgeFieldType): boolean {
return type === 'FREE_TEXT' || type === 'ROLE';
}
+1 -1
View File
@@ -75,7 +75,7 @@ export function ConfigArtists() {
</div> </div>
</div> </div>
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fill,minmax(320px,1fr))]"> <div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr))]">
{days.map((date) => ( {days.map((date) => (
<DayColumn key={date} date={date} artists={byDate.get(date) ?? []} /> <DayColumn key={date} date={date} artists={byDate.get(date) ?? []} />
))} ))}
+9 -8
View File
@@ -109,8 +109,9 @@ export function ConfigGrid() {
].sort(); ].sort();
return ( return (
<div className="flex min-h-0 flex-1 items-stretch"> <div className="flex min-h-0 flex-1 flex-col items-stretch md:flex-row">
<div className="max-h-[calc(100vh-118px)] w-[250px] flex-none overflow-auto border-r-[3px] border-ink"> {/* Mobile : liste des postes au-dessus du détail, hauteur bornée. */}
<div className="max-h-[260px] w-full flex-none overflow-auto border-b-[3px] border-ink md:max-h-[calc(100vh-118px)] md:w-[250px] md:border-b-0 md:border-r-[3px]">
<div className="sticky top-0 z-[2] flex items-center gap-[6px] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream"> <div className="sticky top-0 z-[2] flex items-center gap-[6px] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('config.grid.positionsHeader', { count: totals.count })} {t('config.grid.positionsHeader', { count: totals.count })}
<HelpTip text={t('config.grid.note')} side="bottom" /> <HelpTip text={t('config.grid.note')} side="bottom" />
@@ -148,7 +149,7 @@ export function ConfigGrid() {
{createPosition.isError ? <ErrorNote error={createPosition.error} /> : null} {createPosition.isError ? <ErrorNote error={createPosition.error} /> : null}
</div> </div>
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4"> <div className="min-w-0 flex-1 p-3 sm:p-4 md:max-h-[calc(100vh-118px)] md:overflow-auto">
<PositionHeader <PositionHeader
key={position.id} key={position.id}
position={position} position={position}
@@ -158,7 +159,7 @@ export function ConfigGrid() {
{positionShifts.isLoading ? <Loading /> : null} {positionShifts.isLoading ? <Loading /> : null}
{updateShift.isError ? <ErrorNote error={updateShift.error} /> : null} {updateShift.isError ? <ErrorNote error={updateShift.error} /> : null}
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]"> <div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr))]">
{days.map((day) => ( {days.map((day) => (
<DayCard <DayCard
key={day} key={day}
@@ -197,15 +198,15 @@ function PositionHeader({
return ( return (
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]"> <div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
<div className="flex flex-col gap-1"> <div className="flex w-full min-w-0 flex-col gap-1 sm:w-auto">
<input <input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
onBlur={commitName} onBlur={commitName}
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
className="border-2 border-ink bg-yellow px-2 py-1 font-display text-[22px] font-black tracking-tight" className="w-full min-w-0 border-2 border-ink bg-yellow px-2 py-1 font-display text-[20px] font-black tracking-tight sm:text-[22px]"
/> />
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<select <select
value={position.category} value={position.category}
onChange={(e) => onChange={(e) =>
@@ -250,7 +251,7 @@ function PositionHeader({
{updatePosition.isError ? <ErrorNote error={updatePosition.error} /> : null} {updatePosition.isError ? <ErrorNote error={updatePosition.error} /> : null}
{generate.isError ? <ErrorNote error={generate.error} /> : null} {generate.isError ? <ErrorNote error={generate.error} /> : null}
</div> </div>
<div className="ml-auto flex gap-2"> <div className="flex gap-2 sm:ml-auto">
<MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} /> <MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} />
<MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent /> <MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent />
</div> </div>
+106 -2
View File
@@ -1,9 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { useSendPlanningNotification } from '@/api/hooks'; import { useSendPlanningNotification, useSendTestEmail, useVolunteers } from '@/api/hooks';
import type { TestEmailResult } from '@/api/schema';
import { ErrorNote } from '@/components/state'; import { ErrorNote } from '@/components/state';
import { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui'; import { Btn, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx';
import { volunteerName } from '@/lib/volunteer';
const MAIL_VARIABLES = [ const MAIL_VARIABLES = [
'{prenom}', '{prenom}',
@@ -32,7 +35,7 @@ export function ConfigMail() {
return ( return (
<div className="p-4"> <div className="p-4">
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]"> <div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fit,minmax(min(320px,100%),1fr))]">
<Panel shadow="lg"> <Panel shadow="lg">
<PanelHeader> <PanelHeader>
{t('config.mail.templateTitle')} <HelpTip text={t('config.mail.smtpBanner')} side="bottom" /> {t('config.mail.templateTitle')} <HelpTip text={t('config.mail.smtpBanner')} side="bottom" />
@@ -100,6 +103,8 @@ export function ConfigMail() {
</div> </div>
</Panel> </Panel>
<TestEmailPanel subject={subject} body={body} />
<Panel shadow="lg"> <Panel shadow="lg">
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader> <PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
<div className="p-[13px]"> <div className="p-[13px]">
@@ -116,3 +121,102 @@ export function ConfigMail() {
</div> </div>
); );
} }
const RESULT_TONE: Record<TestEmailResult['status'], string> = {
sent: 'bg-green text-white',
disabled: 'bg-yellow-soft',
connection_failed: 'bg-orange',
rejected: 'bg-orange',
};
/** Envoie à l'adresse saisie le récapitulatif **tel que les bénévoles le
* recevront** (le modèle en cours d'édition, variables remplies avec les
* données d'un bénévole) et affiche le résultat réel du serveur SMTP. */
function TestEmailPanel({ subject, body }: { subject: string; body: string }) {
const [email, setEmail] = useState('');
// '' = choix automatique (premier bénévole qui a un créneau).
const [volunteerId, setVolunteerId] = useState('');
const [withLink, setWithLink] = useState(false);
const volunteers = useVolunteers();
const test = useSendTestEmail();
const result = test.data;
const options = [...(volunteers.data ?? [])].sort((a, b) =>
volunteerName(a).localeCompare(volunteerName(b), 'fr'),
);
return (
<Panel shadow="lg">
<PanelHeader>{t('config.mail.test.title')}</PanelHeader>
<form
onSubmit={(e) => {
e.preventDefault();
if (!email.trim()) return;
test.mutate({
email: email.trim(),
subject,
// Même corps que le bouton « avec lien de connexion » si coché.
body: withLink ? withLoginLink(body) : body,
volunteer_id: volunteerId || null,
});
}}
className="flex flex-col gap-3 p-[13px]"
>
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">{t('config.mail.test.help')}</p>
<label className="block">
<FieldLabel>{t('config.mail.test.email')}</FieldLabel>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('config.mail.test.placeholder')}
className="w-full border-2 border-ink bg-cream p-[9px] font-mono text-[12px]"
/>
</label>
<label className="block">
<FieldLabel>{t('config.mail.test.example')}</FieldLabel>
<select
value={volunteerId}
onChange={(e) => setVolunteerId(e.target.value)}
className="w-full border-2 border-ink bg-cream p-[8px] font-display text-[12.5px] font-bold"
>
<option value="">{t('config.mail.test.exampleAuto')}</option>
{options.map((v) => (
<option key={v.id} value={v.id}>
{volunteerName(v)}
</option>
))}
</select>
</label>
<label className="flex cursor-pointer items-center gap-2 font-display text-[12px] font-bold">
<input type="checkbox" checked={withLink} onChange={(e) => setWithLink(e.target.checked)} />
{t('config.mail.test.withLink')}
</label>
<Btn type="submit" disabled={!email.trim() || test.isPending}>
{test.isPending ? t('config.mail.test.sending') : t('config.mail.test.send')}
</Btn>
{result && !test.isPending ? (
<div className={cx('border-2 border-ink p-[9px]', RESULT_TONE[result.status])}>
<div className="font-display text-[12px] font-extrabold leading-snug">
{t(`config.mail.test.result.${result.status}`)}
</div>
<div className="mt-1 font-mono text-[10.5px] leading-snug">
{result.example_volunteer_id
? t('config.mail.test.filledWith', { name: result.example_volunteer_name || t('volunteer.unnamed') })
: t('config.mail.test.filledWithExample')}
</div>
{result.detail ? (
<div className="mt-1 break-words font-mono text-[10.5px] leading-snug">
{t('config.mail.test.serverSaid', { detail: result.detail })}
</div>
) : null}
{result.status !== 'sent' && result.status !== 'disabled' ? (
<div className="mt-1 font-mono text-[10px] leading-snug opacity-80">
{t('config.mail.test.config', { host: result.smtp_host, from: result.smtp_from })}
</div>
) : null}
</div>
) : null}
{test.isError ? <ErrorNote error={test.error} /> : null}
</form>
</Panel>
);
}
@@ -0,0 +1,96 @@
import { useState } from 'react';
import { useEdition, usePositions, useUpdateEdition, useUpdatePosition } from '@/api/hooks';
import type { PositionRead } from '@/api/schema';
import { MessageEditor } from '@/components/MessageEditor';
import { ErrorNote, Loading } from '@/components/state';
import { Panel, PanelHeader } from '@/components/ui';
import { cx } from '@/lib/cx';
import { t } from '@/i18n';
// Messages montrés sur l'écran bénévole : celui de l'organisation, le même
// pour tous (`Edition.volunteer_message`), et, pour suivi, ceux des postes
// (`Position.message`) — saisis d'ordinaire par chaque référent depuis son
// écran, mais la coordination peut aussi les reprendre ici.
export function ConfigMessage() {
const edition = useEdition();
const positions = usePositions();
const updateEdition = useUpdateEdition();
if (edition.isLoading || positions.isLoading) return <Loading />;
const error = edition.error ?? positions.error;
if (error) return <ErrorNote error={error} />;
return (
<div className="grid items-start gap-4 p-3 sm:p-4 [grid-template-columns:repeat(auto-fit,minmax(min(360px,100%),1fr))]">
<Panel shadow="lg">
<PanelHeader>{t('config.message.orgTitle')}</PanelHeader>
<div className="flex flex-col gap-3 p-[13px]">
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">{t('config.message.orgHelp')}</p>
<MessageEditor
value={edition.data?.volunteer_message ?? null}
updatedAt={edition.data?.volunteer_message_updated_at ?? null}
placeholder={t('config.message.orgPlaceholder')}
saving={updateEdition.isPending}
error={updateEdition.error}
onSave={(text) => updateEdition.mutateAsync({ volunteer_message: text })}
/>
</div>
</Panel>
<Panel shadow="lg">
<PanelHeader>{t('config.message.positionsTitle')}</PanelHeader>
<div className="flex flex-col gap-3 p-[13px]">
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.message.positionsHelp')}
</p>
<div className="flex flex-col border-2 border-ink">
{(positions.data ?? []).map((position) => (
<PositionMessageRow key={position.id} position={position} />
))}
</div>
</div>
</Panel>
</div>
);
}
function PositionMessageRow({ position }: { position: PositionRead }) {
const [open, setOpen] = useState(false);
const update = useUpdatePosition();
return (
<div className="border-b-2 border-ink last:border-b-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className={cx(
'flex w-full cursor-pointer items-baseline gap-2 px-[10px] py-2 text-left hover:bg-yellow-soft',
open && 'bg-sand',
)}
>
<span className="min-w-0 flex-1">
<span className="block font-display text-[12.5px] font-extrabold">{position.name}</span>
<span className="block truncate font-mono text-[10px] opacity-65">
{position.message ?? t('config.message.none')}
</span>
</span>
<span aria-hidden className="font-mono text-[10px]">
{open ? '▲' : '▼'}
</span>
</button>
{open ? (
<div className="border-t-2 border-ink bg-paper p-[10px]">
<MessageEditor
value={position.message}
updatedAt={position.message_updated_at}
placeholder={t('mobileLead.message.placeholder')}
saving={update.isPending}
error={update.error}
onSave={(text) => update.mutateAsync({ id: position.id, body: { message: text } })}
/>
</div>
) : null}
</div>
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export function ConfigRules() {
const shifts = useShifts(); const shifts = useShifts();
return ( return (
<div className="grid items-start gap-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]"> <div className="grid items-start gap-4 p-3 sm:p-4 [grid-template-columns:repeat(auto-fit,minmax(min(320px,100%),1fr))]">
<Panel shadow="lg"> <Panel shadow="lg">
<PanelHeader>{t('config.rules.editionTitle')}</PanelHeader> <PanelHeader>{t('config.rules.editionTitle')}</PanelHeader>
<div className="flex flex-col gap-2 p-[13px]"> <div className="flex flex-col gap-2 p-[13px]">
+192 -40
View File
@@ -9,11 +9,14 @@ import {
useCreateAccount, useCreateAccount,
useCreateVolunteer, useCreateVolunteer,
useDeleteAccount, useDeleteAccount,
useResetLoginLink,
useDeleteVolunteer, useDeleteVolunteer,
useExecuteImport, useExecuteImport,
useExportMecenatReceipts, useExportMecenatReceipts,
useExportVolunteers, useExportVolunteers,
usePositions, usePositions,
useRemoveVolunteerPhoto,
useSetVolunteerPhoto,
useUpdateAccount, useUpdateAccount,
useUpdatePosition, useUpdatePosition,
useUpdateVolunteer, useUpdateVolunteer,
@@ -39,6 +42,7 @@ import { foldAccents } from '@/lib/planning';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { datePart, shortDayLabel, timePart } from '@/lib/time'; import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [ const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
{ key: 'first_name', required: true }, { key: 'first_name', required: true },
@@ -109,6 +113,7 @@ function VolunteerAccessSection({
const accounts = useAccounts(); const accounts = useAccounts();
const createAccount = useCreateAccount(); const createAccount = useCreateAccount();
const deleteAccount = useDeleteAccount(); const deleteAccount = useDeleteAccount();
const resetLoginLink = useResetLoginLink();
const account = accounts.data?.find((a) => a.volunteer_id === volunteerId); const account = accounts.data?.find((a) => a.volunteer_id === volunteerId);
return ( return (
@@ -154,8 +159,29 @@ function VolunteerAccessSection({
{t('config.vol.access.needEmail')} {t('config.vol.access.needEmail')}
</div> </div>
) : null} ) : null}
{account && account.auth_method === 'magic_link' ? (
<div className="mt-[6px] flex flex-wrap items-center gap-2">
<button
type="button"
disabled={resetLoginLink.isPending}
onClick={() => {
if (window.confirm(t('config.vol.access.resetLinkConfirm'))) {
resetLoginLink.mutate(account.id);
}
}}
className="cursor-pointer border-2 border-ink bg-paper px-[8px] py-[5px] font-display text-[9.5px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:opacity-50"
>
{t('config.vol.access.resetLink')}
</button>
<HelpTip text={t('config.vol.access.resetLinkHint')} />
{resetLoginLink.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold">{t('config.vol.access.resetLinkDone')}</span>
) : null}
</div>
) : null}
{createAccount.isError ? <ErrorNote error={createAccount.error} /> : null} {createAccount.isError ? <ErrorNote error={createAccount.error} /> : null}
{deleteAccount.isError ? <ErrorNote error={deleteAccount.error} /> : null} {deleteAccount.isError ? <ErrorNote error={deleteAccount.error} /> : null}
{resetLoginLink.isError ? <ErrorNote error={resetLoginLink.error} /> : null}
</div> </div>
); );
} }
@@ -167,11 +193,124 @@ const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status
EXPIRED: 'config.vol.feeStatus.expired', EXPIRED: 'config.vol.feeStatus.expired',
}; };
/**
* Nombre d'exemplaires du badge imprimés sur les planches (1 à 100). Saisie
* libre gardée en local : n'enregistre qu'un entier valide, et revient à la
* dernière valeur enregistrée si le champ est quitté vide ou hors bornes.
*/
function BadgeCountInput({ value, onChange }: { value: number; onChange: (count: number) => void }) {
const [draft, setDraft] = useState(String(value));
const parse = (raw: string) => {
const n = Number(raw);
return Number.isInteger(n) && n >= 1 && n <= 100 ? n : null;
};
return (
<label className="block">
<FieldLabel>
{t('config.vol.field.badgeCount')}
<HelpTip text={t('config.vol.field.badgeCount.help')} className="ml-[6px]" />
</FieldLabel>
<input
type="number"
min={1}
max={100}
step={1}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
const n = parse(e.target.value);
if (n !== null && n !== value) onChange(n);
}}
onBlur={() => {
if (parse(draft) === null) setDraft(String(value));
}}
className="w-[90px] border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[13px] font-bold"
/>
</label>
);
}
/**
* Photo du bénévole (badge, écrans bénévole/référent, feuilles de poste) :
* celle envoyée au formulaire public, ou ajoutée ici par la coordination
* pour un bénévole qui n'en a pas donné (ajouté à la main, importé…).
*/
function VolunteerPhotoSection({ volunteer }: { volunteer: VolunteerRead }) {
const photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo, volunteer.updated_at);
const setPhoto = useSetVolunteerPhoto();
const removePhoto = useRemoveVolunteerPhoto();
const inputRef = useRef<HTMLInputElement>(null);
const pending = setPhoto.isPending || removePhoto.isPending;
return (
<div>
<FieldLabel>
{t('config.vol.photo.title')} <HelpTip text={t('config.vol.photo.hint')} />
</FieldLabel>
<div className="flex items-center gap-3">
{photoUrl ? (
<img
src={photoUrl}
alt=""
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
/>
) : (
<span className="flex h-[90px] w-[90px] flex-none items-center justify-center border-2 border-dashed border-ink/50 bg-cream font-mono text-[9.5px] opacity-60">
{t('config.vol.photo.none')}
</span>
)}
<div className="flex flex-col items-start gap-2">
<button
type="button"
disabled={pending}
onClick={() => inputRef.current?.click()}
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[7px] font-display text-[10.5px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:cursor-not-allowed disabled:opacity-50"
>
{setPhoto.isPending
? t('app.loading')
: volunteer.has_photo
? t('config.vol.photo.replace')
: t('config.vol.photo.add')}
</button>
{volunteer.has_photo ? (
<button
type="button"
disabled={pending}
onClick={() => {
if (window.confirm(t('config.vol.photo.removeConfirm'))) {
removePhoto.mutate(volunteer.id);
}
}}
className="cursor-pointer border-2 border-ink bg-paper px-[8px] py-[5px] font-display text-[9.5px] font-extrabold uppercase tracking-wide hover:bg-orange disabled:opacity-50"
>
{t('config.vol.photo.remove')}
</button>
) : null}
</div>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) setPhoto.mutate({ id: volunteer.id, file });
e.target.value = '';
}}
/>
</div>
{setPhoto.isError ? <ErrorNote error={setPhoto.error} /> : null}
{removePhoto.isError ? <ErrorNote error={removePhoto.error} /> : null}
</div>
);
}
/** /**
* Réponses du formulaire public d'inscription (voir `app/api/forms.py`) — * Réponses du formulaire public d'inscription (voir `app/api/forms.py`) —
* note de compagnon en texte libre (à résoudre manuellement en vrai vœu via * note de compagnon en texte libre (à résoudre manuellement en vrai vœu via
* le sélecteur ci-dessus une fois la personne visée elle-même inscrite), * le sélecteur ci-dessus une fois la personne visée elle-même inscrite),
* cases informatives, photo. N'apparaît que si le bénévole vient * cases informatives (la photo a sa propre section, `VolunteerPhotoSection`).
* N'apparaît que si le bénévole vient
* effectivement de ce formulaire (sinon rien à afficher). * effectivement de ce formulaire (sinon rien à afficher).
*/ */
function PublicFormAnswers({ function PublicFormAnswers({
@@ -181,12 +320,8 @@ function PublicFormAnswers({
volunteer: VolunteerRead; volunteer: VolunteerRead;
patch: (body: VolunteerUpdate) => void; patch: (body: VolunteerUpdate) => void;
}) { }) {
const photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo);
const hasAnything = const hasAnything =
volunteer.has_photo || Boolean(volunteer.companion_wish_note) || volunteer.self_declared_available;
Boolean(volunteer.companion_wish_note) ||
volunteer.self_declared_available;
if (!hasAnything) return null; if (!hasAnything) return null;
return ( return (
@@ -195,14 +330,6 @@ function PublicFormAnswers({
{t('config.vol.publicForm.title')} <HelpTip text={t('config.vol.publicForm.hint')} /> {t('config.vol.publicForm.title')} <HelpTip text={t('config.vol.publicForm.hint')} />
</FieldLabel> </FieldLabel>
{photoUrl ? (
<img
src={photoUrl}
alt=""
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
/>
) : null}
{volunteer.companion_wish_note ? ( {volunteer.companion_wish_note ? (
<label className="block"> <label className="block">
<FieldLabel>{t('form.field.companionWish')}</FieldLabel> <FieldLabel>{t('form.field.companionWish')}</FieldLabel>
@@ -571,8 +698,10 @@ function issueParams(issue: ImportIssue): Record<string, string | number> {
return out; return out;
} }
// Mobile : nom + cotisation seulement (téléphone, email et vœux restent sur
// la fiche) ; toutes les colonnes à partir de `md:`.
const LIST_COLUMNS = const LIST_COLUMNS =
'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.2fr) minmax(0,1.2fr)'; 'grid-cols-[minmax(0,1fr)_auto] md:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_minmax(0,2fr)_minmax(0,1.2fr)_minmax(0,1.2fr)]';
/** Résumé d'une ligne de la liste : mode de paiement (et mécénat), statut /** Résumé d'une ligne de la liste : mode de paiement (et mécénat), statut
* d'un paiement en ligne non abouti. */ * d'un paiement en ligne non abouti. */
@@ -677,7 +806,7 @@ export function ConfigVolunteers() {
return ( return (
<div className="flex min-h-0 flex-1 items-stretch"> <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="min-w-0 flex-1 p-3 sm:p-4 lg:max-h-[calc(100vh-118px)] lg:overflow-auto">
<div className="mb-[14px] flex flex-wrap items-stretch gap-3"> <div className="mb-[14px] flex flex-wrap items-stretch gap-3">
<div className="flex flex-1 flex-col justify-center gap-[10px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg"> <div className="flex flex-1 flex-col justify-center gap-[10px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
<div> <div>
@@ -757,13 +886,15 @@ export function ConfigVolunteers() {
) : ( ) : (
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg"> <div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div <div
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream" className={cx(
style={{ gridTemplateColumns: LIST_COLUMNS }} 'grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream',
LIST_COLUMNS,
)}
> >
<span>{t('config.vol.colName')}</span> <span>{t('config.vol.colName')}</span>
<span>{t('config.vol.colPhone')}</span> <span className="hidden md:block">{t('config.vol.colPhone')}</span>
<span>{t('config.vol.colEmail')}</span> <span className="hidden md:block">{t('config.vol.colEmail')}</span>
<span>{t('config.vol.colWishes')}</span> <span className="hidden md:block">{t('config.vol.colWishes')}</span>
<span>{t('config.vol.colFee')}</span> <span>{t('config.vol.colFee')}</span>
</div> </div>
{rows.map((volunteer) => { {rows.map((volunteer) => {
@@ -774,27 +905,26 @@ export function ConfigVolunteers() {
type="button" type="button"
onClick={() => setSelectedId(volunteer.id)} onClick={() => setSelectedId(volunteer.id)}
className={cx( className={cx(
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left', 'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[9px] text-left md:py-[7px]',
LIST_COLUMNS,
active ? 'bg-yellow' : 'hover:bg-cream', active ? 'bg-yellow' : 'hover:bg-cream',
)} )}
style={{ gridTemplateColumns: LIST_COLUMNS }}
> >
<span className="flex min-w-0 items-center gap-[7px]"> <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"> <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.first_name[0]} {volunteerInitials(volunteer)}
{volunteer.last_name[0]}
</span> </span>
<span className="truncate font-display text-[12.5px] font-bold"> <span className="truncate font-display text-[12.5px] font-bold">
{volunteer.first_name} {volunteer.last_name} {volunteerName(volunteer)}
</span> </span>
</span> </span>
<span className="truncate font-mono text-[11px] opacity-80"> <span className="hidden truncate font-mono text-[11px] opacity-80 md:block">
{volunteer.phone ?? '—'} {volunteer.phone ?? '—'}
</span> </span>
<span className="truncate font-mono text-[10.5px] opacity-70"> <span className="hidden truncate font-mono text-[10.5px] opacity-70 md:block">
{volunteer.email ?? '—'} {volunteer.email ?? '—'}
</span> </span>
<span className="truncate font-mono text-[10px] opacity-70"> <span className="hidden truncate font-mono text-[10px] opacity-70 md:block">
{t('config.vol.wishesCount', { {t('config.vol.wishesCount', {
count: count:
volunteer.artist_ids.length + volunteer.artist_ids.length +
@@ -815,8 +945,17 @@ export function ConfigVolunteers() {
)} )}
</div> </div>
{/* Fiche : à côté de la liste sur grand écran, par-dessus (plein écran
sur mobile, tiroir à droite sur tablette) en dessous de `lg:`. */}
{selected ? ( {selected ? (
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper"> <div
aria-hidden
onClick={() => setSelectedId(null)}
className="fixed inset-0 z-40 bg-ink/40 lg:hidden"
/>
) : null}
{selected ? (
<aside className="fixed inset-y-0 right-0 z-50 w-full overflow-auto bg-paper sm:w-[400px] sm:border-l-[3px] sm:border-ink lg:static lg:z-auto lg:max-h-[calc(100vh-118px)] lg:w-[360px] lg:flex-none lg:border-l-[3px]">
<div className="bg-ink px-[13px] py-[11px] text-cream"> <div className="bg-ink px-[13px] py-[11px] text-cream">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-[6px] font-mono text-[10px] text-yellow"> <div className="flex items-center gap-[6px] font-mono text-[10px] text-yellow">
@@ -826,18 +965,18 @@ export function ConfigVolunteers() {
<button <button
type="button" type="button"
onClick={() => setSelectedId(null)} onClick={() => setSelectedId(null)}
className="cursor-pointer font-mono text-[11px] text-cream/70 hover:text-cream" aria-label={t('action.close')}
className="h-8 w-8 cursor-pointer border-2 border-cream/40 font-mono text-[12px] text-cream/80 hover:text-cream lg:h-auto lg:w-auto lg:border-0"
> >
✕ ✕
</button> </button>
</div> </div>
<div className="mt-[7px] flex items-center gap-[9px]"> <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"> <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.first_name[0]} {volunteerInitials(selected)}
{selected.last_name[0]}
</span> </span>
<span className="font-display text-[18px] font-black tracking-tight"> <span className="font-display text-[18px] font-black tracking-tight">
{selected.first_name} {selected.last_name} {volunteerName(selected)}
</span> </span>
</div> </div>
</div> </div>
@@ -870,6 +1009,22 @@ export function ConfigVolunteers() {
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold" className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
/> />
</label> </label>
<label className="block">
<FieldLabel>
{t('config.vol.field.badgeText')}
<HelpTip text={t('config.vol.field.badgeText.help')} className="ml-[6px]" />
</FieldLabel>
<TextInput
value={selected.badge_text ?? ''}
onChange={(v) => patch({ badge_text: v || null })}
/>
</label>
<BadgeCountInput
key={`${selected.id}-${selected.badge_count}`}
value={selected.badge_count}
onChange={(badge_count) => patch({ badge_count })}
/>
<VolunteerPhotoSection key={selected.id} volunteer={selected} />
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} /> <VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} /> <VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
@@ -949,10 +1104,7 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
const submit = async () => { const submit = async () => {
setLocalError(null); setLocalError(null);
if (!form.first_name.trim() || !form.last_name.trim()) { // Aucun champ obligatoire : la fiche peut être complétée plus tard.
setLocalError(t('config.vol.requiredNames'));
return;
}
try { try {
await createVolunteer.mutateAsync({ await createVolunteer.mutateAsync({
first_name: form.first_name.trim(), first_name: form.first_name.trim(),
@@ -994,11 +1146,11 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
> >
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<label className="block"> <label className="block">
<FieldLabel>{t('config.vol.field.firstName')} *</FieldLabel> <FieldLabel>{t('config.vol.field.firstName')}</FieldLabel>
<TextInput value={form.first_name} onChange={(v) => set('first_name', v)} /> <TextInput value={form.first_name} onChange={(v) => set('first_name', v)} />
</label> </label>
<label className="block"> <label className="block">
<FieldLabel>{t('config.vol.field.lastName')} *</FieldLabel> <FieldLabel>{t('config.vol.field.lastName')}</FieldLabel>
<TextInput value={form.last_name} onChange={(v) => set('last_name', v)} /> <TextInput value={form.last_name} onChange={(v) => set('last_name', v)} />
</label> </label>
</div> </div>
@@ -27,6 +27,7 @@ import {
} from '@/lib/planning'; } from '@/lib/planning';
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time'; import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { volunteerInitials, volunteerName } from '@/lib/volunteer';
interface Props { interface Props {
positionId: string; positionId: string;
@@ -208,7 +209,7 @@ export function AssignmentPanel({
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="h-6 w-6 cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream" className="h-8 w-8 flex-none cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream lg:h-6 lg:w-6"
aria-label={t('action.close')} aria-label={t('action.close')}
> >
✕ ✕
@@ -250,7 +251,7 @@ export function AssignmentPanel({
{initials(volunteer)} {initials(volunteer)}
</span> </span>
<span className="min-w-0 flex-1 font-display text-[12px] font-bold"> <span className="min-w-0 flex-1 font-display text-[12px] font-bold">
{volunteer.first_name} {volunteer.last_name} {volunteerName(volunteer)}
<span className="block font-mono text-[9px] opacity-60"> <span className="block font-mono text-[9px] opacity-60">
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })} {t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
</span> </span>
@@ -348,7 +349,7 @@ function CandidateRow({
{initials(volunteer)} {initials(volunteer)}
</span> </span>
<span className="min-w-0 flex-1 font-display text-[13px] font-bold"> <span className="min-w-0 flex-1 font-display text-[13px] font-bold">
{volunteer.first_name} {volunteer.last_name} {volunteerName(volunteer)}
{suggested ? ( {suggested ? (
<span className="ml-[6px] inline-block border-[1.5px] border-ink bg-blue px-[5px] py-[1px] align-middle font-display text-[8px] font-extrabold uppercase tracking-wide"> <span className="ml-[6px] inline-block border-[1.5px] border-ink bg-blue px-[5px] py-[1px] align-middle font-display text-[8px] font-extrabold uppercase tracking-wide">
{t('grille.panel.suggested')} {t('grille.panel.suggested')}
@@ -436,7 +437,7 @@ function CandidateRow({
} }
function initials(v: VolunteerRead): string { function initials(v: VolunteerRead): string {
return `${v.first_name[0] ?? ''}${v.last_name[0] ?? ''}`; return volunteerInitials(v);
} }
function round(n: number): number { function round(n: number): number {
+5
View File
@@ -12,12 +12,17 @@ export default {
sand: '#EFE8D6', // barres secondaires sand: '#EFE8D6', // barres secondaires
'sand-line': '#E0D8C4', // séparateurs sur paper 'sand-line': '#E0D8C4', // séparateurs sur paper
'sand-dark': '#E7E0CE', 'sand-dark': '#E7E0CE',
'sand-mid': '#CFC7B4', // frise horaire : graduations, créneau terminé
yellow: '#FFD23F', yellow: '#FFD23F',
'yellow-soft': '#FFE68A', // état « incomplet » 'yellow-soft': '#FFE68A', // état « incomplet »
'yellow-hover': '#FFDF77', 'yellow-hover': '#FFDF77',
orange: '#FF5C39', orange: '#FF5C39',
'orange-dark': '#E0421F', 'orange-dark': '#E0421F',
green: '#0E9F6E', green: '#0E9F6E',
mint: '#C8F0DC', // pointé présent (compteur)
'mint-pale': '#EAF7EF', // carte d'un bénévole pointé présent
'orange-pale': '#FFF1EC', // créneau en cours
'orange-blush': '#FFE3DA', // carte d'un bénévole pointé absent
blue: '#A8C4FF', blue: '#A8C4FF',
'blue-strong': '#2A6BFF', // « non publié » 'blue-strong': '#2A6BFF', // « non publié »
mauve: '#CBA6E3', // état « sureffectif » mauve: '#CBA6E3', // état « sureffectif »