Compare commits

..
4 Commits
Author SHA1 Message Date
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
35 changed files with 1242 additions and 224 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.
+20 -4
View File
@@ -74,7 +74,10 @@ les tables sauf les tables d'association.
`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 +86,9 @@ 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 libre imprimé sur le badge
(champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la
fiche (« Texte du badge ») — jamais par le formulaire public.
- **`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
@@ -157,7 +162,8 @@ les tables sauf les tables d'association.
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 (`field_type` ∈
`FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE`, au plus un par édition — `FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`,
au plus un par édition —
`PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/ `PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge, `width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge,
`font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer) `font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer)
@@ -169,7 +175,14 @@ les tables sauf les tables d'association.
référent du poste (`Position.lead_account_id` → son bénévole) s'il y en a référent du poste (`Position.lead_account_id` → son bénévole) s'il y en a
un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes, un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes,
`\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ; `\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent »). `ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ;
`FREE_TEXT` est `Volunteer.badge_text` (vide ⇒ 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, le cadre reste vide plutôt que de faire échouer la
planche.
`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) 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
@@ -917,6 +930,9 @@ 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 |
**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, pointage sur site, IA. Frontend : squelette + 6 écrans +
+44 -2
View File
@@ -3,11 +3,14 @@
`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
@@ -77,3 +80,42 @@ 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
+9 -2
View File
@@ -3,8 +3,9 @@ et génération du PDF.
Le verso est une image fixe, la même pour tous les bénévoles. Le recto Le verso est une image fixe, la même pour tous les bénévoles. Le recto
combine une image de fond et des champs positionnés (nom, poste(s) affectés, combine une image de fond et des champs positionnés (nom, poste(s) affectés,
catégorie, rôle) — voir `app/badges/pdf.py` pour la composition du PDF et catégorie, rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py`
`app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle. pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
`BadgeField`) pour le modèle.
""" """
import io import io
@@ -278,6 +279,8 @@ def _field_text(
) -> str: ) -> str:
if field.field_type == BadgeFieldType.FULL_NAME: if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}" return f"{volunteer.first_name} {volunteer.last_name}"
if field.field_type == BadgeFieldType.FREE_TEXT:
return volunteer.badge_text or ""
assignments = assignments_by_volunteer.get(volunteer.id, []) assignments = assignments_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.POSITIONS: if field.field_type == BadgeFieldType.POSITIONS:
return "\n".join( return "\n".join(
@@ -355,6 +358,10 @@ 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,
) )
for field in fields for field in fields
) )
+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
+3
View File
@@ -74,6 +74,8 @@ 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
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 +93,7 @@ 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
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`) —
+40 -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,
@@ -342,6 +343,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,
+52 -1
View File
@@ -18,6 +18,7 @@ recto-verso classique, page de verso n directement après la page de recto n.
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
@@ -50,7 +51,11 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu
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. un poste par ligne avec son horaire), positionné depuis
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end).""" le coin haut-gauche 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 — `text` et le style sont
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné."""
text: str text: str
x_mm: float x_mm: float
@@ -61,6 +66,8 @@ 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
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -114,6 +121,47 @@ def _draw_background(
) )
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:
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 _draw_photo(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
if not f.image:
return
photo = _fit_photo(f.image, f.width_mm, f.height_mm)
if photo is None:
return
c.drawImage(
ImageReader(io.BytesIO(photo)),
(x0_mm + f.x_mm) * mm,
# `f.y_mm` est le haut du cadre depuis le HAUT du badge ; reportlab
# place l'image par son coin bas-gauche, depuis le BAS de la page.
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
width=f.width_mm * mm,
height=f.height_mm * mm,
)
def _draw_fields( def _draw_fields(
c: canvas.Canvas, c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...], fields: tuple[BadgeFieldSpec, ...],
@@ -122,6 +170,9 @@ def _draw_fields(
badge_height_mm: float, badge_height_mm: float,
) -> None: ) -> None:
for f in fields: for f in fields:
if f.is_photo:
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm)
continue
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size) c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.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
+2
View File
@@ -36,6 +36,7 @@ _HEADER = [
"Mécénat — code postal", "Mécénat — code postal",
"Mécénat — pays", "Mécénat — pays",
"Commentaire", "Commentaire",
"Texte du badge",
"Inscrit le", "Inscrit le",
] ]
@@ -123,6 +124,7 @@ 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 ""),
_fmt_dt(v.created_at), _fmt_dt(v.created_at),
] ]
) )
+1
View File
@@ -78,6 +78,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")
+3
View File
@@ -183,6 +183,9 @@ 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 libre imprimé sur le badge (champ `BadgeFieldType.FREE_TEXT`),
# saisi par la coordination sur la fiche — ex. « Accès backstage ».
badge_text: str | None = None
# 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(
+5
View File
@@ -37,6 +37,11 @@ class BadgeFieldType(enum.StrEnum):
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 propre à chaque bénévole, saisi par la coordination sur sa
# fiche (`Volunteer.badge_text`).
FREE_TEXT = "FREE_TEXT"
class BadgeAlign(enum.StrEnum): class BadgeAlign(enum.StrEnum):
+47
View File
@@ -609,3 +609,50 @@ 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
+37
View File
@@ -207,3 +207,40 @@ 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,
},
]
},
)
assert resp.status_code == 200
assert {f["field_type"] for f in resp.json()} == {"PHOTO", "FREE_TEXT"}
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")
+12
View File
@@ -99,3 +99,15 @@ 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()) == ""
+54
View File
@@ -1,11 +1,17 @@
"""Génération des planches de badges (module pur, sans base).""" """Génération des planches de badges (module pur, sans base)."""
import io
from PIL import Image
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,
_cell_origin, _cell_origin,
_fit_photo,
_grid_size, _grid_size,
generate_badge_sheets_pdf, generate_badge_sheets_pdf,
image_size_mm, image_size_mm,
@@ -97,3 +103,51 @@ 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")
+27 -4
View File
@@ -103,9 +103,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 +129,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;
} }
@@ -361,6 +367,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({
+7
View File
@@ -155,6 +155,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', []);
+1
View File
@@ -289,6 +289,7 @@ 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,
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,
+20
View File
@@ -234,6 +234,7 @@ 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,
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,
@@ -995,6 +996,7 @@ 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,
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 +1041,7 @@ 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 } : {}),
...('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 +1069,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(
@@ -1302,6 +1320,7 @@ 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,
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,
@@ -1772,6 +1791,7 @@ 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,
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,
+13 -1
View File
@@ -107,6 +107,9 @@ 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;
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 +160,7 @@ 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;
self_declared_available?: boolean | null; self_declared_available?: boolean | null;
willing_to_pay_fee?: boolean | null; willing_to_pay_fee?: boolean | null;
} }
@@ -419,7 +423,15 @@ 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';
export interface BadgeFieldIn { export interface BadgeFieldIn {
+6 -1
View File
@@ -88,8 +88,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
+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>
);
}
+19 -1
View File
@@ -54,6 +54,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",
@@ -228,6 +230,15 @@
"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.badgeText.help": "Imprimé sur le badge si le champ « Texte libre » y est placé (écran Badges).",
"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 : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.",
"config.vol.access.grant": "Donner un accès de connexion", "config.vol.access.grant": "Donner un accès de connexion",
@@ -338,7 +349,7 @@
"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",
@@ -350,6 +361,12 @@
"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 propre à chaque bénévole, à saisir sur sa fiche (Configuration › Bénévoles, « Texte du badge »). Vide = rien d'imprimé.",
"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)",
@@ -533,6 +550,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.",
+86 -23
View File
@@ -23,17 +23,51 @@ import { cx } from '@/lib/cx';
// l'écran ; les coordonnées enregistrées restent en mm. // l'écran ; les coordonnées enregistrées restent en mm.
const DEFAULT_BADGE_W_MM = 85.6; const DEFAULT_BADGE_W_MM = 85.6;
const DEFAULT_BADGE_H_MM = 54; const DEFAULT_BADGE_H_MM = 54;
const SCALE = 4.6; // px par mm à l'écran const SCALE = 4.6; // px par mm à l'écran (au plus)
// Marges horizontales autour de l'aperçu (page + panneau + bordures), pour
// le faire tenir dans un écran étroit.
const PREVIEW_GUTTER_PX = 90;
/** Échelle d'édition : `SCALE`, réduite sur un écran trop étroit pour un
* badge à pleine taille (téléphone, tablette en portrait). Les coordonnées
* enregistrées restent en mm, seule la conversion px ↔ mm suit l'échelle. */
function useEditorScale(badgeWidthMm: number): number {
const [viewport, setViewport] = useState(() =>
typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth,
);
useEffect(() => {
const onResize = () => setViewport(window.innerWidth);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
return Math.min(SCALE, Math.max(1, (viewport - PREVIEW_GUTTER_PX) / badgeWidthMm));
}
type FieldState = BadgeFieldIn & { enabled: boolean }; type FieldState = BadgeFieldIn & { enabled: boolean };
const FIELD_TYPES: BadgeFieldType[] = ['FULL_NAME', 'POSITIONS', 'CATEGORY', 'ROLE']; const FIELD_TYPES: BadgeFieldType[] = [
'FULL_NAME',
'POSITIONS',
'CATEGORY',
'ROLE',
'PHOTO',
'FREE_TEXT',
];
const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = { const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName', FULL_NAME: 'badges.field.fullName',
POSITIONS: 'badges.field.positions', POSITIONS: 'badges.field.positions',
CATEGORY: 'badges.field.category', CATEGORY: 'badges.field.category',
ROLE: 'badges.field.role', ROLE: 'badges.field.role',
PHOTO: 'badges.field.photo',
FREE_TEXT: 'badges.field.freeText',
};
// Indication affichée à côté du nom du champ, pour ceux dont l'origine du
// contenu n'est pas évidente.
const FIELD_HELP_KEY: Partial<Record<BadgeFieldType, string>> = {
PHOTO: 'badges.field.photo.help',
FREE_TEXT: 'badges.field.freeText.help',
}; };
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = { const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
@@ -41,6 +75,8 @@ const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
POSITIONS: 'badges.field.positions.placeholder', POSITIONS: 'badges.field.positions.placeholder',
CATEGORY: 'badges.field.category.placeholder', CATEGORY: 'badges.field.category.placeholder',
ROLE: 'badges.field.role.placeholder', ROLE: 'badges.field.role.placeholder',
PHOTO: 'badges.field.photo.placeholder',
FREE_TEXT: 'badges.field.freeText.placeholder',
}; };
// Position/taille par défaut à l'activation d'un champ — empilés // Position/taille par défaut à l'activation d'un champ — empilés
@@ -65,6 +101,16 @@ const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> =
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6, x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b', font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
}, },
// Format portrait d'identité (≈ 3,5 × 4,5 cm réduit), en haut à droite.
// Taille de police/couleur sans effet : la photo est une image.
PHOTO: {
x_mm: 58, y_mm: 4, width_mm: 22, height_mm: 28,
font_size: 8, align: 'CENTER', bold: false, color: '#000000',
},
FREE_TEXT: {
x_mm: 5, y_mm: 39, width_mm: 50, height_mm: 7,
font_size: 9, align: 'LEFT', bold: true, color: '#000000',
},
}; };
function clamp(value: number, min: number, max: number): number { function clamp(value: number, min: number, max: number): number {
@@ -126,11 +172,12 @@ function BadgeCustomizeTab() {
if (template.data) setFieldsState(fieldsFromServer(template.data.fields)); if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
}, [template.data]); }, [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 badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM; const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
const scale = useEditorScale(badgeW);
if (template.isLoading || !fields) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => { const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev)); setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
@@ -157,7 +204,7 @@ function BadgeCustomizeTab() {
}; };
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 gap-4 overflow-auto p-3 sm:p-4 lg:flex-row lg:items-start">
<div className="flex flex-none flex-col gap-4"> <div className="flex flex-none flex-col gap-4">
<Panel> <Panel>
<PanelHeader> <PanelHeader>
@@ -182,8 +229,8 @@ function BadgeCustomizeTab() {
<div <div
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none" className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
style={{ style={{
width: badgeW * SCALE, width: badgeW * scale,
height: badgeH * SCALE, height: badgeH * scale,
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined, backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
}} }}
onPointerDown={() => setSelected(null)} onPointerDown={() => setSelected(null)}
@@ -194,6 +241,7 @@ function BadgeCustomizeTab() {
field={fields[type]} field={fields[type]}
badgeW={badgeW} badgeW={badgeW}
badgeH={badgeH} badgeH={badgeH}
scale={scale}
selected={selected === type} selected={selected === type}
onSelect={() => setSelected(type)} onSelect={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)} onChange={(patch) => updateField(type, patch)}
@@ -223,13 +271,13 @@ function BadgeCustomizeTab() {
/> />
<div <div
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center" 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 }} style={{ width: badgeW * scale, height: badgeH * scale, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
/> />
</div> </div>
</Panel> </Panel>
</div> </div>
<Panel className="min-w-[320px] flex-1"> <Panel className="min-w-0 flex-1 lg:min-w-[320px]">
<PanelHeader>{t('badges.fields')}</PanelHeader> <PanelHeader>{t('badges.fields')}</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) => (
@@ -353,6 +401,7 @@ function FieldBox({
field, field,
badgeW, badgeW,
badgeH, badgeH,
scale,
selected, selected,
onSelect, onSelect,
onChange, onChange,
@@ -360,6 +409,7 @@ function FieldBox({
field: FieldState; field: FieldState;
badgeW: number; badgeW: number;
badgeH: number; badgeH: number;
scale: number;
selected: boolean; selected: boolean;
onSelect: () => void; onSelect: () => void;
onChange: (patch: Partial<FieldState>) => void; onChange: (patch: Partial<FieldState>) => void;
@@ -381,8 +431,8 @@ function FieldBox({
const onDragMove = (e: React.PointerEvent) => { const onDragMove = (e: React.PointerEvent) => {
if (!dragRef.current) return; if (!dragRef.current) return;
const d = dragRef.current; const d = dragRef.current;
const dxMm = (e.clientX - d.pointerXPx) / SCALE; const dxMm = (e.clientX - d.pointerXPx) / scale;
const dyMm = (e.clientY - d.pointerYPx) / SCALE; const dyMm = (e.clientY - d.pointerYPx) / scale;
onChange({ onChange({
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm), x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm), y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
@@ -399,8 +449,8 @@ function FieldBox({
const onResizeMove = (e: React.PointerEvent) => { const onResizeMove = (e: React.PointerEvent) => {
if (!resizeRef.current) return; if (!resizeRef.current) return;
const d = resizeRef.current; const d = resizeRef.current;
const dwMm = (e.clientX - d.pointerXPx) / SCALE; const dwMm = (e.clientX - d.pointerXPx) / scale;
const dhMm = (e.clientY - d.pointerYPx) / SCALE; const dhMm = (e.clientY - d.pointerYPx) / scale;
onChange({ onChange({
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm), width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm), height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
@@ -413,20 +463,25 @@ function FieldBox({
onPointerMove={onDragMove} onPointerMove={onDragMove}
onPointerUp={() => (dragRef.current = null)} onPointerUp={() => (dragRef.current = null)}
className={cx( className={cx(
'absolute flex cursor-move items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]', 'absolute flex cursor-move touch-none items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
selected ? 'border-blue-strong' : 'border-ink/40', selected ? 'border-blue-strong' : 'border-ink/40',
)} )}
style={{ style={{
left: field.x_mm * SCALE, left: field.x_mm * scale,
top: field.y_mm * SCALE, top: field.y_mm * scale,
width: field.width_mm * SCALE, width: field.width_mm * scale,
height: field.height_mm * SCALE, height: field.height_mm * scale,
}} }}
> >
{field.field_type === 'PHOTO' ? (
<span className="pointer-events-none flex h-full w-full items-center justify-center bg-sand-dark/70 font-mono text-[10px] font-bold uppercase text-ink/60">
{t(FIELD_PLACEHOLDER_KEY.PHOTO)}
</span>
) : (
<span <span
className="pointer-events-none whitespace-pre-line font-sans leading-tight" className="pointer-events-none whitespace-pre-line font-sans leading-tight"
style={{ style={{
fontSize: field.font_size * (SCALE / 2.83), // pt -> px approx. à l'échelle du badge fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge
fontWeight: field.bold ? 800 : 400, fontWeight: field.bold ? 800 : 400,
color: field.color, color: field.color,
width: '100%', width: '100%',
@@ -435,11 +490,12 @@ function FieldBox({
> >
{t(FIELD_PLACEHOLDER_KEY[field.field_type])} {t(FIELD_PLACEHOLDER_KEY[field.field_type])}
</span> </span>
)}
<div <div
onPointerDown={startResize} onPointerDown={startResize}
onPointerMove={onResizeMove} onPointerMove={onResizeMove}
onPointerUp={() => (resizeRef.current = null)} 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" className="absolute bottom-0 right-0 h-[14px] w-[14px] cursor-nwse-resize touch-none lg:h-[10px] lg:w-[10px] border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
/> />
</div> </div>
); );
@@ -460,6 +516,7 @@ function FieldEditorRow({
}) { }) {
return ( return (
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}> <div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
<div className="flex items-center">
<label className="flex items-center gap-[8px]"> <label className="flex items-center gap-[8px]">
<input <input
type="checkbox" type="checkbox"
@@ -471,8 +528,14 @@ function FieldEditorRow({
{t(FIELD_LABEL_KEY[type])} {t(FIELD_LABEL_KEY[type])}
</span> </span>
</label> </label>
{FIELD_HELP_KEY[type] ? (
<HelpTip text={t(FIELD_HELP_KEY[type])} className="ml-[6px]" />
) : null}
</div>
{field.enabled ? ( {/* La photo n'a pas de style de texte : seuls sa place et son cadre
(sur l'aperçu) comptent. */}
{field.enabled && type !== 'PHOTO' ? (
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]"> <div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
<label className="flex items-center gap-[5px]"> <label className="flex items-center gap-[5px]">
<FieldLabel>{t('badges.field.size')}</FieldLabel> <FieldLabel>{t('badges.field.size')}</FieldLabel>
@@ -570,7 +633,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">
+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}
+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>
+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>
+1 -1
View File
@@ -32,7 +32,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" />
+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]">
+119 -28
View File
@@ -14,6 +14,8 @@ import {
useExportMecenatReceipts, useExportMecenatReceipts,
useExportVolunteers, useExportVolunteers,
usePositions, usePositions,
useRemoveVolunteerPhoto,
useSetVolunteerPhoto,
useUpdateAccount, useUpdateAccount,
useUpdatePosition, useUpdatePosition,
useUpdateVolunteer, useUpdateVolunteer,
@@ -167,11 +169,87 @@ const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status
EXPIRED: 'config.vol.feeStatus.expired', EXPIRED: 'config.vol.feeStatus.expired',
}; };
/**
* 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 +259,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 +269,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 +637,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 +745,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 +825,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,10 +844,10 @@ 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">
@@ -788,13 +858,13 @@ export function ConfigVolunteers() {
{volunteer.first_name} {volunteer.last_name} {volunteer.first_name} {volunteer.last_name}
</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 +885,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,7 +905,8 @@ 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>
@@ -870,6 +950,17 @@ 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>
<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} />
@@ -208,7 +208,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')}
> >
✕ ✕