Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6892e0307d | ||
|
|
54cba467fc | ||
|
|
5b493c9737 | ||
|
|
85e841ae4a |
+362
@@ -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.
|
||||
@@ -74,7 +74,10 @@ les tables sauf les tables d'association.
|
||||
`wished_companion_ids` ; `None`/absent = inchangé, `[]` = vider).
|
||||
Renseignables aussi via le formulaire public (§ 9) : `photo`/
|
||||
`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
|
||||
forcément encore en base au moment de l'inscription, donc pas de lien
|
||||
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
|
||||
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
|
||||
`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`,
|
||||
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
||||
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
|
||||
déformation, `preserveAspectRatio=True`) à ce même format.
|
||||
- **`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`/
|
||||
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge,
|
||||
`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
|
||||
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 ;
|
||||
`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?}` —
|
||||
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
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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
|
||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
`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`
|
||||
(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
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, UploadFile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import delete
|
||||
from sqlmodel import Session, select
|
||||
|
||||
@@ -77,3 +80,42 @@ def set_wished_positions(
|
||||
filter_value=volunteer.id,
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
`app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle.
|
||||
catégorie, rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py`
|
||||
pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
|
||||
`BadgeField`) pour le modèle.
|
||||
"""
|
||||
|
||||
import io
|
||||
@@ -278,6 +279,8 @@ def _field_text(
|
||||
) -> str:
|
||||
if field.field_type == BadgeFieldType.FULL_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, [])
|
||||
if field.field_type == BadgeFieldType.POSITIONS:
|
||||
return "\n".join(
|
||||
@@ -355,6 +358,10 @@ def generate_badges(
|
||||
align=field.align.value,
|
||||
bold=field.bold,
|
||||
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
|
||||
)
|
||||
|
||||
@@ -25,7 +25,6 @@ coordination, faute de savoir si la personne visée est déjà inscrite (voir
|
||||
CLAUDE.md § 2, `Volunteer`)."""
|
||||
|
||||
import datetime as dt
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
@@ -43,13 +42,15 @@ from fastapi import (
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api._common import (
|
||||
ALLOWED_PHOTO_TYPES,
|
||||
MAX_PHOTO_BYTES,
|
||||
owned_or_404,
|
||||
read_photo,
|
||||
resolve_owned_ids,
|
||||
set_artists,
|
||||
set_wished_positions,
|
||||
@@ -109,8 +110,6 @@ logger = logging.getLogger("app.forms")
|
||||
|
||||
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
|
||||
# postes « au cas où » — force une vraie priorisation dès le formulaire.
|
||||
_MAX_POSITION_WISHES = 2
|
||||
@@ -340,14 +339,14 @@ def delete_payment_option(
|
||||
|
||||
async def _read_logo(file: UploadFile) -> tuple[bytes, str]:
|
||||
content_type = file.content_type or ""
|
||||
if content_type not in _ALLOWED_PHOTO_TYPES:
|
||||
if content_type not in ALLOWED_PHOTO_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=422, detail={"code": "form.logo_type_not_supported"}
|
||||
)
|
||||
content = await file.read()
|
||||
if not content:
|
||||
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"})
|
||||
return content, content_type
|
||||
|
||||
@@ -462,39 +461,6 @@ def _prune_stale_pending(session: Session, edition_id: uuid.UUID) -> None:
|
||||
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(
|
||||
session: Session, model, edition_id: uuid.UUID, ids: 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
|
||||
)
|
||||
|
||||
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:
|
||||
# Pas de paiement en ligne (case désactivée, ou « je paierai sur
|
||||
|
||||
@@ -74,6 +74,8 @@ class VolunteerUpdate(ApiModel):
|
||||
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
|
||||
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
|
||||
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
|
||||
willing_to_pay_fee: bool | None = None
|
||||
|
||||
@@ -91,6 +93,7 @@ class VolunteerRead(ApiModel):
|
||||
wished_companion_ids: list[uuid.UUID]
|
||||
has_photo: bool
|
||||
companion_wish_note: str | None
|
||||
badge_text: str | None
|
||||
self_declared_available: bool
|
||||
willing_to_pay_fee: bool
|
||||
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
|
||||
|
||||
@@ -5,12 +5,13 @@ L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
|
||||
|
||||
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 sqlmodel import Session, select
|
||||
|
||||
from app.api._common import (
|
||||
owned_or_404,
|
||||
read_photo,
|
||||
replace_links,
|
||||
resolve_owned_ids,
|
||||
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)
|
||||
def update_volunteer(
|
||||
volunteer_id: uuid.UUID,
|
||||
|
||||
@@ -18,6 +18,7 @@ recto-verso classique, page de verso n directement après la page de recto n.
|
||||
import io
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
from reportlab.lib.colors import HexColor
|
||||
from reportlab.lib.pagesizes import A4
|
||||
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:
|
||||
"""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
|
||||
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
|
||||
x_mm: float
|
||||
@@ -61,6 +66,8 @@ class BadgeFieldSpec:
|
||||
align: str # "LEFT" | "CENTER" | "RIGHT"
|
||||
bold: bool
|
||||
color: str # "#rrggbb"
|
||||
is_photo: bool = False
|
||||
image: bytes | None = None
|
||||
|
||||
|
||||
@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(
|
||||
c: canvas.Canvas,
|
||||
fields: tuple[BadgeFieldSpec, ...],
|
||||
@@ -122,6 +170,9 @@ def _draw_fields(
|
||||
badge_height_mm: float,
|
||||
) -> None:
|
||||
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.setFillColor(HexColor(f.color))
|
||||
left_x = x0_mm + f.x_mm
|
||||
|
||||
@@ -36,6 +36,7 @@ _HEADER = [
|
||||
"Mécénat — code postal",
|
||||
"Mécénat — pays",
|
||||
"Commentaire",
|
||||
"Texte du badge",
|
||||
"Inscrit le",
|
||||
]
|
||||
|
||||
@@ -123,6 +124,7 @@ def generate_volunteers_xlsx(
|
||||
_safe_text(v.mecenat_postal_code or ""),
|
||||
_safe_text(v.mecenat_country or ""),
|
||||
_safe_text(v.comment or ""),
|
||||
_safe_text(v.badge_text or ""),
|
||||
_fmt_dt(v.created_at),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"form.captcha_failed": "Vérification anti-robot invalide, réessayez.",
|
||||
"form.invalid_payload": "Données du formulaire invalides.",
|
||||
"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.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||
"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")
|
||||
@@ -183,6 +183,9 @@ class Volunteer(TimestampMixin, table=True):
|
||||
# cette note sur la fiche et la transforme, si besoin, en un vrai
|
||||
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
||||
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
|
||||
# logique n'y est attachée (pas de workflow de paiement).
|
||||
self_declared_available: bool = Field(
|
||||
|
||||
@@ -37,6 +37,11 @@ class BadgeFieldType(enum.StrEnum):
|
||||
POSITIONS = "POSITIONS"
|
||||
CATEGORY = "CATEGORY"
|
||||
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):
|
||||
|
||||
@@ -609,3 +609,50 @@ def test_export_gives_the_sumup_transaction(client, edition, session):
|
||||
assert fiche["fee_transaction_code"] == "TEENSK4W2K"
|
||||
# L'identifiant du checkout, lui, reste interne.
|
||||
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
|
||||
|
||||
@@ -207,3 +207,40 @@ def test_generating_produces_a_pdf(client, edition, session):
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "application/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")
|
||||
|
||||
@@ -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={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()) == ""
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
"""Génération des planches de badges (module pur, sans base)."""
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.badges.pdf import (
|
||||
ASSUMED_DPI,
|
||||
DEFAULT_BADGE_HEIGHT_MM,
|
||||
DEFAULT_BADGE_WIDTH_MM,
|
||||
BadgeFieldSpec,
|
||||
BadgeSpec,
|
||||
_cell_origin,
|
||||
_fit_photo,
|
||||
_grid_size,
|
||||
generate_badge_sheets_pdf,
|
||||
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
|
||||
)
|
||||
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")
|
||||
|
||||
@@ -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
|
||||
* `null` si absente. */
|
||||
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) {
|
||||
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public ou
|
||||
* ajoutée par la coordination), ou `null` si absente. `version` (ex.
|
||||
* `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);
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
@@ -123,7 +129,7 @@ export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, has
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [volunteerId, hasPhoto]);
|
||||
}, [volunteerId, hasPhoto, version]);
|
||||
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() {
|
||||
const invalidate = useInvalidate();
|
||||
return useMutation({
|
||||
|
||||
@@ -155,6 +155,13 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
||||
planning: (editionId, id) =>
|
||||
request(`${base(editionId)}/volunteers/${id}/planning`),
|
||||
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) => {
|
||||
const blob = await requestBlob(`${base(editionId)}/volunteers/export`);
|
||||
if (!blob) throw new ApiError(500, 'error.generic', []);
|
||||
|
||||
@@ -289,6 +289,7 @@ export function buildMockState(): MockState {
|
||||
wished_companion_ids,
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
|
||||
@@ -234,6 +234,7 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
||||
wished_companion_ids: [],
|
||||
has_photo: pending.photo !== null,
|
||||
companion_wish_note: pending.companionWishNote,
|
||||
badge_text: null,
|
||||
self_declared_available: pending.available,
|
||||
willing_to_pay_fee: true,
|
||||
fee_payment_label: pending.paymentLabel,
|
||||
@@ -995,6 +996,7 @@ export function createMockApi(): PlanningApi {
|
||||
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -1039,6 +1041,7 @@ export function createMockApi(): PlanningApi {
|
||||
...('companion_wish_note' in body
|
||||
? { 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: body.self_declared_available }
|
||||
: {}),
|
||||
@@ -1066,6 +1069,21 @@ export function createMockApi(): PlanningApi {
|
||||
if (!dataUrl) return null;
|
||||
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()),
|
||||
exportMecenatReceipts: () => {
|
||||
const paid = state.volunteers.some(
|
||||
@@ -1302,6 +1320,7 @@ export function createMockApi(): PlanningApi {
|
||||
wished_companion_ids: [],
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -1772,6 +1791,7 @@ export function createMockApi(): PlanningApi {
|
||||
wished_companion_ids: [],
|
||||
has_photo: photo !== null,
|
||||
companion_wish_note: companionWishNote,
|
||||
badge_text: null,
|
||||
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
||||
fee_payment_label: null,
|
||||
|
||||
@@ -107,6 +107,9 @@ export interface VolunteerRead {
|
||||
/** Renseignés par le formulaire public d'inscription (voir schema forms). */
|
||||
has_photo: boolean;
|
||||
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;
|
||||
willing_to_pay_fee: boolean;
|
||||
/** Paiement de la cotisation via SumUp (voir schema forms) —
|
||||
@@ -157,6 +160,7 @@ export interface VolunteerUpdate {
|
||||
wished_position_ids?: string[] | null;
|
||||
wished_companion_ids?: string[] | null;
|
||||
companion_wish_note?: string | null;
|
||||
badge_text?: string | null;
|
||||
self_declared_available?: boolean | null;
|
||||
willing_to_pay_fee?: boolean | null;
|
||||
}
|
||||
@@ -419,7 +423,15 @@ export interface MatrixResponse {
|
||||
// --------------------------------------------------------------------------- //
|
||||
// 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 interface BadgeFieldIn {
|
||||
|
||||
@@ -88,8 +88,13 @@ export interface PlanningApi {
|
||||
remove(editionId: string, id: string): Promise<void>;
|
||||
/** Planning enrichi (poste, lieu, référent) — écran bénévole. */
|
||||
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>;
|
||||
/** 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). */
|
||||
exportXlsx(editionId: string): Promise<Blob>;
|
||||
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF
|
||||
|
||||
+112
-32
@@ -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 { useVolunteers } from '@/api/hooks';
|
||||
@@ -17,20 +18,30 @@ export function AppShell() {
|
||||
const initials = account
|
||||
? 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 (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-20 flex items-stretch border-b-[3px] border-ink bg-yellow">
|
||||
<div className="flex flex-col justify-center border-r-[3px] border-ink px-4 py-2">
|
||||
<div className="font-display text-[14px] font-black tracking-tight">
|
||||
<header className="sticky top-0 z-30 border-b-[3px] border-ink bg-yellow">
|
||||
<div className="flex items-stretch">
|
||||
<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')}
|
||||
</div>
|
||||
{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 })}
|
||||
</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)) : ''}
|
||||
</div>
|
||||
)}
|
||||
@@ -38,40 +49,22 @@ export function AppShell() {
|
||||
|
||||
{/* Rien à choisir avec un seul écran (bénévole, référent) : la
|
||||
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) => (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className={({ isActive }) =>
|
||||
cx(
|
||||
'relative flex min-w-[112px] flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4',
|
||||
'font-display text-[11.5px] font-bold uppercase tracking-wide',
|
||||
isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
|
||||
<span>{t(tab.labelKey)}</span>
|
||||
{isActive ? (
|
||||
<span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" />
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
<TabLink key={tab.to} tab={tab} />
|
||||
))}
|
||||
</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' ? (
|
||||
<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')}
|
||||
</div>
|
||||
) : null}
|
||||
<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}
|
||||
>
|
||||
{initials}
|
||||
@@ -79,11 +72,68 @@ export function AppShell() {
|
||||
<button
|
||||
type="button"
|
||||
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')}
|
||||
</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>
|
||||
|
||||
{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>
|
||||
|
||||
<main className="flex flex-1 flex-col">
|
||||
@@ -92,3 +142,33 @@ export function AppShell() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"header.title": "GESTION DES BÉNÉVOLES",
|
||||
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
||||
"header.mockMode": "DONNÉES DE DÉMO",
|
||||
"header.openMenu": "Menu",
|
||||
"header.closeMenu": "Fermer le menu",
|
||||
"tab.grille": "Grille des besoins",
|
||||
"tab.volunteers": "Vue par bénévole",
|
||||
"tab.mobileVolunteer": "Bénévole · mobile",
|
||||
@@ -228,6 +230,15 @@
|
||||
"config.vol.field.email": "Email (lien magique)",
|
||||
"config.vol.field.phone": "Téléphone",
|
||||
"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.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",
|
||||
@@ -338,7 +349,7 @@
|
||||
"badges.tab.customize": "PERSONNALISER",
|
||||
"badges.tab.generate": "GÉNÉRER",
|
||||
"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.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",
|
||||
@@ -350,6 +361,12 @@
|
||||
"badges.field.category.placeholder": "Bar",
|
||||
"badges.field.role": "Rôle (référent / bénévole)",
|
||||
"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.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)",
|
||||
@@ -533,6 +550,7 @@
|
||||
"form.captcha_failed": "Vérification anti-robot invalide, réessaie.",
|
||||
"form.invalid_payload": "Données du formulaire invalides.",
|
||||
"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.logo_type_not_supported": "Le logo doit être au format PNG ou JPEG.",
|
||||
"form.logo_empty": "Le logo est vide.",
|
||||
|
||||
@@ -23,17 +23,51 @@ import { cx } from '@/lib/cx';
|
||||
// l'écran ; les coordonnées enregistrées restent en mm.
|
||||
const DEFAULT_BADGE_W_MM = 85.6;
|
||||
const DEFAULT_BADGE_H_MM = 54;
|
||||
const SCALE = 4.6; // px par mm à l'écran
|
||||
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 };
|
||||
|
||||
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> = {
|
||||
FULL_NAME: 'badges.field.fullName',
|
||||
POSITIONS: 'badges.field.positions',
|
||||
CATEGORY: 'badges.field.category',
|
||||
ROLE: 'badges.field.role',
|
||||
PHOTO: 'badges.field.photo',
|
||||
FREE_TEXT: 'badges.field.freeText',
|
||||
};
|
||||
|
||||
// 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> = {
|
||||
@@ -41,6 +75,8 @@ const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
|
||||
POSITIONS: 'badges.field.positions.placeholder',
|
||||
CATEGORY: 'badges.field.category.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
|
||||
@@ -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,
|
||||
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 {
|
||||
@@ -126,11 +172,12 @@ function BadgeCustomizeTab() {
|
||||
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
|
||||
}, [template.data]);
|
||||
|
||||
if (template.isLoading || !fields) return <Loading />;
|
||||
if (template.error) return <ErrorNote error={template.error} />;
|
||||
|
||||
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
||||
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
|
||||
const scale = useEditorScale(badgeW);
|
||||
|
||||
if (template.isLoading || !fields) return <Loading />;
|
||||
if (template.error) return <ErrorNote error={template.error} />;
|
||||
|
||||
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
|
||||
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
|
||||
@@ -157,7 +204,7 @@ function BadgeCustomizeTab() {
|
||||
};
|
||||
|
||||
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">
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
@@ -182,8 +229,8 @@ function BadgeCustomizeTab() {
|
||||
<div
|
||||
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
|
||||
style={{
|
||||
width: badgeW * SCALE,
|
||||
height: badgeH * SCALE,
|
||||
width: badgeW * scale,
|
||||
height: badgeH * scale,
|
||||
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
|
||||
}}
|
||||
onPointerDown={() => setSelected(null)}
|
||||
@@ -194,6 +241,7 @@ function BadgeCustomizeTab() {
|
||||
field={fields[type]}
|
||||
badgeW={badgeW}
|
||||
badgeH={badgeH}
|
||||
scale={scale}
|
||||
selected={selected === type}
|
||||
onSelect={() => setSelected(type)}
|
||||
onChange={(patch) => updateField(type, patch)}
|
||||
@@ -223,13 +271,13 @@ function BadgeCustomizeTab() {
|
||||
/>
|
||||
<div
|
||||
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
|
||||
style={{ width: badgeW * SCALE, height: badgeH * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
|
||||
style={{ width: badgeW * scale, height: badgeH * scale, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel className="min-w-[320px] flex-1">
|
||||
<Panel className="min-w-0 flex-1 lg:min-w-[320px]">
|
||||
<PanelHeader>{t('badges.fields')}</PanelHeader>
|
||||
<div className="flex flex-col divide-y-2 divide-ink/10">
|
||||
{FIELD_TYPES.map((type) => (
|
||||
@@ -353,6 +401,7 @@ function FieldBox({
|
||||
field,
|
||||
badgeW,
|
||||
badgeH,
|
||||
scale,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
@@ -360,6 +409,7 @@ function FieldBox({
|
||||
field: FieldState;
|
||||
badgeW: number;
|
||||
badgeH: number;
|
||||
scale: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onChange: (patch: Partial<FieldState>) => void;
|
||||
@@ -381,8 +431,8 @@ function FieldBox({
|
||||
const onDragMove = (e: React.PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const d = dragRef.current;
|
||||
const dxMm = (e.clientX - d.pointerXPx) / SCALE;
|
||||
const dyMm = (e.clientY - d.pointerYPx) / SCALE;
|
||||
const dxMm = (e.clientX - d.pointerXPx) / scale;
|
||||
const dyMm = (e.clientY - d.pointerYPx) / scale;
|
||||
onChange({
|
||||
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
|
||||
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
|
||||
@@ -399,8 +449,8 @@ function FieldBox({
|
||||
const onResizeMove = (e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const d = resizeRef.current;
|
||||
const dwMm = (e.clientX - d.pointerXPx) / SCALE;
|
||||
const dhMm = (e.clientY - d.pointerYPx) / SCALE;
|
||||
const dwMm = (e.clientX - d.pointerXPx) / scale;
|
||||
const dhMm = (e.clientY - d.pointerYPx) / scale;
|
||||
onChange({
|
||||
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
|
||||
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
|
||||
@@ -413,20 +463,25 @@ function FieldBox({
|
||||
onPointerMove={onDragMove}
|
||||
onPointerUp={() => (dragRef.current = null)}
|
||||
className={cx(
|
||||
'absolute flex cursor-move items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
|
||||
'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',
|
||||
)}
|
||||
style={{
|
||||
left: field.x_mm * SCALE,
|
||||
top: field.y_mm * SCALE,
|
||||
width: field.width_mm * SCALE,
|
||||
height: field.height_mm * SCALE,
|
||||
left: field.x_mm * scale,
|
||||
top: field.y_mm * scale,
|
||||
width: field.width_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
|
||||
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
||||
style={{
|
||||
fontSize: field.font_size * (SCALE / 2.83), // pt -> px approx. à l'échelle du badge
|
||||
fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge
|
||||
fontWeight: field.bold ? 800 : 400,
|
||||
color: field.color,
|
||||
width: '100%',
|
||||
@@ -435,11 +490,12 @@ function FieldBox({
|
||||
>
|
||||
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
onPointerDown={startResize}
|
||||
onPointerMove={onResizeMove}
|
||||
onPointerUp={() => (resizeRef.current = null)}
|
||||
className="absolute bottom-0 right-0 h-[10px] w-[10px] cursor-nwse-resize border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
|
||||
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>
|
||||
);
|
||||
@@ -460,6 +516,7 @@ function FieldEditorRow({
|
||||
}) {
|
||||
return (
|
||||
<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]">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -471,8 +528,14 @@ function FieldEditorRow({
|
||||
{t(FIELD_LABEL_KEY[type])}
|
||||
</span>
|
||||
</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]">
|
||||
<label className="flex items-center gap-[5px]">
|
||||
<FieldLabel>{t('badges.field.size')}</FieldLabel>
|
||||
@@ -570,7 +633,7 @@ function BadgeGenerateTab() {
|
||||
const disabled = generate.isPending || (mode === 'pick' && picked.size === 0);
|
||||
|
||||
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]">
|
||||
<PanelHeader>{t('badges.generate.title')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
|
||||
@@ -117,9 +117,9 @@ export function FormBuilderPage() {
|
||||
};
|
||||
|
||||
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">
|
||||
<Panel className="max-w-[420px] flex-1">
|
||||
<Panel className="flex-1 lg:max-w-[420px]">
|
||||
<PanelHeader>{t('form.link.title')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||
@@ -172,7 +172,7 @@ export function FormBuilderPage() {
|
||||
</div>
|
||||
</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>
|
||||
<div className="flex flex-col divide-y-2 divide-ink/10">
|
||||
{FIELD_TYPES.map((type) => (
|
||||
@@ -398,14 +398,14 @@ function PaymentOptionsForm() {
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-[6px]">
|
||||
{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
|
||||
defaultValue={o.label}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
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
|
||||
type="number"
|
||||
@@ -449,19 +449,19 @@ function PaymentOptionsForm() {
|
||||
type="button"
|
||||
onClick={() => remove.mutate(o.id)}
|
||||
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>
|
||||
</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
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
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
|
||||
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')}
|
||||
</button>
|
||||
|
||||
@@ -88,7 +88,9 @@ export function GrillePage() {
|
||||
: matrix.data.positions.filter((p) => p.category === categoryFilter);
|
||||
|
||||
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) => {
|
||||
const width = columnWidthPx(day.slotLabels.length);
|
||||
return day.slotLabels.map(() => `${width}px`);
|
||||
@@ -134,8 +136,8 @@ export function GrillePage() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<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 flex-wrap items-center gap-[6px]">
|
||||
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
|
||||
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
|
||||
{t('filter.all')}
|
||||
@@ -163,7 +165,7 @@ export function GrillePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => runSolver.mutate()}
|
||||
@@ -199,7 +201,7 @@ export function GrillePage() {
|
||||
) : null}
|
||||
</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>
|
||||
<LegendItem swatch="bg-hatch-orange" glyph="▲">
|
||||
{t('grille.legend.critical')}
|
||||
@@ -220,7 +222,7 @@ export function GrillePage() {
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
||||
<div className="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
|
||||
style={{ gridColumn: '1 / span 1', gridRow: 1 }}
|
||||
@@ -278,7 +280,25 @@ export function GrillePage() {
|
||||
</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 ? (
|
||||
<AssignmentPanel
|
||||
key={selected.shift.shift_id}
|
||||
|
||||
@@ -191,7 +191,7 @@ function PaymentConfirmationView({ token }: { token: string }) {
|
||||
};
|
||||
|
||||
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>
|
||||
<PanelHeader>{t('publicForm.confirmation.title')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
@@ -273,7 +273,7 @@ function MecenatInfoStep({
|
||||
};
|
||||
|
||||
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} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.mecenat.title')}</PanelHeader>
|
||||
@@ -333,7 +333,7 @@ function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string
|
||||
};
|
||||
|
||||
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} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.retry.title')}</PanelHeader>
|
||||
@@ -406,7 +406,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
|
||||
if (volunteerId) {
|
||||
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} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
||||
@@ -437,7 +437,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
|
||||
if (!config.data.is_open) {
|
||||
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} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
|
||||
@@ -524,7 +524,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
||||
};
|
||||
|
||||
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} />
|
||||
<Panel>
|
||||
<PanelHeader>{t('publicForm.title')}</PanelHeader>
|
||||
|
||||
@@ -75,7 +75,7 @@ export function ConfigArtists() {
|
||||
</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) => (
|
||||
<DayColumn key={date} date={date} artists={byDate.get(date) ?? []} />
|
||||
))}
|
||||
|
||||
@@ -109,8 +109,9 @@ export function ConfigGrid() {
|
||||
].sort();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
<div className="max-h-[calc(100vh-118px)] w-[250px] flex-none overflow-auto border-r-[3px] border-ink">
|
||||
<div className="flex min-h-0 flex-1 flex-col items-stretch md:flex-row">
|
||||
{/* 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">
|
||||
{t('config.grid.positionsHeader', { count: totals.count })}
|
||||
<HelpTip text={t('config.grid.note')} side="bottom" />
|
||||
@@ -148,7 +149,7 @@ export function ConfigGrid() {
|
||||
{createPosition.isError ? <ErrorNote error={createPosition.error} /> : null}
|
||||
</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
|
||||
key={position.id}
|
||||
position={position}
|
||||
@@ -158,7 +159,7 @@ export function ConfigGrid() {
|
||||
{positionShifts.isLoading ? <Loading /> : 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) => (
|
||||
<DayCard
|
||||
key={day}
|
||||
@@ -197,15 +198,15 @@ function PositionHeader({
|
||||
|
||||
return (
|
||||
<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
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={commitName}
|
||||
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
|
||||
value={position.category}
|
||||
onChange={(e) =>
|
||||
@@ -250,7 +251,7 @@ function PositionHeader({
|
||||
{updatePosition.isError ? <ErrorNote error={updatePosition.error} /> : null}
|
||||
{generate.isError ? <ErrorNote error={generate.error} /> : null}
|
||||
</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.ideal} h`} label={t('config.grid.idealTotal')} accent />
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@ export function ConfigMail() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
<PanelHeader>
|
||||
{t('config.mail.templateTitle')} <HelpTip text={t('config.mail.smtpBanner')} side="bottom" />
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ConfigRules() {
|
||||
const shifts = useShifts();
|
||||
|
||||
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">
|
||||
<PanelHeader>{t('config.rules.editionTitle')}</PanelHeader>
|
||||
<div className="flex flex-col gap-2 p-[13px]">
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
useExportMecenatReceipts,
|
||||
useExportVolunteers,
|
||||
usePositions,
|
||||
useRemoveVolunteerPhoto,
|
||||
useSetVolunteerPhoto,
|
||||
useUpdateAccount,
|
||||
useUpdatePosition,
|
||||
useUpdateVolunteer,
|
||||
@@ -167,11 +169,87 @@ const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status
|
||||
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`) —
|
||||
* 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),
|
||||
* 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).
|
||||
*/
|
||||
function PublicFormAnswers({
|
||||
@@ -181,12 +259,8 @@ function PublicFormAnswers({
|
||||
volunteer: VolunteerRead;
|
||||
patch: (body: VolunteerUpdate) => void;
|
||||
}) {
|
||||
const photoUrl = useVolunteerPhotoUrl(volunteer.id, volunteer.has_photo);
|
||||
|
||||
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;
|
||||
|
||||
return (
|
||||
@@ -195,14 +269,6 @@ function PublicFormAnswers({
|
||||
{t('config.vol.publicForm.title')} <HelpTip text={t('config.vol.publicForm.hint')} />
|
||||
</FieldLabel>
|
||||
|
||||
{photoUrl ? (
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt=""
|
||||
className="h-[90px] w-[90px] flex-none border-2 border-ink object-cover"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{volunteer.companion_wish_note ? (
|
||||
<label className="block">
|
||||
<FieldLabel>{t('form.field.companionWish')}</FieldLabel>
|
||||
@@ -571,8 +637,10 @@ function issueParams(issue: ImportIssue): Record<string, string | number> {
|
||||
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 =
|
||||
'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
|
||||
* d'un paiement en ligne non abouti. */
|
||||
@@ -677,7 +745,7 @@ export function ConfigVolunteers() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
|
||||
<div className="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="flex flex-1 flex-col justify-center gap-[10px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
|
||||
<div>
|
||||
@@ -757,13 +825,15 @@ export function ConfigVolunteers() {
|
||||
) : (
|
||||
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div
|
||||
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
|
||||
style={{ gridTemplateColumns: LIST_COLUMNS }}
|
||||
className={cx(
|
||||
'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.colPhone')}</span>
|
||||
<span>{t('config.vol.colEmail')}</span>
|
||||
<span>{t('config.vol.colWishes')}</span>
|
||||
<span className="hidden md:block">{t('config.vol.colPhone')}</span>
|
||||
<span className="hidden md:block">{t('config.vol.colEmail')}</span>
|
||||
<span className="hidden md:block">{t('config.vol.colWishes')}</span>
|
||||
<span>{t('config.vol.colFee')}</span>
|
||||
</div>
|
||||
{rows.map((volunteer) => {
|
||||
@@ -774,10 +844,10 @@ export function ConfigVolunteers() {
|
||||
type="button"
|
||||
onClick={() => setSelectedId(volunteer.id)}
|
||||
className={cx(
|
||||
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
|
||||
'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',
|
||||
)}
|
||||
style={{ gridTemplateColumns: LIST_COLUMNS }}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-[7px]">
|
||||
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
|
||||
@@ -788,13 +858,13 @@ export function ConfigVolunteers() {
|
||||
{volunteer.first_name} {volunteer.last_name}
|
||||
</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 ?? '—'}
|
||||
</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 ?? '—'}
|
||||
</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', {
|
||||
count:
|
||||
volunteer.artist_ids.length +
|
||||
@@ -815,8 +885,17 @@ export function ConfigVolunteers() {
|
||||
)}
|
||||
</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 ? (
|
||||
<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="flex items-center justify-between">
|
||||
<div className="flex items-center gap-[6px] font-mono text-[10px] text-yellow">
|
||||
@@ -826,7 +905,8 @@ export function ConfigVolunteers() {
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
@@ -870,6 +950,17 @@ export function ConfigVolunteers() {
|
||||
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
|
||||
/>
|
||||
</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} />
|
||||
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
|
||||
|
||||
@@ -208,7 +208,7 @@ export function AssignmentPanel({
|
||||
<button
|
||||
type="button"
|
||||
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')}
|
||||
>
|
||||
✕
|
||||
|
||||
Reference in New Issue
Block a user