From ced0855d472ff87f918d3cf2ea634a79126b52a2 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Tue, 27 Oct 2026 19:20:42 +0100 Subject: [PATCH] =?UTF-8?q?Refonte=20du=20cr=C3=A9ateur=20de=20badges=20et?= =?UTF-8?q?=20nombre=20de=20badges=20par=20b=C3=A9n=C3=A9vole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Créateur de badges refait d'après la maquette : champs sur le recto et le verso (BadgeField.face, verso placé en miroir dans le PDF pour le recto-verso), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération. Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut, migration 0027) : champ sur la fiche, autant d'exemplaires sur les planches, total affiché à la génération, colonne dans l'export Excel. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 60 +- backend/app/api/badges.py | 96 +- backend/app/api/schemas.py | 17 +- backend/app/badges/pdf.py | 312 +++-- backend/app/exporting/xlsx.py | 2 + backend/app/i18n/locales/fr.json | 3 +- .../versions/0026_badge_editor_v2.py | 62 + .../versions/0027_volunteer_badge_count.py | 35 + backend/app/models/__init__.py | 4 + backend/app/models/domain.py | 48 +- backend/app/models/enums.py | 18 +- backend/tests/test_badges_api.py | 151 +++ backend/tests/test_badges_fields.py | 18 + backend/tests/test_badges_pdf.py | 167 +++ frontend/scripts/smoke.tsx | 60 +- frontend/src/api/mock/dataset.ts | 1 + frontend/src/api/mock/mock-client.ts | 27 +- frontend/src/api/schema.ts | 18 + frontend/src/i18n/locales/fr.json | 112 +- frontend/src/pages/BadgesPage.tsx | 701 +---------- frontend/src/pages/badges/BadgeCanvas.tsx | 460 ++++++++ .../src/pages/badges/BadgeCustomizeTab.tsx | 1049 +++++++++++++++++ frontend/src/pages/badges/BadgeInspector.tsx | 413 +++++++ frontend/src/pages/badges/helveticaMetrics.ts | 32 + frontend/src/pages/badges/issues.ts | 127 ++ frontend/src/pages/badges/layout.ts | 238 ++++ frontend/src/pages/badges/model.ts | 268 +++++ .../src/pages/config/ConfigVolunteers.tsx | 42 + 28 files changed, 3738 insertions(+), 803 deletions(-) create mode 100644 backend/app/migrations/versions/0026_badge_editor_v2.py create mode 100644 backend/app/migrations/versions/0027_volunteer_badge_count.py create mode 100644 frontend/src/pages/badges/BadgeCanvas.tsx create mode 100644 frontend/src/pages/badges/BadgeCustomizeTab.tsx create mode 100644 frontend/src/pages/badges/BadgeInspector.tsx create mode 100644 frontend/src/pages/badges/helveticaMetrics.ts create mode 100644 frontend/src/pages/badges/issues.ts create mode 100644 frontend/src/pages/badges/layout.ts create mode 100644 frontend/src/pages/badges/model.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1f66ddd..fb0a673 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,10 @@ les tables sauf les tables d'association. paiement SumUp plus bas). `badge_text` : texte du badge propre à ce bénévole (champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la fiche (« Texte du badge ») — jamais par le formulaire public — et qui - remplace pour lui le texte commun de l'éditeur. + remplace pour lui le texte commun de l'éditeur. `badge_count` : nombre + d'exemplaires de son badge imprimés sur les planches (1 par défaut, 1 à + 100 — `CHECK >= 1` en base, borne haute à la saisie contre une faute de + frappe), « Nombre de badges » sur la fiche. - **`Artist`** : programmation de l'édition (`name`, `performance_date`, `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le lendemain). Unique (`edition_id`, `name`, `performance_date`). La @@ -162,15 +165,31 @@ les tables sauf les tables d'association. et la génération retombant alors sur le format CR80 par défaut (85,6 × 54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans déformation, `preserveAspectRatio=True`) à ce même format. -- **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈ - `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) - depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`), - qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel - du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`). +- **`BadgeField`** : un champ positionné sur le **recto ou le verso** + (`face` ∈ `FRONT` / `BACK` ; `field_type` ∈ `FULL_NAME` / `POSITIONS` / + `CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`, au plus un par face et par + édition — unique (`edition_id`, `face`, `field_type`), 422 + `badge.duplicate_field_type` sinon ; `PUT .../badges/template/fields` + remplace tout, les deux faces dans la même liste), 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`, `bg_color` (fond plein de la + zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`), + `auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que + tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne + sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`). + `FREE_TEXT` et `ROLE` sont centrés verticalement dans leur cadre (règle + dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la + ligne. Édité visuellement depuis l'écran « Badges » + (`frontend/src/pages/badges/` — refonte d'après `design/Créateur de + badges.dc.html` : liste des champs recto/verso, aperçu des deux faces avec + les données d'un vrai bénévole, inspecteur, formats prédéfinis via + `PUT .../template/size`, annuler, flèches, zoom, repères de découpe + 1,5 mm, contrôle de tous les badges avant génération — `issues.ts`). + L'aperçu reprend la mise en page du PDF à l'identique + (`badges/layout.ts`, avec les métriques Helvetica de ReportLab recopiées + dans `helveticaMetrics.ts`, pas une mesure du navigateur) : toute + règle de mise en page changée dans `app/badges/pdf.py` doit l'être aussi + dans `layout.ts`, sinon le contrôle avant génération ment. `POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`, dessiné par `app/badges/pdf.py:_draw_table`) des créneaux affectés, **regroupés par jour de festival** (§ 3 — une soirée qui passe minuit @@ -188,23 +207,30 @@ les tables sauf les tables d'association. 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. Le texte d'un champ passe à la ligne pour tenir dans la largeur + photo illisible, les **initiales** du bénévole sont imprimées à la + place (fond `#E7E0CE`, même forme) plutôt que de faire échouer la + planche ; `OVAL` découpe la photo en ellipse. Le texte d'un champ passe à la ligne pour tenir dans la largeur de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au milieu d'un mot trop long seul ; les `\n` restent des retours à la ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme l'aperçu de l'éditeur, qui fait de même côté navigateur. Avant, chaque ligne s'imprimait d'un bloc et débordait du cadre. L'éditeur ramène tout champ à l'intérieur du badge - (`BadgesPage.tsx:fitToBadge`) et met le champ sélectionné au premier + (`badges/model.ts:fitToBadge`) et met le champ sélectionné au premier plan : les positions par défaut visent un CR80 paysage, et sur un badge plus étroit un champ pouvait tomber hors du badge, avec des bornes de glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo impossible à déplacer ni redimensionner). `POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` — - absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est + absent = tous ; chaque bénévole y figure `badge_count` fois, à la suite) + génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est pour rien : juste ReportLab) : pages recto personnalisées suivies, planche - par planche, de la page de verso correspondante (même image répétée à - chaque emplacement) — impression recto-verso classique. + par planche, de la page de verso correspondante (image de verso puis + champs `BACK` de chaque bénévole) — impression recto-verso classique + (retournement bord long) : sur la page de verso, chaque badge est placé + **en miroir horizontal** (colonne `cols-1-col`, `_cell_origin(mirrored= + True)`) pour tomber derrière son recto. Indispensable depuis que le verso + est personnalisé ; avant, un verso identique pour tous masquait le + décalage. - **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) : aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/ `Volunteer` existants. Complément des badges (un badge identifie un @@ -953,6 +979,8 @@ Aucune dépendance locale requise hors Docker. | 48 | Badges : texte libre saisi dans l'éditeur (`BadgeField.text`, migration `0025`), commun à tous les badges, le « Texte du badge » de la fiche le remplaçant pour un bénévole ; champs ramenés dans le badge et champ sélectionné au premier plan (la photo restait bloquée hors d'un badge plus étroit que le CR80) | à valider | | 49 | Badges : le texte des champs (nom, postes et horaires…) passe à la ligne pour tenir dans la largeur de son cadre au lieu de déborder (`app/badges/pdf.py:wrap_text`) | à valider | | 50 | Badges : les créneaux affectés s'affichent en tableau — le jour de festival en en-tête, puis une colonne horaire et une colonne poste (le référent n'est plus imprimé sur le badge), filets à la couleur du champ, cellules repliées dans leur colonne ; même tableau dans l'aperçu de l'éditeur | à valider | +| 51 | Refonte du créateur de badges d'après la maquette (`design/`) : champs sur le recto **et le verso** (`BadgeField.face`, verso en miroir dans le PDF), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration `0026`) ; éditeur en 3 colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler/flèches/zoom/repères, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération | à valider | +| 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne dans l'export Excel | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/badges.py b/backend/app/api/badges.py index 6ea548a..177104b 100644 --- a/backend/app/api/badges.py +++ b/backend/app/api/badges.py @@ -1,15 +1,16 @@ """Planches de badges bénévoles : configuration du recto/verso (COORDINATION) 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, photo, texte libre de la fiche) — voir `app/badges/pdf.py` -pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`, +Chaque face (recto, verso) combine une image de fond (la même pour tous les +bénévoles) et des champs positionnés (nom, poste(s) affectés, 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 datetime as dt import io +import re import uuid from collections import defaultdict @@ -36,12 +37,14 @@ from app.badges.pdf import ( BadgeTableSection, generate_badge_sheets_pdf, image_size_mm, + initials, ) from app.db import get_session from app.i18n import t from app.models import ( Account, Assignment, + BadgeFace, BadgeField, BadgeFieldType, BadgeTemplate, @@ -58,6 +61,9 @@ router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"]) _MAX_IMAGE_BYTES = 8 * 1024 * 1024 _ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"} +_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}") +# Champs centrés verticalement dans leur cadre (les autres partent du haut). +_MIDDLE_ALIGNED = {BadgeFieldType.FREE_TEXT, BadgeFieldType.ROLE} # --------------------------------------------------------------------------- # @@ -80,7 +86,7 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]: session.exec( select(BadgeField) .where(BadgeField.edition_id == edition_id) - .order_by(BadgeField.field_type) + .order_by(BadgeField.face, BadgeField.field_type) ) ) @@ -206,9 +212,11 @@ def set_fields( _guard: Account = Depends(require_coordinator), session: Session = Depends(get_session), ) -> list[BadgeField]: - seen: set[BadgeFieldType] = set() + # Au plus un champ de chaque type par face ; le même sur les deux faces + # est permis. + seen: set[tuple[BadgeFace, BadgeFieldType]] = set() for f in payload.fields: - if f.field_type in seen: + if (f.face, f.field_type) in seen: raise HTTPException( status_code=422, detail={ @@ -216,7 +224,12 @@ def set_fields( "params": {"field_type": f.field_type}, }, ) - seen.add(f.field_type) + seen.add((f.face, f.field_type)) + if f.bg_color is not None and not _HEX_COLOR.fullmatch(f.bg_color): + raise HTTPException( + status_code=422, + detail={"code": "badge.invalid_color", "params": {"color": f.bg_color}}, + ) session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id)) for f in payload.fields: @@ -297,7 +310,11 @@ def _field_text( """Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau (`_positions_table`).""" if field.field_type == BadgeFieldType.FULL_NAME: - return f"{volunteer.first_name} {volunteer.last_name}" + # Prénom puis nom, chacun sur sa ligne. + return f"{volunteer.first_name}\n{volunteer.last_name}" + if field.field_type == BadgeFieldType.PHOTO: + # Imprimées à la place d'une photo absente ou illisible. + return initials(volunteer.first_name, volunteer.last_name) if field.field_type == BadgeFieldType.FREE_TEXT: # Texte commun saisi dans l'éditeur, remplacé pour un bénévole par # le « Texte du badge » de sa fiche s'il en a un. @@ -359,35 +376,42 @@ def generate_badges( if account.volunteer_id is not None } - badges = [ - BadgeSpec( - fields=tuple( - BadgeFieldSpec( - text=_field_text( - field, volunteer, assignments_by_volunteer, lead_volunteer_ids - ), - x_mm=field.x_mm, - y_mm=field.y_mm, - width_mm=field.width_mm, - height_mm=field.height_mm, - font_size=field.font_size, - 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, - table=_positions_table( - assignments_by_volunteer.get(volunteer.id, []), boundary - ) - if field.field_type == BadgeFieldType.POSITIONS - else None, - ) - for field in fields - ) + def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec: + return BadgeFieldSpec( + text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids), + x_mm=field.x_mm, + y_mm=field.y_mm, + width_mm=field.width_mm, + height_mm=field.height_mm, + font_size=field.font_size, + 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, + table=_positions_table(assignments_by_volunteer.get(volunteer.id, []), boundary) + if field.field_type == BadgeFieldType.POSITIONS + else None, + bg_color=field.bg_color, + auto_fit=field.auto_fit, + valign="MIDDLE" if field.field_type in _MIDDLE_ALIGNED else "TOP", + photo_shape=field.photo_shape.value, ) + + front_fields = [f for f in fields if f.face == BadgeFace.FRONT] + back_fields = [f for f in fields if f.face == BadgeFace.BACK] + # Un badge par exemplaire demandé sur la fiche (`Volunteer.badge_count`), + # à la suite les uns des autres. + badges = [ + badge for volunteer in volunteers + for badge in [ + BadgeSpec( + fields=tuple(spec(field, volunteer) for field in front_fields), + back_fields=tuple(spec(field, volunteer) for field in back_fields), + ) + ] + * volunteer.badge_count ] pdf_bytes = generate_badge_sheets_pdf( diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 440a571..a26f564 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -8,7 +8,9 @@ from pydantic import BaseModel, ConfigDict, Field from app.models import ( AssignmentSource, BadgeAlign, + BadgeFace, BadgeFieldType, + BadgePhotoShape, FeePaymentStatus, FormFieldType, PositionCategory, @@ -76,6 +78,9 @@ class VolunteerUpdate(ApiModel): companion_wish_note: str | None = None # Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination. badge_text: str | None = None + # Exemplaires du badge sur les planches (1 à 100 ; garde-fou contre une + # faute de frappe qui imprimerait des centaines de pages). + badge_count: int | None = Field(default=None, ge=1, le=100) self_declared_available: bool | None = None willing_to_pay_fee: bool | None = None @@ -94,6 +99,7 @@ class VolunteerRead(ApiModel): has_photo: bool companion_wish_note: str | None badge_text: str | None + badge_count: int self_declared_available: bool willing_to_pay_fee: bool # Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) — @@ -346,6 +352,7 @@ class MatrixResponse(ApiModel): # Badges (planches PDF) # # --------------------------------------------------------------------------- # class BadgeFieldIn(ApiModel): + face: BadgeFace = BadgeFace.FRONT field_type: BadgeFieldType x_mm: float y_mm: float @@ -357,11 +364,17 @@ class BadgeFieldIn(ApiModel): color: str = "#000000" # `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon. text: str | None = None + # Fond de la zone (`#rrggbb`), `None` = transparent — voir `BadgeField`. + bg_color: str | None = None + auto_fit: bool = False + # `PHOTO` seulement, ignoré sinon. + photo_shape: BadgePhotoShape = BadgePhotoShape.RECT class BadgeFieldsSet(ApiModel): - """Remplace l'intégralité des champs du recto — au plus un par - `field_type` (décocher un champ dans l'éditeur retire sa ligne).""" + """Remplace l'intégralité des champs du recto et du verso — au plus un + par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa + ligne).""" fields: list[BadgeFieldIn] = Field(default_factory=list) diff --git a/backend/app/badges/pdf.py b/backend/app/badges/pdf.py index 4acc54a..65bf6f3 100644 --- a/backend/app/badges/pdf.py +++ b/backend/app/badges/pdf.py @@ -8,11 +8,12 @@ Format du badge : dérivé de l'image de fond du recto (pas de format fixe imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée, `DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la taille standard des badges/cartes d'événement) servent de repère à -l'éditeur. Planche : grille sur page A4 portrait. Le verso est identique -pour tous les bénévoles : la planche imprime toutes les pages de recto -(personnalisées) puis, pour chacune, la page de verso correspondante (même -disposition, même image répétée à chaque emplacement rempli) — impression -recto-verso classique, page de verso n directement après la page de recto n. +l'éditeur. Planche : grille sur page A4 portrait. Chaque page de recto est +suivie de sa page de verso (même image de fond à chaque emplacement rempli, +plus les champs du verso de chaque bénévole) — impression recto-verso +classique, retournement sur le bord long : sur la page de verso, chaque +badge est placé en miroir horizontal (colonne `cols - 1 - col`, même rangée) +pour tomber exactement derrière son recto — voir `_cell_origin`. """ import io @@ -36,6 +37,13 @@ _LINE_HEIGHT_FACTOR = 1.25 _CELL_PAD_X_MM = 0.8 _CELL_PAD_Y_MM = 0.5 _TABLE_LINE_WIDTH_PT = 0.4 +# Réduction automatique de la taille du texte (`auto_fit`) : pas et plancher. +_AUTO_FIT_STEP_PT = 0.5 +_AUTO_FIT_MIN_PT = 4.0 +# Photo absente ou illisible : initiales sur ce fond, à cette taille (part de +# la largeur du cadre). +_INITIALS_BG = "#E7E0CE" +_INITIALS_SIZE_RATIO = 0.28 # Résolution supposée d'une image de fond importée, pour convertir ses pixels # en millimètres réels (donc en taille de badge) — 300 dpi est la résolution @@ -73,14 +81,21 @@ class BadgeTable: @dataclass(frozen=True) 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). + possibles — ex. prénom puis nom), positionné depuis 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é. + bruts tels que stockés) plutôt qu'un texte, découpée selon + `photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), les + initiales du bénévole (`text`) sont imprimées à la place, sur un fond + neutre de la même forme. - `table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.""" + `table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`. + + `bg_color` : fond plein de la zone du champ (`None` = transparent ; + ignoré pour la photo). `auto_fit` : réduit la taille du texte jusqu'à ce + que tout tienne dans le cadre (sinon, les lignes en trop ne sont pas + imprimées). `valign` : `TOP` ou `MIDDLE` (centré verticalement).""" text: str x_mm: float @@ -94,13 +109,19 @@ class BadgeFieldSpec: is_photo: bool = False image: bytes | None = None table: BadgeTable | None = None + bg_color: str | None = None + auto_fit: bool = False + valign: str = "TOP" # "TOP" | "MIDDLE" + photo_shape: str = "RECT" # "RECT" | "OVAL" @dataclass(frozen=True) class BadgeSpec: - """Un badge = un bénévole, avec ses champs déjà résolus.""" + """Un badge = un bénévole, avec ses champs déjà résolus : `fields` pour + le recto, `back_fields` pour le verso.""" fields: tuple[BadgeFieldSpec, ...] + back_fields: tuple[BadgeFieldSpec, ...] = () def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]: @@ -112,11 +133,22 @@ def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int] def _cell_origin( - index: int, cols: int, badge_width_mm: float, badge_height_mm: float + index: int, + cols: int, + badge_width_mm: float, + badge_height_mm: float, + mirrored: bool = False, ) -> tuple[float, float]: """Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page, - en mm depuis le coin bas-gauche de la page.""" + en mm depuis le coin bas-gauche de la page. + + `mirrored` : page de verso. Une feuille retournée sur son bord long + inverse gauche et droite : le verso du badge de la colonne `col` doit + être imprimé en colonne `cols - 1 - col` (même rangée) pour tomber + derrière son recto — y compris sur une dernière planche incomplète.""" col, row = index % cols, index // cols + if mirrored: + col = cols - 1 - col x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM) y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM return x0, y0 @@ -169,23 +201,73 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None: return out.getvalue() +def _frame_rect( + f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float +) -> tuple[float, float, float, float]: + """Cadre du champ en points, repère PDF : (gauche, bas, largeur, + hauteur). `f.y_mm` est le haut du cadre depuis le HAUT du badge ; + reportlab dessine depuis le BAS de la page.""" + return ( + (x0_mm + f.x_mm) * mm, + (y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm, + f.width_mm * mm, + f.height_mm * mm, + ) + + +def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: float): + path = c.beginPath() + if shape == "OVAL": + path.ellipse(x, y, w, h) + else: + path.rect(x, y, w, h) + return path + + +def initials(first_name: str, last_name: str) -> str: + """Initiales imprimées à la place d'une photo absente : première lettre + du prénom et du nom, en majuscules.""" + return "".join(part.strip()[:1] for part in (first_name, last_name)).upper() + + +def _draw_initials(c: canvas.Canvas, f: BadgeFieldSpec, x: float, y: float, w: float, h: float): + c.setFillColor(HexColor(_INITIALS_BG)) + c.drawPath(_shape_path(c, f.photo_shape, x, y, w, h), stroke=0, fill=1) + if not f.text: + return + size = max(_AUTO_FIT_MIN_PT, w * _INITIALS_SIZE_RATIO) + c.setFont("Helvetica-Bold", size) + c.setFillColor(HexColor(f.color)) + # Ligne de base décalée d'environ un tiers de la taille : centre + # visuel des majuscules (hauteur de capitale ≈ 0,7 × la taille). + c.drawCentredString(x + w / 2, y + h / 2 - size * 0.35, f.text) + + 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) + x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm) + photo = _fit_photo(f.image, f.width_mm, f.height_mm) if f.image else None + c.saveState() if photo is None: + _draw_initials(c, f, x, y, w, h) + else: + if f.photo_shape == "OVAL": + # Découpe en ellipse inscrite dans le cadre. + c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0) + c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h) + c.restoreState() + + +def _draw_zone_background( + c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float +) -> None: + if not f.bg_color: return - c.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, - ) + c.saveState() + c.setFillColor(HexColor(f.bg_color)) + c.rect(*_frame_rect(f, x0_mm, y0_mm, badge_height_mm), stroke=0, fill=1) + c.restoreState() def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]: @@ -242,6 +324,45 @@ def _column_widths(natural: list[float], available: float) -> list[float]: return widths +_TableLine = tuple[list[list[str]], str, bool, float] + + +def _table_layout( + table: BadgeTable, width_pt: float, font_size: float +) -> tuple[list[float], list[_TableLine]]: + """Largeurs des colonnes et lignes du tableau à `font_size` : pour + chaque ligne, (texte replié de chaque cellule, police, en-tête de + section ?, hauteur en points).""" + regular, bold = "Helvetica", "Helvetica-Bold" + pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm + line_height = font_size * _LINE_HEIGHT_FACTOR + body = [row for section in table.sections for row in section.rows] + columns = max(len(row) for row in body) if body else 1 + natural = [ + max(stringWidth(row[col], regular, font_size) for row in body) + 2 * pad_x + for col in range(columns) + ] if body else [0.0] + widths = _column_widths(natural, width_pt) + total_width = sum(widths) + + # Un en-tête n'a qu'une cellule, sur toute la largeur. + cells_to_draw = [] + for section in table.sections: + cells_to_draw.append(((section.title,), bold, True)) + cells_to_draw.extend((row, regular, False) for row in section.rows) + + lines: list[_TableLine] = [] + for cells, font, is_title in cells_to_draw: + cell_widths = [total_width] if is_title else widths + wrapped = [ + wrap_text(text, font, font_size, max(1.0, cell_widths[col] - 2 * pad_x)) + for col, text in enumerate(cells) + ] + row_height = max(len(w) for w in wrapped) * line_height + 2 * pad_y + lines.append((wrapped, font, is_title, row_height)) + return widths, lines + + def _draw_table( c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float ) -> None: @@ -250,52 +371,43 @@ def _draw_table( filets à la couleur du champ, texte de chaque cellule replié dans sa colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ - texte.""" + texte — sauf avec `auto_fit`, qui réduit la taille du texte jusqu'à ce + que toutes les lignes tiennent (plancher `_AUTO_FIT_MIN_PT`).""" table = f.table if table is None or not table.sections: return - regular, bold = "Helvetica", "Helvetica-Bold" pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm - line_height = f.font_size * _LINE_HEIGHT_FACTOR - body = [row for section in table.sections for row in section.rows] - columns = max(len(row) for row in body) if body else 1 - natural = [ - max(stringWidth(row[col], regular, f.font_size) for row in body) + 2 * pad_x - for col in range(columns) - ] if body else [0.0] - widths = _column_widths(natural, f.width_mm * mm) + width_pt, height_pt = f.width_mm * mm, f.height_mm * mm + font_size = f.font_size + widths, lines_to_draw = _table_layout(table, width_pt, font_size) + if f.auto_fit: + while ( + sum(line[3] for line in lines_to_draw) > height_pt + 0.01 + and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT + ): + font_size -= _AUTO_FIT_STEP_PT + widths, lines_to_draw = _table_layout(table, width_pt, font_size) + line_height = font_size * _LINE_HEIGHT_FACTOR total_width = sum(widths) - # (cellules, police, en-tête de section ?) — un en-tête n'a qu'une - # cellule, sur toute la largeur. - lines_to_draw = [] - for section in table.sections: - lines_to_draw.append(((section.title,), bold, True)) - lines_to_draw.extend((row, regular, False) for row in section.rows) - left = (x0_mm + f.x_mm) * mm top = (y0_mm + badge_height_mm - f.y_mm) * mm - bottom_limit = top - f.height_mm * mm + bottom_limit = top - height_pt color = HexColor(f.color) c.setFillColor(color) c.setStrokeColor(color) c.setLineWidth(_TABLE_LINE_WIDTH_PT) y = top - for cells, font, is_title in lines_to_draw: + for wrapped, font, is_title, row_height in lines_to_draw: cell_widths = [total_width] if is_title else widths - wrapped = [ - wrap_text(text, font, f.font_size, max(1.0, cell_widths[col] - 2 * pad_x)) - for col, text in enumerate(cells) - ] - row_height = max(len(lines) for lines in wrapped) * line_height + 2 * pad_y if y - row_height < bottom_limit - 0.01: break # la ligne déborderait du cadre du champ - c.setFont(font, f.font_size) + c.setFont(font, font_size) x = left for col, lines in enumerate(wrapped): for i, line in enumerate(lines): - baseline = y - pad_y - f.font_size * 0.85 - i * line_height + baseline = y - pad_y - font_size * 0.85 - i * line_height c.drawString(x + pad_x, baseline, line) x += cell_widths[col] if not is_title: @@ -312,6 +424,71 @@ def _draw_table( c.rect(left, y, total_width, top - y, stroke=1, fill=0) +def _lines_that_fit(font_size: float, height_mm: float) -> int: + """Nombre de lignes de texte qui tiennent dans `height_mm` (première + ligne de base à 0,85 × la taille sous le haut du cadre, puis un + interligne par ligne).""" + font_size_mm = font_size / mm + first = font_size_mm * 0.85 + if first > height_mm + 1e-9: + return 0 + return 1 + int((height_mm - first) / (font_size_mm * _LINE_HEIGHT_FACTOR) + 1e-9) + + +def fit_text( + text: str, font_name: str, font_size: float, width_mm: float, height_mm: float, + auto_fit: bool, +) -> tuple[float, list[str]]: + """Taille retenue et lignes à imprimer d'un champ texte. Sans + `auto_fit` : la taille du champ, lignes en trop retirées. Avec : taille + réduite par pas de `_AUTO_FIT_STEP_PT` (plancher `_AUTO_FIT_MIN_PT`) + jusqu'à ce que toutes les lignes repliées tiennent dans le cadre.""" + lines = wrap_text(text, font_name, font_size, width_mm * mm) + if auto_fit: + while ( + len(lines) > _lines_that_fit(font_size, height_mm) + and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT + ): + font_size -= _AUTO_FIT_STEP_PT + lines = wrap_text(text, font_name, font_size, width_mm * mm) + return font_size, lines[: _lines_that_fit(font_size, height_mm)] + + +def _draw_text( + c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float +) -> None: + font_name = "Helvetica-Bold" if f.bold else "Helvetica" + font_size, lines = fit_text( + f.text, font_name, f.font_size, f.width_mm, f.height_mm, f.auto_fit + ) + if not lines: + return + c.setFont(font_name, font_size) + c.setFillColor(HexColor(f.color)) + left_x = x0_mm + f.x_mm + # `font_size` est en points (unité reportlab) ; le reste du calcul + # travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant + # ici le facteur de conversion pt/mm de reportlab). + font_size_mm = font_size / mm + line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR + # `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab + # dessine depuis le BAS de la page. + top_y = y0_mm + badge_height_mm - f.y_mm + if f.valign == "MIDDLE": + # Hauteur du bloc : interlignes entre les lignes + une hauteur de + # caractère pour la dernière. + block = (len(lines) - 1) * line_height_mm + font_size_mm + top_y -= max(0.0, (f.height_mm - block) / 2) + for i, line in enumerate(lines): + baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm + if f.align == "CENTER": + c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line) + elif f.align == "RIGHT": + c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line) + else: + c.drawString(left_x * mm, baseline_y * mm, line) + + def _draw_fields( c: canvas.Canvas, fields: tuple[BadgeFieldSpec, ...], @@ -323,33 +500,11 @@ def _draw_fields( if f.is_photo: _draw_photo(c, f, x0_mm, y0_mm, badge_height_mm) continue + _draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm) if f.table is not None: _draw_table(c, f, x0_mm, y0_mm, badge_height_mm) - continue - font_name = "Helvetica-Bold" if f.bold else "Helvetica" - c.setFont(font_name, f.font_size) - c.setFillColor(HexColor(f.color)) - left_x = x0_mm + f.x_mm - # `f.font_size` est en points (unité reportlab) ; le reste du calcul - # travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant - # ici le facteur de conversion pt/mm de reportlab). - font_size_mm = f.font_size / mm - line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR - # `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab - # dessine depuis le BAS de la page. - top_y = y0_mm + badge_height_mm - f.y_mm - bottom_limit = top_y - f.height_mm - lines = wrap_text(f.text, font_name, f.font_size, f.width_mm * mm) - for i, line in enumerate(lines): - baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm - if baseline_y < bottom_limit: - break # la ligne déborderait de la boîte du champ - if f.align == "CENTER": - c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line) - elif f.align == "RIGHT": - c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line) - else: - c.drawString(left_x * mm, baseline_y * mm, line) + else: + _draw_text(c, f, x0_mm, y0_mm, badge_height_mm) def generate_badge_sheets_pdf( @@ -374,9 +529,10 @@ def generate_badge_sheets_pdf( _draw_fields(c, badge.fields, x0, y0, badge_height_mm) c.showPage() - for i in range(len(page_badges)): - x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm) + for i, badge in enumerate(page_badges): + x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True) _draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm) + _draw_fields(c, badge.back_fields, x0, y0, badge_height_mm) c.showPage() c.save() return buffer.getvalue() diff --git a/backend/app/exporting/xlsx.py b/backend/app/exporting/xlsx.py index b74731e..451a161 100644 --- a/backend/app/exporting/xlsx.py +++ b/backend/app/exporting/xlsx.py @@ -37,6 +37,7 @@ _HEADER = [ "Mécénat — pays", "Commentaire", "Texte du badge", + "Nombre de badges", "Inscrit le", ] @@ -125,6 +126,7 @@ def generate_volunteers_xlsx( _safe_text(v.mecenat_country or ""), _safe_text(v.comment or ""), _safe_text(v.badge_text or ""), + v.badge_count, _fmt_dt(v.created_at), ] ) diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 104d00e..d0b0a6c 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -61,7 +61,8 @@ "badge.image_too_large": "L'image est trop volumineuse.", "badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.", "badge.invalid_size": "La taille du badge doit être strictement positive.", - "badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.", + "badge.invalid_color": "La couleur « {color} » n'est pas valide (format attendu : #rrggbb).", + "badge.no_fields": "Aucun champ n'est configuré sur le badge.", "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.", "badge.role.referent": "Référent", "badge.role.volunteer": "Bénévole", diff --git a/backend/app/migrations/versions/0026_badge_editor_v2.py b/backend/app/migrations/versions/0026_badge_editor_v2.py new file mode 100644 index 0000000..5b215a6 --- /dev/null +++ b/backend/app/migrations/versions/0026_badge_editor_v2.py @@ -0,0 +1,62 @@ +"""Éditeur de badges v2 (verso, fond de zone, auto-ajustement, photo ovale) + +Revision ID: 0026_badge_editor_v2 +Revises: 0025_badge_field_text +Create Date: 2026-10-27 + +`badge_field.face` : face du badge (`FRONT`/`BACK`) — les champs existants +deviennent des champs du recto. La contrainte d'unicité passe de +(`edition_id`, `field_type`) à (`edition_id`, `face`, `field_type`) : un même +champ peut figurer sur les deux faces. `bg_color` (fond de la zone, +transparent si `NULL`), `auto_fit` (réduction automatique du texte) et +`photo_shape` (`RECT`/`OVAL`, champ `PHOTO`). +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0026_badge_editor_v2" +down_revision: str | None = "0025_badge_field_text" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_FACE = sa.Enum("FRONT", "BACK", name="badgeface", native_enum=False) +_PHOTO_SHAPE = sa.Enum("RECT", "OVAL", name="badgephotoshape", native_enum=False) + + +def upgrade() -> None: + op.add_column( + "badge_field", + sa.Column("face", _FACE, server_default="FRONT", nullable=False), + ) + op.add_column("badge_field", sa.Column("bg_color", sa.String(), nullable=True)) + op.add_column( + "badge_field", + sa.Column("auto_fit", sa.Boolean(), server_default="false", nullable=False), + ) + op.add_column( + "badge_field", + sa.Column("photo_shape", _PHOTO_SHAPE, server_default="RECT", nullable=False), + ) + op.drop_constraint("uq_badge_field_edition_type", "badge_field", type_="unique") + op.create_unique_constraint( + "uq_badge_field_edition_face_type", + "badge_field", + ["edition_id", "face", "field_type"], + ) + + +def downgrade() -> None: + # Le verso n'existait pas : ses champs sont perdus (sinon l'ancienne + # contrainte d'unicité échouerait sur un champ présent sur les deux faces). + op.execute("DELETE FROM badge_field WHERE face = 'BACK'") + op.drop_constraint("uq_badge_field_edition_face_type", "badge_field", type_="unique") + op.create_unique_constraint( + "uq_badge_field_edition_type", "badge_field", ["edition_id", "field_type"] + ) + op.drop_column("badge_field", "photo_shape") + op.drop_column("badge_field", "auto_fit") + op.drop_column("badge_field", "bg_color") + op.drop_column("badge_field", "face") diff --git a/backend/app/migrations/versions/0027_volunteer_badge_count.py b/backend/app/migrations/versions/0027_volunteer_badge_count.py new file mode 100644 index 0000000..59e4466 --- /dev/null +++ b/backend/app/migrations/versions/0027_volunteer_badge_count.py @@ -0,0 +1,35 @@ +"""Nombre de badges par bénévole + +Revision ID: 0027_volunteer_badge_count +Revises: 0026_badge_editor_v2 +Create Date: 2026-10-27 + +`volunteer.badge_count` : nombre d'exemplaires de son badge imprimés sur les +planches (`POST .../badges/generate`), 1 par défaut — les bénévoles existants +gardent un seul badge. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0027_volunteer_badge_count" +down_revision: str | None = "0026_badge_editor_v2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "volunteer", + sa.Column("badge_count", sa.Integer(), nullable=False, server_default="1"), + ) + op.create_check_constraint( + "ck_volunteer_badge_count_positive", "volunteer", "badge_count >= 1" + ) + + +def downgrade() -> None: + op.drop_constraint("ck_volunteer_badge_count_positive", "volunteer", type_="check") + op.drop_column("volunteer", "badge_count") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b1919a0..1df26d4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -28,7 +28,9 @@ from app.models.domain import ( from app.models.enums import ( AssignmentSource, BadgeAlign, + BadgeFace, BadgeFieldType, + BadgePhotoShape, FeePaymentStatus, FormFieldType, PositionCategory, @@ -41,8 +43,10 @@ __all__ = [ "Assignment", "AssignmentSource", "BadgeAlign", + "BadgeFace", "BadgeField", "BadgeFieldType", + "BadgePhotoShape", "BadgeTemplate", "DayPart", "Edition", diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 7c63f0d..47daa81 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -18,7 +18,9 @@ from app.models.base import TimestampMixin from app.models.enums import ( AssignmentSource, BadgeAlign, + BadgeFace, BadgeFieldType, + BadgePhotoShape, FeePaymentStatus, FormFieldType, PositionCategory, @@ -160,6 +162,10 @@ class Artist(TimestampMixin, table=True): class Volunteer(TimestampMixin, table=True): + __table_args__ = ( + CheckConstraint("badge_count >= 1", name="ck_volunteer_badge_count_positive"), + ) + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) edition_id: uuid.UUID = Field( foreign_key="edition.id", index=True, ondelete="CASCADE" @@ -187,6 +193,11 @@ class Volunteer(TimestampMixin, table=True): # saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide : # le texte commun de l'éditeur (`BadgeField.text`) s'applique. badge_text: str | None = None + # Nombre d'exemplaires de son badge à imprimer sur les planches (badge + # perdu, plusieurs accréditations…) — 1 par défaut. + badge_count: int = Field( + default=1, sa_column=Column(Integer, nullable=False, server_default="1") + ) # Cases à cocher du formulaire public — purement informatives : aucune # logique n'y est attachée (pas de workflow de paiement). self_declared_available: bool = Field( @@ -429,16 +440,17 @@ class BadgeTemplate(TimestampMixin, table=True): class BadgeField(TimestampMixin, table=True): - """Un champ positionné sur le recto du badge (nom, poste(s), catégorie, - rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin - haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`). - Au plus un champ par `field_type` et par édition : décocher un champ dans + """Un champ positionné sur une face du badge (`face` : recto ou verso — + nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en + millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`). + Au plus un champ par `field_type` et par face pour une édition (le même + champ peut figurer au recto et au verso) : décocher un champ dans l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`).""" __tablename__ = "badge_field" __table_args__ = ( UniqueConstraint( - "edition_id", "field_type", name="uq_badge_field_edition_type" + "edition_id", "face", "field_type", name="uq_badge_field_edition_face_type" ), ) @@ -446,6 +458,14 @@ class BadgeField(TimestampMixin, table=True): edition_id: uuid.UUID = Field( foreign_key="edition.id", index=True, ondelete="CASCADE" ) + face: BadgeFace = Field( + default=BadgeFace.FRONT, + sa_column=Column( + SAEnum(BadgeFace, native_enum=False), + nullable=False, + server_default=BadgeFace.FRONT.value, + ), + ) field_type: BadgeFieldType = Field( sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False) ) @@ -471,6 +491,24 @@ class BadgeField(TimestampMixin, table=True): # tous les badges — sauf ceux dont la fiche porte un « Texte du badge » # (`Volunteer.badge_text`), qui le remplace pour ce bénévole. text: str | None = None + # Fond de la zone du champ (`#rrggbb`) ; `None` = transparent. Sans + # effet sur le champ `PHOTO`. + bg_color: str | None = None + # Réduit la taille du texte (par pas de 0,5 pt) jusqu'à ce que tout + # tienne dans le cadre, au lieu de tronquer les lignes en trop. + auto_fit: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="false"), + ) + # Champ `PHOTO` seulement : rectangle ou ellipse inscrite dans le cadre. + photo_shape: BadgePhotoShape = Field( + default=BadgePhotoShape.RECT, + sa_column=Column( + SAEnum(BadgePhotoShape, native_enum=False), + nullable=False, + server_default=BadgePhotoShape.RECT.value, + ), + ) # --------------------------------------------------------------------------- # diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py index 634aabc..8847535 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -31,7 +31,7 @@ class PositionCategory(enum.StrEnum): class BadgeFieldType(enum.StrEnum): - """Champ affichable sur le recto d'un badge (voir `app/badges/`).""" + """Champ affichable sur le recto ou le verso d'un badge (voir `app/badges/`).""" FULL_NAME = "FULL_NAME" POSITIONS = "POSITIONS" @@ -45,6 +45,22 @@ class BadgeFieldType(enum.StrEnum): FREE_TEXT = "FREE_TEXT" +class BadgeFace(enum.StrEnum): + """Face du badge sur laquelle un champ est imprimé : le verso est + personnalisable comme le recto (même champ possible sur les deux).""" + + FRONT = "FRONT" + BACK = "BACK" + + +class BadgePhotoShape(enum.StrEnum): + """Forme de découpe du champ `PHOTO` (cadre rectangulaire ou ellipse + inscrite dans le cadre).""" + + RECT = "RECT" + OVAL = "OVAL" + + class BadgeAlign(enum.StrEnum): LEFT = "LEFT" CENTER = "CENTER" diff --git a/backend/tests/test_badges_api.py b/backend/tests/test_badges_api.py index 2d254ee..b3f2592 100644 --- a/backend/tests/test_badges_api.py +++ b/backend/tests/test_badges_api.py @@ -247,3 +247,154 @@ def test_generating_with_photo_and_free_text_fields(client, edition, session): resp = client.post(f"{_badges_url(edition)}/generate", json={}) assert resp.status_code == 200 assert resp.content.startswith(b"%PDF") + + +def _field(**kw): + return {"x_mm": 0, "y_mm": 0, "width_mm": 10, "height_mm": 10, **kw} + + +def test_new_field_attributes_default_and_round_trip(client, edition): + resp = client.put( + f"{_badges_url(edition)}/template/fields", + json={ + "fields": [ + _field(field_type="FULL_NAME"), + _field( + field_type="PHOTO", face="BACK", photo_shape="OVAL", + bg_color="#FFD23F", auto_fit=True, + ), + ] + }, + ) + assert resp.status_code == 200 + by_face = {f["face"]: f for f in resp.json()} + front, back = by_face["FRONT"], by_face["BACK"] + assert front["field_type"] == "FULL_NAME" + assert front["bg_color"] is None + assert front["auto_fit"] is False + assert front["photo_shape"] == "RECT" + assert back["field_type"] == "PHOTO" + assert back["photo_shape"] == "OVAL" + assert back["bg_color"] == "#FFD23F" + assert back["auto_fit"] is True + + again = client.get(f"{_badges_url(edition)}/template").json()["fields"] + assert {(f["face"], f["field_type"]) for f in again} == { + ("FRONT", "FULL_NAME"), ("BACK", "PHOTO"), + } + + +def test_same_field_type_on_front_and_back_is_allowed(client, edition): + resp = client.put( + f"{_badges_url(edition)}/template/fields", + json={ + "fields": [ + _field(field_type="FREE_TEXT", face="FRONT", text="Recto"), + _field(field_type="FREE_TEXT", face="BACK", text="Verso"), + ] + }, + ) + assert resp.status_code == 200 + assert {(f["face"], f["text"]) for f in resp.json()} == { + ("FRONT", "Recto"), ("BACK", "Verso"), + } + + +def test_same_field_type_twice_on_the_back_is_rejected(client, edition): + resp = client.put( + f"{_badges_url(edition)}/template/fields", + json={ + "fields": [ + _field(field_type="ROLE", face="BACK"), + _field(field_type="ROLE", face="BACK"), + ] + }, + ) + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "badge.duplicate_field_type" + + +@pytest.mark.parametrize("color", ["red", "#FFF", "#GGGGGG", "FFD23F", "#FFD23F0"]) +def test_invalid_background_color_is_rejected(client, edition, color): + resp = client.put( + f"{_badges_url(edition)}/template/fields", + json={"fields": [_field(field_type="ROLE", bg_color=color)]}, + ) + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "badge.invalid_color" + + +def test_generating_with_back_fields(client, edition, session, monkeypatch): + import app.api.badges as badges_api + + captured = {} + original = badges_api.generate_badge_sheets_pdf + + def spy(badges, *args, **kwargs): + captured["badges"] = badges + return original(badges, *args, **kwargs) + + monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy) + resp = client.put( + f"{_badges_url(edition)}/template/fields", + json={ + "fields": [ + _field(field_type="FULL_NAME", width_mm=60, font_size=12), + _field(field_type="ROLE", face="BACK", bg_color="#FFD23F", auto_fit=True), + _field(field_type="PHOTO", face="BACK", photo_shape="OVAL"), + ] + }, + ) + assert resp.status_code == 200 + make_volunteer(session, edition, first_name="Alex", last_name="Martin") + + resp = client.post(f"{_badges_url(edition)}/generate", json={}) + assert resp.status_code == 200 + assert resp.content.startswith(b"%PDF") + + (badge,) = captured["badges"] + assert [f.text for f in badge.fields] == ["Alex\nMartin"] + back = {f.is_photo: f for f in badge.back_fields} + assert back[False].text == "Bénévole" + assert back[False].valign == "MIDDLE" + assert back[False].bg_color == "#FFD23F" + assert back[False].auto_fit is True + assert back[True].photo_shape == "OVAL" + assert back[True].text == "AM" # initiales, faute de photo + + +def test_generating_prints_as_many_badges_as_requested(client, edition, session, monkeypatch): + import app.api.badges as badges_api + + captured = {} + original = badges_api.generate_badge_sheets_pdf + + def spy(badges, *args, **kwargs): + captured["badges"] = badges + return original(badges, *args, **kwargs) + + monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy) + client.put( + f"{_badges_url(edition)}/template/fields", + json={"fields": [_field(field_type="FULL_NAME", width_mm=60, font_size=12)]}, + ) + many = make_volunteer(session, edition, first_name="Alex", last_name="Martin") + make_volunteer(session, edition, first_name="Sam", last_name="Nuit") + resp = client.patch(f"/editions/{edition.id}/volunteers/{many.id}", json={"badge_count": 3}) + assert resp.status_code == 200 + assert resp.json()["badge_count"] == 3 + + resp = client.post(f"{_badges_url(edition)}/generate", json={}) + assert resp.status_code == 200 + + names = [badge.fields[0].text for badge in captured["badges"]] + assert names == ["Alex\nMartin"] * 3 + ["Sam\nNuit"] + + +@pytest.mark.parametrize("count", [0, -1, 101]) +def test_badge_count_out_of_range_is_rejected(client, edition, session, count): + volunteer = make_volunteer(session, edition) + resp = client.patch( + f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count} + ) + assert resp.status_code == 422 diff --git a/backend/tests/test_badges_fields.py b/backend/tests/test_badges_fields.py index a426580..50d5e68 100644 --- a/backend/tests/test_badges_fields.py +++ b/backend/tests/test_badges_fields.py @@ -104,3 +104,21 @@ def test_field_text_free_text_falls_back_to_the_common_editor_text(): assert _field_text(field, own, {}, set()) == "Accès backstage" assert _field_text(field, other, {}, set()) == "Staff 2026" + + +def test_field_text_full_name_is_first_name_then_last_name_on_two_lines(): + volunteer = _volunteer(first_name="Alex", last_name="Martin") + field = BadgeField( + edition_id=uuid.uuid4(), field_type=BadgeFieldType.FULL_NAME, + x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9, + ) + assert _field_text(field, volunteer, {}, set()) == "Alex\nMartin" + + +def test_field_text_photo_is_the_initials_used_without_a_photo(): + volunteer = _volunteer(first_name="alex", last_name="martin") + field = BadgeField( + edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO, + x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9, + ) + assert _field_text(field, volunteer, {}, set()) == "AM" diff --git a/backend/tests/test_badges_pdf.py b/backend/tests/test_badges_pdf.py index 0fdcd71..016e3ae 100644 --- a/backend/tests/test_badges_pdf.py +++ b/backend/tests/test_badges_pdf.py @@ -236,3 +236,170 @@ def test_table_rows_beyond_the_frame_are_not_drawn(): few = generate_badge_sheets_pdf([_table_badge(4, height_mm=12)], None, None) many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None) assert abs(len(many) - len(few)) < 50 + + +# --------------------------------------------------------------------------- # +# Éditeur v2 : verso, fond de zone, réduction automatique, photo # +# --------------------------------------------------------------------------- # +from app.badges import pdf as pdf_module # noqa: E402 +from app.badges.pdf import fit_text, initials # noqa: E402 + + +def _text_field(**kw) -> BadgeFieldSpec: + base = dict( + text="Alex", x_mm=5, y_mm=5, width_mm=60, height_mm=10, + font_size=12, align="LEFT", bold=False, color="#000000", + ) + base.update(kw) + return BadgeFieldSpec(**base) + + +def test_cell_origin_mirrors_the_column_on_the_back_page(): + cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM) + w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM + # Retournement bord long : colonne 0 au recto ⇒ dernière colonne au + # verso, même rangée. + assert _cell_origin(0, cols, w, h, mirrored=True) == _cell_origin(cols - 1, cols, w, h) + assert _cell_origin(cols, cols, w, h, mirrored=True) == _cell_origin( + 2 * cols - 1, cols, w, h + ) + + +def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeypatch): + calls = [] + original = pdf_module._draw_fields + + def spy(c, fields, x0, y0, badge_height_mm): + calls.append((fields, x0, y0)) + original(c, fields, x0, y0, badge_height_mm) + + monkeypatch.setattr(pdf_module, "_draw_fields", spy) + back = (_text_field(text="Verso Alex"),) + # Un seul badge : planche incomplète, le verso doit quand même être en + # miroir (colonne de droite). + generate_badge_sheets_pdf( + [BadgeSpec(fields=(_text_field(),), back_fields=back)], None, None + ) + + cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM) + w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM + (front_fields, fx, fy), (back_fields, bx, by) = calls + assert (fx, fy) == _cell_origin(0, cols, w, h) + assert back_fields == back + assert (bx, by) == _cell_origin(cols - 1, cols, w, h) + + +def test_fit_text_without_auto_fit_drops_the_lines_that_overflow(): + text = "\n".join(["ligne"] * 10) + size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False) + assert size == 12 + assert 0 < len(lines) < 10 + + +def test_fit_text_with_auto_fit_shrinks_until_every_line_fits(): + text = "\n".join(["ligne"] * 4) + assert len(fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)[1]) < 4 + size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True) + assert 4 < size < 12 + assert (size * 2) == int(size * 2) # pas de 0,5 pt + assert len(lines) == 4 + + +def test_fit_text_auto_fit_stops_at_the_minimum_size(): + text = "\n".join(["ligne"] * 200) + size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True) + assert size == 4.0 + assert len(lines) < 200 + + +def test_fit_text_auto_fit_keeps_the_size_when_the_text_already_fits(): + assert fit_text("Alex", "Helvetica", 12, 60, 10, auto_fit=True) == (12, ["Alex"]) + + +def _table_lines_drawn(monkeypatch, auto_fit: bool) -> int: + drawn = [] + monkeypatch.setattr( + pdf_module.canvas.Canvas, "drawString", + lambda self, x, y, text, *a, **k: drawn.append(text), + ) + badge = _table_badge(4, height_mm=20) + field = badge.fields[0] + badge = BadgeSpec(fields=(BadgeFieldSpec(**{**field.__dict__, "auto_fit": auto_fit}),)) + generate_badge_sheets_pdf([badge], None, None) + return sum(1 for text in drawn if text == "19:00–22:00") + + +def test_table_auto_fit_draws_every_row(monkeypatch): + # En-tête + 4 lignes : la dernière ne tient pas à 7 pt dans 20 mm. + assert _table_lines_drawn(monkeypatch, auto_fit=False) < 4 + assert _table_lines_drawn(monkeypatch, auto_fit=True) == 4 + + +def _filled_rects(monkeypatch) -> list: + fills = [] + original = pdf_module.canvas.Canvas.rect + + def spy(self, x, y, w, h, stroke=1, fill=0): + if fill: + fills.append((round(w / mm, 3), round(h / mm, 3))) + return original(self, x, y, w, h, stroke=stroke, fill=fill) + + monkeypatch.setattr(pdf_module.canvas.Canvas, "rect", spy) + return fills + + +def test_zone_background_fills_the_field_frame(monkeypatch): + fills = _filled_rects(monkeypatch) + generate_badge_sheets_pdf( + [BadgeSpec(fields=(_text_field(bg_color="#FFD23F"),))], None, None + ) + assert fills == [(60, 10)] + + +def test_no_zone_background_by_default(monkeypatch): + fills = _filled_rects(monkeypatch) + generate_badge_sheets_pdf([BadgeSpec(fields=(_text_field(),))], None, None) + assert fills == [] + + +def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch): + baselines = [] + monkeypatch.setattr( + pdf_module.canvas.Canvas, "drawString", + lambda self, x, y, text, *a, **k: baselines.append(y), + ) + for valign in ("TOP", "MIDDLE"): + generate_badge_sheets_pdf( + [BadgeSpec(fields=(_text_field(height_mm=30, valign=valign),))], None, None + ) + top, middle = baselines + assert middle < top + + +def test_initials_are_the_uppercase_first_letters(): + assert initials("alex", "martin") == "AM" + assert initials("Élodie", "de la Tour") == "ÉD" + + +def test_missing_photo_prints_the_initials(monkeypatch): + drawn = [] + monkeypatch.setattr( + pdf_module.canvas.Canvas, "drawCentredString", + lambda self, x, y, text, *a, **k: drawn.append(text), + ) + for image in (None, b"pas une image"): + badge = BadgeSpec( + fields=(BadgeFieldSpec(**{**_photo_badge(image).fields[0].__dict__, "text": "AM"}),) + ) + generate_badge_sheets_pdf([badge], None, None) + assert drawn == ["AM", "AM"] + + +def test_oval_photo_and_oval_initials_generate_a_valid_pdf(): + for image in (_jpeg(400, 500), None): + field = _photo_badge(image).fields[0] + badge = BadgeSpec( + fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL", "text": "AM"}),) + ) + pdf = generate_badge_sheets_pdf([badge], None, None) + assert pdf.startswith(b"%PDF") diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index 0ce72d3..e65d243 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -461,6 +461,64 @@ function checkDayBoundary() { console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`); } +// Éditeur de badges (onglet « Personnaliser ») : un champ par face, rendu +// avec les vraies données (nom du premier bénévole dans l'ordre +// d'impression), contrôle avant génération présent ; le mock refuse un même +// type de champ deux fois sur une face, mais l'accepte sur les deux. +async function checkBadgeEditor() { + if (!coordinator) throw new Error('badges : pas de session coordination pour ce test'); + const base = { + x_mm: 4, y_mm: 4, width_mm: 40, height_mm: 12, font_size: 9, align: 'LEFT' as const, + bold: true, color: '#14110E', bg_color: null, auto_fit: true, photo_shape: 'RECT' as const, + }; + let duplicate = 'non'; + try { + await api.badges.setFields(e, { + fields: [ + { ...base, field_type: 'FULL_NAME', face: 'FRONT' }, + { ...base, field_type: 'FULL_NAME', face: 'FRONT' }, + ], + }); + } catch (err) { + duplicate = err instanceof ApiError ? err.code : String(err); + } + if (duplicate !== 'badge.duplicate_field_type') throw new Error(`doublon accepté : ${duplicate}`); + await api.badges.setFields(e, { + fields: [ + { ...base, field_type: 'FULL_NAME', face: 'FRONT' }, + { ...base, field_type: 'POSITIONS', face: 'FRONT', y_mm: 18, height_mm: 30, bold: false }, + { ...base, field_type: 'FULL_NAME', face: 'BACK', bg_color: '#FFD23F' }, + { ...base, field_type: 'FREE_TEXT', face: 'BACK', y_mm: 30, text: 'STAFF' }, + ], + }); + const vols = [...(await api.volunteers.list(e))].sort( + (a, b) => + a.last_name.localeCompare(b.last_name, 'fr') || a.first_name.localeCompare(b.first_name, 'fr'), + ); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await prefetchCore(client); + await Promise.all([ + client.prefetchQuery({ queryKey: ['badgeTemplate', e], queryFn: () => api.badges.getTemplate(e) }), + client.prefetchQuery({ queryKey: ['dayParts', e], queryFn: () => api.dayParts.list(e) }), + client.prefetchQuery({ queryKey: ['accounts', e], queryFn: () => api.accounts.list(e) }), + ]); + const html = renderToString( + createElement( + QueryClientProvider, + { client }, + createElement( + AuthContext.Provider, + { value: authValue(coordinator) }, + createElement(MemoryRouter, null, createElement(BadgesPage)), + ), + ), + ); + const expected = [vols[0]?.last_name ?? '', 'STAFF', 'Contrôle des']; + const missing = expected.filter((text) => !html.includes(text)); + if (missing.length) throw new Error(`badges : absent du rendu ${JSON.stringify(missing)}`); + console.log(`ok ${'/badges'.padEnd(26)} ${html.length} octets`); +} + // --------------------------------------------------------------------------- // try { checkDayBoundary(); @@ -484,7 +542,7 @@ if (coordinator) { await renderProtected('/grille', coordinator, createElement(GrillePage)); await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage)); await renderProtected('/configuration', coordinator, createElement(ConfigPage)); - await renderProtected('/badges', coordinator, createElement(BadgesPage)); + await checkBadgeEditor().catch((err) => fail('checkBadgeEditor', err)); await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage)); await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => { await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) }); diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index 9899ec7..71be10a 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -290,6 +290,7 @@ export function buildMockState(): MockState { has_photo: false, companion_wish_note: null, badge_text: null, + badge_count: 1, self_declared_available: false, willing_to_pay_fee: false, fee_payment_label: null, diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 3ee0098..42946ca 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -235,6 +235,7 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead { has_photo: pending.photo !== null, companion_wish_note: pending.companionWishNote, badge_text: null, + badge_count: 1, self_declared_available: pending.available, willing_to_pay_fee: true, fee_payment_label: pending.paymentLabel, @@ -997,6 +998,7 @@ export function createMockApi(): PlanningApi { has_photo: false, companion_wish_note: null, badge_text: null, + badge_count: 1, self_declared_available: false, willing_to_pay_fee: false, fee_payment_label: null, @@ -1042,6 +1044,14 @@ export function createMockApi(): PlanningApi { ? { companion_wish_note: body.companion_wish_note ?? null } : {}), ...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}), + ...(body.badge_count != null + ? (() => { + if (!Number.isInteger(body.badge_count) || body.badge_count < 1 || body.badge_count > 100) { + throw new ApiError(422, 'error.generic'); + } + return { badge_count: body.badge_count }; + })() + : {}), ...('self_declared_available' in body && body.self_declared_available != null ? { self_declared_available: body.self_declared_available } : {}), @@ -1321,6 +1331,7 @@ export function createMockApi(): PlanningApi { has_photo: false, companion_wish_note: null, badge_text: null, + badge_count: 1, self_declared_available: false, willing_to_pay_fee: false, fee_payment_label: null, @@ -1577,13 +1588,24 @@ export function createMockApi(): PlanningApi { return (await fetch(state.badgeTemplate.backImage)).blob(); }, setFields: (_edition, body: BadgeFieldsSet) => { + // Un même type de champ peut exister une fois par face (recto/verso), + // comme côté back-end. const seen = new Set(); + const hexColor = /^#[0-9a-fA-F]{6}$/; for (const f of body.fields) { - if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type'); - seen.add(f.field_type); + const key = `${f.face ?? 'FRONT'}:${f.field_type}`; + if (seen.has(key)) throw new ApiError(422, 'badge.duplicate_field_type'); + seen.add(key); + if (!hexColor.test(f.color) || (f.bg_color != null && !hexColor.test(f.bg_color))) { + throw new ApiError(422, 'badge.invalid_color'); + } } const fields: BadgeFieldRead[] = body.fields.map((f) => ({ ...f, + face: f.face ?? 'FRONT', + bg_color: f.bg_color ?? null, + auto_fit: f.auto_fit ?? true, + photo_shape: f.photo_shape ?? 'RECT', id: `bf${state.seq++}`, edition_id: state.edition.id, })); @@ -1792,6 +1814,7 @@ export function createMockApi(): PlanningApi { has_photo: photo !== null, companion_wish_note: companionWishNote, badge_text: null, + badge_count: 1, self_declared_available: fields.has('AVAILABLE') ? available : false, willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'), fee_payment_label: null, diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index b63fb67..1d22cb4 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -110,6 +110,8 @@ export interface VolunteerRead { /** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la * coordination. */ badge_text: string | null; + /** Exemplaires de son badge imprimés sur les planches (1 par défaut). */ + badge_count: number; self_declared_available: boolean; willing_to_pay_fee: boolean; /** Paiement de la cotisation via SumUp (voir schema forms) — @@ -161,6 +163,8 @@ export interface VolunteerUpdate { wished_companion_ids?: string[] | null; companion_wish_note?: string | null; badge_text?: string | null; + /** 1 à 100. */ + badge_count?: number | null; self_declared_available?: boolean | null; willing_to_pay_fee?: boolean | null; } @@ -433,9 +437,16 @@ export type BadgeFieldType = | 'PHOTO' | 'FREE_TEXT'; export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT'; +/** Face du badge où se trouve un champ : chaque type de champ peut exister + * une fois par face. */ +export type BadgeFace = 'FRONT' | 'BACK'; +/** Forme du cadre de la photo (`PHOTO`). */ +export type BadgePhotoShape = 'RECT' | 'OVAL'; export interface BadgeFieldIn { field_type: BadgeFieldType; + /** Défaut `FRONT`. */ + face: BadgeFace; x_mm: number; y_mm: number; width_mm: number; @@ -444,6 +455,13 @@ export interface BadgeFieldIn { align: BadgeAlign; bold: boolean; color: string; + /** Fond de la zone du champ (`#rrggbb`), `null` = transparent. */ + bg_color: string | null; + /** Réduit la taille du texte (par pas de 0,5 pt, jusqu'à 4 pt) quand il + * ne tient pas dans le cadre. */ + auto_fit: boolean; + /** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */ + photo_shape: BadgePhotoShape; /** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour * un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */ text?: string | null; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 3bbd004..2a2e43d 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -231,6 +231,8 @@ "config.vol.field.phone": "Téléphone", "config.vol.field.comment": "Commentaire", "config.vol.field.badgeText": "Texte du badge", + "config.vol.field.badgeCount": "Nombre de badges", + "config.vol.field.badgeCount.help": "Nombre d'exemplaires de son badge imprimés sur les planches (1 à 100). 1 par défaut.", "config.vol.field.badgeText.help": "Remplace, pour ce bénévole seulement, le texte libre commun saisi dans l'écran Badges. Vide = texte commun.", "config.vol.photo.title": "Photo", "config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.", @@ -384,13 +386,121 @@ "badges.saved": "Enregistré ✓", "badges.generate.title": "Génération de la planche PDF", "badges.generate.all": "Tous les bénévoles ({count})", + "badges.generate.total": "{count} badge(s) à imprimer", "badges.generate.pick": "Choisir ({count} sélectionné(s))", "badges.generate.action": "Générer le PDF", "badges.generating": "Génération…", + "badges.face.front": "Recto", + "badges.face.back": "Verso", + "badges.face.front.short": "recto", + "badges.face.back.short": "verso", + "badges.dims": "{width} × {height} mm", + "badges.drop.front": "Déposez ici le visuel du recto (PNG ou JPEG)", + "badges.drop.back": "Déposez ici le visuel du verso (PNG ou JPEG)", + "badges.format.title": "Format du badge", + "badges.format.current": "Format actuel", + "badges.preset.cr80Portrait": "Carte CR80 portrait", + "badges.preset.cr80Landscape": "Carte CR80 paysage", + "badges.preset.a7": "A7 tour de cou", + "badges.fieldsOf": "Champs du {face}", + "badges.fieldsShown": "{count} affiché(s)", + "badges.layer.toggle": "Afficher / masquer", + "badges.layer.hidden": "masqué", + "badges.layer.meta": "{size} pt · {weight}", + "badges.layer.bold": "gras", + "badges.layer.regular": "normal", + "badges.layer.rect": "rectangle", + "badges.layer.oval": "ovale", + "badges.layer.issueCount": "▲ {count}", + "badges.layer.issueTruncated.one": "{count} badge avec texte coupé", + "badges.layer.issueTruncated.other": "{count} badges avec texte coupé", + "badges.layer.issueReduced.one": "{count} badge réduit automatiquement", + "badges.layer.issueReduced.other": "{count} badges réduits automatiquement", + "badges.keys.hint": "Clic sur un champ pour le régler. Flèches : déplacer de 0,5 mm. Maj + flèches : 2 mm. Échap : désélectionner. Ctrl/Cmd + Z : annuler.", + "badges.undo": "↶ Annuler", + "badges.undo.title": "Ctrl/Cmd + Z", + "badges.dirty": "Modifications non enregistrées", + "badges.clean": "✓ Enregistré", + "badges.savedAt": "✓ Enregistré à {time}", + "badges.sample.firstName": "Prénom", + "badges.sample.lastName": "Nom", + "badges.preview.with": "Aperçu avec", + "badges.preview.prev": "Bénévole précédent", + "badges.preview.next": "Bénévole suivant", + "badges.preview.counter": "Badge {index} / {total} · {role}", + "badges.preview.noVolunteer": "Aucun bénévole : exemple fictif", + "badges.preview.longest": "Tester le nom le plus long", + "badges.preview.guides": "Repères", + "badges.preview.guides.help": "Affiche la marge de découpe (1,5 mm) et le cadre de chaque champ", + "badges.preview.zoomIn": "Agrandir l'aperçu", + "badges.preview.zoomOut": "Réduire l'aperçu", + "badges.preview.zoom": "{percent} %", + "badges.preview.truncated": "▲ TEXTE COUPÉ", + "badges.preview.dims": "{width} × {height} mm", + "badges.preview.reducedTo": " · réduit à {size} pt", + "badges.pt": "{size} pt", + "badges.color.white": "Blanc", + "badges.color.black": "Noir", + "badges.color.yellow": "Jaune", + "badges.color.coral": "Corail", + "badges.color.none": "Aucun", + "badges.color.custom": "Autre", + "badges.align.left": "Gauche", + "badges.align.center": "Centre", + "badges.align.right": "Droite", + "badges.inspector.kicker": "{face} · champ sélectionné", + "badges.inspector.content": "Contenu", + "badges.inspector.contentHelp": "Identique sur tous les badges — sauf pour un bénévole dont la fiche porte un « Texte du badge », qui le remplace.", + "badges.inspector.fontSize": "Taille du texte", + "badges.inspector.reducedHere": "Ici réduit à {size} pt", + "badges.inspector.capHeight": "Hauteur ≈ {mm} mm", + "badges.inspector.smaller": "Diminuer la taille", + "badges.inspector.larger": "Augmenter la taille", + "badges.inspector.autoFit": "Réduire automatiquement si le texte est trop long", + "badges.inspector.align": "Alignement", + "badges.inspector.weight": "Graisse", + "badges.inspector.regular": "Normal", + "badges.inspector.bold": "Gras", + "badges.inspector.textColor": "Couleur du texte", + "badges.inspector.background": "Fond de la zone", + "badges.inspector.lowContrast": "⚠ Contraste faible : le texte risque d'être illisible à l'impression.", + "badges.inspector.photoShape": "Forme de la photo", + "badges.inspector.photoRect": "▢ Rectangle", + "badges.inspector.photoOval": "◯ Ovale", + "badges.inspector.photoStat": "{count} photo(s) reçue(s) sur {total}. Sans photo : initiales sur fond beige.", + "badges.inspector.geometry": "Position et taille (mm)", + "badges.inspector.x": "X", + "badges.inspector.y": "Y", + "badges.inspector.w": "L", + "badges.inspector.h": "H", + "badges.inspector.center": "↔ Centrer", + "badges.inspector.fullWidth": "⇤⇥ Pleine largeur", + "badges.inspector.hide": "Masquer ce champ", + "badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.", + "badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.", + "badges.inspector.empty.drop": "Pour changer le visuel de fond, déposez une image directement sur le recto ou le verso.", + "badges.check.title": "Contrôle des {count} badges", + "badges.check.subtitle": "Avant génération des planches", + "badges.check.critical": "⛔ À corriger", + "badges.check.warning": "⚠ À vérifier", + "badges.check.fixAutoFit": "Activer la réduction auto", + "badges.check.showExample": "Voir un exemple", + "badges.check.ok": "Les {count} badges s'affichent sans texte coupé ni champ hors zone de découpe.", + "badges.check.estimate": "Estimation faite dans le navigateur : le PDF généré reste la référence.", + "badges.issue.where": "{field} ({face})", + "badges.issue.truncated.one": "{where} : texte coupé sur {count} badge.", + "badges.issue.truncated.other": "{where} : texte coupé sur {count} badges.", + "badges.issue.reduced.one": "{where} : réduit automatiquement sur {count} badge, jusqu'à {size} pt.", + "badges.issue.reduced.other": "{where} : réduit automatiquement sur {count} badges, jusqu'à {size} pt.", + "badges.issue.margin": "{where} : touche la marge de découpe (1,5 mm), risque d'être rogné.", + "badges.issue.contrast": "{where} : texte et fond de zone trop proches, illisible.", + "badges.issue.noPhoto.one": "{count} bénévole n'a pas de photo : ses initiales seront imprimées à la place.", + "badges.issue.noPhoto.other": "{count} bénévoles n'ont pas de photo : leurs initiales seront imprimées à la place.", "badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.", "badge.empty_image": "L'image est vide.", "badge.image_too_large": "L'image est trop volumineuse.", - "badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.", + "badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois par face.", + "badge.invalid_color": "Couleur invalide (format attendu : #rrggbb).", "badge.invalid_size": "La taille du badge doit être strictement positive.", "badge.no_fields": "Activez au moins un champ sur le recto avant de générer.", "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.", diff --git a/frontend/src/pages/BadgesPage.tsx b/frontend/src/pages/BadgesPage.tsx index a39fb6c..cf5124a 100644 --- a/frontend/src/pages/BadgesPage.tsx +++ b/frontend/src/pages/BadgesPage.tsx @@ -1,677 +1,43 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; -import { - useBadgeImageUrl, - useBadgeTemplate, - useGenerateBadges, - useSetBadgeFields, - useSetBadgeSize, - useUploadBadgeBackImage, - useUploadBadgeFrontImage, - useVolunteers, -} from '@/api/hooks'; -import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema'; +import { useGenerateBadges, useVolunteers } from '@/api/hooks'; import { ApiError } from '@/api'; import { EmptyNote, ErrorNote, Loading } from '@/components/state'; -import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui'; +import { Btn, Chip, Panel, PanelHeader } from '@/components/ui'; import { t } from '@/i18n'; -import { cx } from '@/lib/cx'; -// Le format du badge est dérivé de l'image de fond du recto (voir -// `backend/app/badges/pdf.py:image_size_mm`) — CR80 (85,6 × 54 mm) tant -// qu'aucune image n'est importée. `SCALE` fixe la taille d'édition à -// l'écran ; les coordonnées enregistrées restent en mm. -const DEFAULT_BADGE_W_MM = 85.6; -const DEFAULT_BADGE_H_MM = 54; -const SCALE = 4.6; // px par mm à l'écran (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; +import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab'; -/** É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', - 'PHOTO', - 'FREE_TEXT', -]; - -const FIELD_LABEL_KEY: Record = { - 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> = { - PHOTO: 'badges.field.photo.help', - FREE_TEXT: 'badges.field.freeText.help', -}; - -const FIELD_PLACEHOLDER_KEY: Record = { - FULL_NAME: 'badges.field.fullName.placeholder', - 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 -// verticalement pour ne pas se superposer (repères pour un badge CR80 ; -// l'utilisateur ajuste ensuite à la souris pour son propre format). Postes -// affectés sur plusieurs lignes (un par créneau, avec horaire) : plus haut -// que les autres champs. -const FIELD_DEFAULTS: Record> = { - FULL_NAME: { - x_mm: 5, y_mm: 4, width_mm: 70, height_mm: 10, - font_size: 13, align: 'LEFT', bold: true, color: '#000000', - }, - POSITIONS: { - x_mm: 5, y_mm: 15, width_mm: 70, height_mm: 22, - font_size: 8, align: 'LEFT', bold: false, color: '#000000', - }, - CATEGORY: { - x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 7, - font_size: 8, align: 'LEFT', bold: false, color: '#000000', - }, - ROLE: { - x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6, - font_size: 8, align: 'LEFT', bold: true, color: '#a1442b', - }, - // 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 { - return Math.min(Math.max(value, min), max); -} - -/** Ramène un champ à l'intérieur du badge (réduit s'il est plus grand, - * décalé s'il dépasse). Les positions par défaut sont pensées pour un CR80 - * paysage (85,6 × 54 mm) : sur un badge plus étroit (portrait…), un champ - * activé pouvait tomber hors du badge — invisible, et les bornes négatives - * du glisser-déposer le bloquaient (bug corrigé une fois : photo - * impossible à déplacer ni redimensionner). */ -function fitToBadge(field: F, badgeW: number, badgeH: number): F { - const width_mm = Math.min(field.width_mm, badgeW); - const height_mm = Math.min(field.height_mm, badgeH); - return { - ...field, - width_mm, - height_mm, - x_mm: clamp(field.x_mm, 0, badgeW - width_mm), - y_mm: clamp(field.y_mm, 0, badgeH - height_mm), - }; -} - -function fieldsFromServer(saved: BadgeFieldIn[]): Record { - const byType = new Map(saved.map((f) => [f.field_type, f])); - const out = {} as Record; - for (const type of FIELD_TYPES) { - const existing = byType.get(type); - out[type] = existing - ? { ...existing, enabled: true } - : { field_type: type, enabled: false, ...FIELD_DEFAULTS[type] }; - } - return out; -} +// L'onglet « Personnaliser » (éditeur recto/verso, aperçu avec les vraies +// données, contrôle avant génération) vit dans `./badges/`. export function BadgesPage() { const [tab, setTab] = useState<'customize' | 'generate'>('customize'); + const tabBar = ( + <> + {t('badges.admin')} + setTab('customize')}> + {t('badges.tab.customize')} + + setTab('generate')}> + {t('badges.tab.generate')} + + + ); + return (
-
- {t('badges.admin')} - setTab('customize')}> - {t('badges.tab.customize')} - - setTab('generate')}> - {t('badges.tab.generate')} - -
- - {tab === 'customize' ? : } -
- ); -} - -// --------------------------------------------------------------------------- // -// Personnalisation // -// --------------------------------------------------------------------------- // -function BadgeCustomizeTab() { - const template = useBadgeTemplate(); - const frontUrl = useBadgeImageUrl( - 'front', template.data?.has_front_image ?? false, template.dataUpdatedAt, - ); - const backUrl = useBadgeImageUrl( - 'back', template.data?.has_back_image ?? false, template.dataUpdatedAt, - ); - const uploadFront = useUploadBadgeFrontImage(); - const uploadBack = useUploadBadgeBackImage(); - const setFields = useSetBadgeFields(); - const setSize = useSetBadgeSize(); - - const [fields, setFieldsState] = useState | null>(null); - const [selected, setSelected] = useState(null); - - const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM; - const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM; - - useEffect(() => { - if (!template.data) return; - const loaded = fieldsFromServer(template.data.fields); - for (const type of FIELD_TYPES) loaded[type] = fitToBadge(loaded[type], badgeW, badgeH); - setFieldsState(loaded); - }, [template.data, badgeW, badgeH]); - const scale = useEditorScale(badgeW); - - if (template.isLoading || !fields) return ; - if (template.error) return ; - - const updateField = (type: BadgeFieldType, patch: Partial) => { - setFieldsState((prev) => - prev - ? { ...prev, [type]: fitToBadge({ ...prev[type], ...patch }, badgeW, badgeH) } - : prev, - ); - // Un champ qu'on vient d'activer passe au premier plan, sélectionné. - if (patch.enabled) setSelected(type); - }; - - const save = () => { - const payload: BadgeFieldIn[] = FIELD_TYPES.filter((type) => fields[type].enabled).map( - (type) => { - const f = fields[type]; - return { - field_type: f.field_type, - x_mm: f.x_mm, - y_mm: f.y_mm, - width_mm: f.width_mm, - height_mm: f.height_mm, - font_size: f.font_size, - align: f.align, - bold: f.bold, - color: f.color, - text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null, - }; - }, - ); - setFields.mutate({ fields: payload }); - }; - - return ( -
-
- - - {t('badges.front')} - - -
- uploadFront.mutate(file)} - /> - - setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm }) - } - /> -
setSelected(null)} - > - {FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => ( - setSelected(type)} - onChange={(patch) => updateField(type, patch)} - /> - ))} -
-

- {t('badges.editor.hint', { - width: Math.round(badgeW * 10) / 10, - height: Math.round(badgeH * 10) / 10, - })} -

-
-
- - - - {t('badges.back')} - - -
- uploadBack.mutate(file)} - /> -
-
- -
- - - {t('badges.fields')} -
- {FIELD_TYPES.map((type) => ( - setSelected(type)} - onChange={(patch) => updateField(type, patch)} - /> - ))} -
-
- - {setFields.isPending ? t('badges.saving') : t('badges.save')} - - {setFields.isSuccess ? ( - {t('badges.saved')} - ) : null} - {setFields.isError ? ( - - {errorMessage(setFields.error)} - - ) : null} -
-
-
- ); -} - -function ImageUploadRow({ - url, - pending, - error, - onPick, -}: { - url: string | null; - pending: boolean; - error: unknown; - onPick: (file: File) => void; -}) { - const inputRef = useRef(null); - return ( -
- inputRef.current?.click()} disabled={pending}> - {pending ? t('badges.uploading') : url ? t('badges.image.replace') : t('badges.image.choose')} - - { - const file = e.target.files?.[0]; - if (file) onPick(file); - e.target.value = ''; - }} - /> - {error ? ( - {errorMessage(error)} - ) : null} -
- ); -} - -/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de - * l'image importée). Sert à agrandir un badge devenu minuscule parce que - * l'image de fond n'était pas à une résolution d'impression (300 dpi - * supposés à l'import, voir `useBadgeTemplate`). */ -function BadgeSizeControl({ - badgeW, - badgeH, - pending, - onApply, -}: { - badgeW: number; - badgeH: number; - pending: boolean; - onApply: (width_mm: number, height_mm: number) => void; -}) { - const rounded = Math.round(badgeW * 10) / 10; - const [width, setWidth] = useState(String(rounded)); - - useEffect(() => setWidth(String(rounded)), [rounded]); - - const apply = () => { - const w = Number(width); - if (!Number.isFinite(w) || w <= 0) { - setWidth(String(rounded)); - return; - } - const ratio = badgeH / badgeW; - onApply(w, Math.round(w * ratio * 10) / 10); - }; - - return ( -
- {t('badges.size.width')} - setWidth(e.target.value)} - onBlur={apply} - onKeyDown={(e) => { - if (e.key === 'Enter') apply(); - }} - className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]" - /> - - {t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })} - -
- ); -} - -function FieldBox({ - field, - badgeW, - badgeH, - scale, - selected, - onSelect, - onChange, -}: { - field: FieldState; - badgeW: number; - badgeH: number; - scale: number; - selected: boolean; - onSelect: () => void; - onChange: (patch: Partial) => void; -}) { - const dragRef = useRef<{ startXMm: number; startYMm: number; pointerXPx: number; pointerYPx: number } | null>( - null, - ); - const resizeRef = useRef<{ startWMm: number; startHMm: number; pointerXPx: number; pointerYPx: number } | null>( - null, - ); - - const startDrag = (e: React.PointerEvent) => { - e.stopPropagation(); - onSelect(); - (e.target as Element).setPointerCapture(e.pointerId); - dragRef.current = { startXMm: field.x_mm, startYMm: field.y_mm, pointerXPx: e.clientX, pointerYPx: e.clientY }; - }; - - const onDragMove = (e: React.PointerEvent) => { - if (!dragRef.current) return; - const d = dragRef.current; - const dxMm = (e.clientX - d.pointerXPx) / scale; - const dyMm = (e.clientY - d.pointerYPx) / scale; - onChange({ - x_mm: clamp(d.startXMm + dxMm, 0, Math.max(0, badgeW - field.width_mm)), - y_mm: clamp(d.startYMm + dyMm, 0, Math.max(0, badgeH - field.height_mm)), - }); - }; - - const startResize = (e: React.PointerEvent) => { - e.stopPropagation(); - onSelect(); - (e.target as Element).setPointerCapture(e.pointerId); - resizeRef.current = { startWMm: field.width_mm, startHMm: field.height_mm, pointerXPx: e.clientX, pointerYPx: e.clientY }; - }; - - const onResizeMove = (e: React.PointerEvent) => { - if (!resizeRef.current) return; - const d = resizeRef.current; - const dwMm = (e.clientX - d.pointerXPx) / scale; - const dhMm = (e.clientY - d.pointerYPx) / scale; - const maxW = badgeW - field.x_mm; - const maxH = badgeH - field.y_mm; - onChange({ - width_mm: clamp(d.startWMm + dwMm, Math.min(10, maxW), maxW), - height_mm: clamp(d.startHMm + dhMm, Math.min(5, maxH), maxH), - }); - }; - - return ( -
(dragRef.current = null)} - className={cx( - '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, - // Le champ sélectionné passe devant les autres : sinon un champ - // recouvert (ex. la photo sous un texte) ne se saisit plus. - zIndex: selected ? 10 : undefined, - }} - > - {field.field_type === 'POSITIONS' ? ( - - ) : field.field_type === 'PHOTO' ? ( - - {t(FIELD_PLACEHOLDER_KEY.PHOTO)} - + {tab === 'customize' ? ( + ) : ( - px approx. à l'échelle du badge - fontWeight: field.bold ? 800 : 400, - color: field.color, - width: '100%', - textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left', - }} - > - {field.field_type === 'FREE_TEXT' && field.text?.trim() - ? field.text - : t(FIELD_PLACEHOLDER_KEY[field.field_type])} - - )} -
(resizeRef.current = null)} - 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" - /> -
- ); -} - -/** Aperçu du champ « postes » : le jour en en-tête, puis une ligne - * horaire / poste par créneau, comme dans le PDF - * (`backend/app/badges/pdf.py:_draw_table`), avec des lignes d'exemple. */ -function PositionsTablePreview({ field, scale }: { field: FieldState; scale: number }) { - const fontSize = field.font_size * (scale / 2.83); // pt -> px approx. à l'échelle du badge - const sections = [1, 2].map((n) => ({ - title: t(`badges.field.positions.sample${n}.day`), - rows: t(`badges.field.positions.sample${n}.rows`) - .split('\n') - .map((row) => row.split('|')), - })); - const cell = 'border px-[0.8mm] py-[0.5mm] text-left align-top'; - const border = { borderColor: field.color }; - return ( - - {sections.map((section) => ( - - - - - {section.rows.map(([time, position]) => ( - - - - - ))} - - ))} -
- {section.title} -
- {time} - - {position} -
- ); -} - -function FieldEditorRow({ - type, - field, - active, - onFocus, - onChange, -}: { - type: BadgeFieldType; - field: FieldState; - active: boolean; - onFocus: () => void; - onChange: (patch: Partial) => void; -}) { - return ( -
-
- - {FIELD_HELP_KEY[type] ? ( - - ) : null} -
- - {field.enabled && type === 'FREE_TEXT' ? ( - onChange({ text: e.target.value })} - placeholder={t('badges.field.freeText.input')} - className="ml-[24px] border-2 border-ink bg-cream px-[8px] py-[5px] font-display text-[12px] font-bold" - /> - ) : null} - - {/* La photo n'a pas de style de texte : seuls sa place et son cadre - (sur l'aperçu) comptent. */} - {field.enabled && type !== 'PHOTO' ? ( -
- - -
- {(['LEFT', 'CENTER', 'RIGHT'] as BadgeAlign[]).map((align) => ( - - ))} + <> +
+ {tabBar}
- - - - onChange({ color: e.target.value })} - className="h-[24px] w-[30px] cursor-pointer border-2 border-ink p-0" - /> -
- ) : null} + + + )}
); } @@ -734,6 +100,18 @@ function BadgeGenerateTab() {
+ {/* Un bénévole peut demander plusieurs exemplaires (fiche, + « Nombre de badges ») : le total imprimé peut dépasser le + nombre de bénévoles. */} + + {t('badges.generate.total', { + count: (mode === 'pick' ? sorted.filter((v) => picked.has(v.id)) : sorted).reduce( + (sum, v) => sum + v.badge_count, + 0, + ), + })} + + {mode === 'pick' ? (
{sorted.map((v) => ( @@ -748,6 +126,9 @@ function BadgeGenerateTab() { className="h-[14px] w-[14px] accent-ink" /> {v.first_name} {v.last_name} + {v.badge_count > 1 ? ( + × {v.badge_count} + ) : null} ))}
diff --git a/frontend/src/pages/badges/BadgeCanvas.tsx b/frontend/src/pages/badges/BadgeCanvas.tsx new file mode 100644 index 0000000..1aacacd --- /dev/null +++ b/frontend/src/pages/badges/BadgeCanvas.tsx @@ -0,0 +1,460 @@ +import { useRef, useState } from 'react'; + +import type { BadgeFace, VolunteerRead } from '@/api/schema'; +import { t } from '@/i18n'; +import { cx } from '@/lib/cx'; + +import { + CELL_PAD_X_MM, + CELL_PAD_Y_MM, + FIRST_BASELINE, + LINE_HEIGHT, + MM_PER_PT, + type TableLayout, + type TextLayout, +} from './layout'; +import { + FACE_LABEL_KEY, + fmt, + initials, + isVerticallyCentered, + renderField, + SAFE_MARGIN_MM, + type BadgeData, + type FieldState, +} from './model'; + +/** Fond d'une photo absente (initiales à la place), comme le PDF. */ +export const PHOTO_PLACEHOLDER_BG = '#E7E0CE'; +/** Pas d'accroche du glisser-déposer (mm). */ +const DRAG_SNAP_MM = 0.5; +/** Plus petit cadre possible au redimensionnement (mm). */ +const MIN_W_MM = 3; +const MIN_H_MM = 2; +/** Décalage entre la ligne de base d'une ligne CSS (interligne 1,25, police + * Helvetica/Arial) et celle du PDF (0,85 × taille sous le haut du cadre). */ +const CSS_BASELINE = 0.97; +const FONT_STACK = 'Helvetica, Arial, sans-serif'; + +function snap(value: number): number { + return Math.round(value / DRAG_SNAP_MM) * DRAG_SNAP_MM; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +export interface BadgeCanvasProps { + face: BadgeFace; + fields: FieldState[]; + data: BadgeData; + volunteer: VolunteerRead | null; + photoUrl: string | null; + imageUrl: string | null; + badgeW: number; + badgeH: number; + scale: number; + showGuides: boolean; + selectedType: string | null; + uploading: boolean; + onSelect: (field: FieldState) => void; + /** `record` : enregistrer l'état actuel dans l'historique d'annulation + * avant d'appliquer `patch` (une seule fois par glisser-déposer). */ + onChange: (field: FieldState, patch: Partial, record: boolean) => void; + onDropImage: (file: File) => void; +} + +/** Aperçu d'une face du badge, éditable (glisser, redimensionner) et zone de + * dépôt de son image de fond. */ +export function BadgeCanvas({ + face, + fields, + data, + volunteer, + photoUrl, + imageUrl, + badgeW, + badgeH, + scale, + showGuides, + selectedType, + uploading, + onSelect, + onChange, + onDropImage, +}: BadgeCanvasProps) { + const [dropping, setDropping] = useState(false); + const shown = fields.filter((f) => f.enabled); + const isFaceSelected = shown.some((f) => f.field_type === selectedType); + + return ( +
+
+ + {t(FACE_LABEL_KEY[face])} + + + {t('badges.dims', { width: fmt(badgeW), height: fmt(badgeH) })} + +
+
{ + if (!e.dataTransfer.types.includes('Files')) return; + e.preventDefault(); + setDropping(true); + }} + onDragLeave={() => setDropping(false)} + onDrop={(e) => { + e.preventDefault(); + setDropping(false); + const file = e.dataTransfer.files[0]; + if (file) onDropImage(file); + }} + > + {/* Invitation à déposer une image : seulement sur une face encore + vide (elle passerait sous les champs sinon), ou par-dessus tout + pendant un dépôt / un envoi. */} + {uploading || dropping || (!imageUrl && shown.length === 0) ? ( +
+ {uploading + ? t('badges.uploading') + : t(face === 'FRONT' ? 'badges.drop.front' : 'badges.drop.back')} +
+ ) : null} + {showGuides ? ( +
+ ) : null} + {shown.map((field) => ( + onSelect(field)} + onChange={(patch, record) => onChange(field, patch, record)} + /> + ))} +
+
+ ); +} + +function FieldBox({ + field, + data, + volunteer, + photoUrl, + badgeW, + badgeH, + scale, + showGuides, + selected, + onSelect, + onChange, +}: { + field: FieldState; + data: BadgeData; + volunteer: VolunteerRead | null; + photoUrl: string | null; + badgeW: number; + badgeH: number; + scale: number; + showGuides: boolean; + selected: boolean; + onSelect: () => void; + onChange: (patch: Partial, record: boolean) => void; +}) { + const dragRef = useRef<{ + mode: 'move' | 'resize'; + x: number; + y: number; + w: number; + h: number; + px: number; + py: number; + moved: boolean; + } | null>(null); + + const start = (mode: 'move' | 'resize') => (e: React.PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + onSelect(); + (e.currentTarget as Element).setPointerCapture(e.pointerId); + dragRef.current = { + mode, + x: field.x_mm, + y: field.y_mm, + w: field.width_mm, + h: field.height_mm, + px: e.clientX, + py: e.clientY, + moved: false, + }; + }; + + const move = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + e.stopPropagation(); + const dx = (e.clientX - d.px) / scale; + const dy = (e.clientY - d.py) / scale; + if (!d.moved && Math.abs(dx) < 0.2 && Math.abs(dy) < 0.2) return; + const record = !d.moved; + d.moved = true; + if (d.mode === 'move') { + // Bornes jamais négatives : un champ plus grand que le badge reste + // saisissable (voir `fitToBadge`). + onChange( + { + x_mm: clamp(snap(d.x + dx), 0, Math.max(0, badgeW - d.w)), + y_mm: clamp(snap(d.y + dy), 0, Math.max(0, badgeH - d.h)), + }, + record, + ); + } else { + const maxW = Math.max(0, badgeW - d.x); + const maxH = Math.max(0, badgeH - d.y); + onChange( + { + width_mm: clamp(snap(d.w + dx), Math.min(MIN_W_MM, maxW), maxW), + height_mm: clamp(snap(d.h + dy), Math.min(MIN_H_MM, maxH), maxH), + }, + record, + ); + } + }; + + const end = (e: React.PointerEvent) => { + if (dragRef.current) e.stopPropagation(); + dragRef.current = null; + }; + + const rendered = volunteer ? renderField(field, volunteer, data) : null; + const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null; + const truncated = layout?.truncated ?? false; + + return ( +
+ {rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? ( + + ) : rendered?.kind === 'table' ? ( + + ) : rendered?.kind === 'text' ? ( + + ) : null} + + {truncated ? ( + + {t('badges.preview.truncated')} + + ) : null} + {selected ? ( + <> + + {t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })} + {layout?.reduced + ? t('badges.preview.reducedTo', { size: fmt(layout.fontSize) }) + : null} + + + + ) : null} +
+ ); +} + +function TextContent({ field, layout, scale }: { field: FieldState; layout: TextLayout; scale: number }) { + const fontPx = layout.fontSize * MM_PER_PT * scale; + const lineHeightPx = fontPx * LINE_HEIGHT; + // Bandeaux (texte libre, rôle) centrés verticalement, les autres en haut — + // même calcul que le PDF : bloc = interlignes + une hauteur de caractère. + const blockPx = Math.max(0, layout.visible.length - 1) * lineHeightPx + fontPx; + const boxPx = field.height_mm * scale; + const offset = isVerticallyCentered(field.field_type) ? Math.max(0, (boxPx - blockPx) / 2) : 0; + const top = offset + (FIRST_BASELINE - CSS_BASELINE) * fontPx; + return ( +
+
+ {layout.visible.map((line, i) => ( +
+ {line || ' '} +
+ ))} +
+
+ ); +} + +/** Tableau des créneaux, mêmes colonnes, hauteurs de ligne et lignes + * retenues que le PDF (`_draw_table`). */ +function TableContent({ field, layout, scale }: { field: FieldState; layout: TableLayout; scale: number }) { + const fontPx = layout.fontSize * MM_PER_PT * scale; + const lineHeightPx = fontPx * LINE_HEIGHT; + const rule = `${Math.max(0.5, 0.4 * MM_PER_PT * scale)}px solid ${field.color}`; + const padX = CELL_PAD_X_MM * scale; + const padY = CELL_PAD_Y_MM * scale; + const fullWidth = layout.widthsMm.reduce((a, b) => a + b, 0) * scale; + return ( +
+ {layout.rows.length ? ( +
+ {layout.rows.map((row, i) => ( +
0 ? rule : undefined, + fontWeight: row.isTitle ? 700 : 400, + }} + > + {row.cells.map((lines, col) => ( +
0 ? rule : undefined, + boxSizing: 'border-box', + }} + > + {lines.map((line, j) => ( +
+ {line || ' '} +
+ ))} +
+ ))} +
+ ))} +
+ ) : null} +
+ ); +} + +function PhotoContent({ + field, + volunteer, + photoUrl, + scale, +}: { + field: FieldState; + volunteer: VolunteerRead | null; + photoUrl: string | null; + scale: number; +}) { + const radius = field.photo_shape === 'OVAL' ? '50%' : 0; + if (volunteer?.has_photo && photoUrl) { + return ( + + ); + } + return ( +
+ {volunteer ? initials(volunteer) : null} +
+ ); +} diff --git a/frontend/src/pages/badges/BadgeCustomizeTab.tsx b/frontend/src/pages/badges/BadgeCustomizeTab.tsx new file mode 100644 index 0000000..cdbb7ca --- /dev/null +++ b/frontend/src/pages/badges/BadgeCustomizeTab.tsx @@ -0,0 +1,1049 @@ +import { + useDeferredValue, + useEffect, + useMemo, + useReducer, + useRef, + useState, + type ReactNode, +} from 'react'; + +import { + useAccounts, + useAssignments, + useBadgeImageUrl, + useBadgeTemplate, + useDayParts, + usePositions, + useSetBadgeFields, + useSetBadgeSize, + useShifts, + useUploadBadgeBackImage, + useUploadBadgeFrontImage, + useVolunteerPhotoUrl, + useVolunteers, +} from '@/api/hooks'; +import type { + BadgeFace, + BadgeTemplateRead, + PositionRead, + ShiftRead, + VolunteerRead, +} from '@/api/schema'; +import { ApiError } from '@/api'; +import { ErrorNote, Loading } from '@/components/state'; +import { Btn, FieldLabel } from '@/components/ui'; +import { t } from '@/i18n'; +import { cx } from '@/lib/cx'; +import { dayBoundaryFromDayParts } from '@/lib/planning'; + +import { BadgeCanvas } from './BadgeCanvas'; +import { BadgeInspector } from './BadgeInspector'; +import { computeIssues, statsKey, type BadgeIssue, type FieldStats } from './issues'; +import { stringWidthPt } from './helveticaMetrics'; +import { + BADGE_PRESETS, + DEFAULT_BADGE_H_MM, + DEFAULT_BADGE_W_MM, + FACE_LABEL_KEY, + FACES, + FIELD_LABEL_KEY, + fieldsFromServer, + fitToBadge, + fmt, + renderField, + sameRef, + toPayload, + type BadgeData, + type FieldRef, + type FieldState, +} from './model'; + +const HISTORY_LIMIT = 40; +const ZOOM_MIN = 0.5; +const ZOOM_MAX = 2; +const ZOOM_STEP = 0.25; +/** Hauteur / largeur maximales d'un aperçu à 100 % (px). */ +const PREVIEW_MAX_H_PX = 430; +const PREVIEW_MAX_W_PX = 360; +const KEY_STEP_MM = 0.5; +const KEY_STEP_FAST_MM = 2; + +function errorMessage(error: unknown): string { + if (error instanceof ApiError) return t(error.code); + return t('error.generic'); +} + +function plural(key: string, count: number, params: Record = {}): string { + return t(`${key}.${count > 1 ? 'other' : 'one'}`, { count, ...params }); +} + +// --------------------------------------------------------------------------- // +// État : champs + historique d'annulation // +// --------------------------------------------------------------------------- // +interface EditorState { + fields: FieldState[]; + history: FieldState[][]; +} + +type EditorAction = + | { + type: 'patch'; + ref: FieldRef; + patch: Partial; + record: boolean; + badgeW: number; + badgeH: number; + } + | { type: 'fit'; badgeW: number; badgeH: number } + | { type: 'undo' }; + +function editorReducer(state: EditorState, action: EditorAction): EditorState { + switch (action.type) { + case 'patch': + return { + history: action.record + ? [...state.history, state.fields].slice(-HISTORY_LIMIT) + : state.history, + fields: state.fields.map((f) => + sameRef({ face: f.face, type: f.field_type }, action.ref) + ? fitToBadge({ ...f, ...action.patch }, action.badgeW, action.badgeH) + : f, + ), + }; + case 'fit': + return { + ...state, + fields: state.fields.map((f) => fitToBadge(f, action.badgeW, action.badgeH)), + }; + case 'undo': + if (!state.history.length) return state; + return { + fields: state.history[state.history.length - 1], + history: state.history.slice(0, -1), + }; + } +} + +// --------------------------------------------------------------------------- // +// Chargement des données // +// --------------------------------------------------------------------------- // +/** Onglet « Personnaliser » : charge le modèle de badge et les données + * réelles (bénévoles, affectations…) qui alimentent l'aperçu et le contrôle. */ +export function BadgeCustomizeTab({ tabBar }: { tabBar: ReactNode }) { + const template = useBadgeTemplate(); + const volunteers = useVolunteers(); + const positions = usePositions(); + const shifts = useShifts(); + const assignments = useAssignments(); + const accounts = useAccounts(); + const dayParts = useDayParts(); + + const data = useMemo(() => { + if (!volunteers.data || !positions.data || !shifts.data || !assignments.data) return null; + const positionById = new Map(positions.data.map((p) => [p.id, p])); + const shiftById = new Map(shifts.data.map((s) => [s.id, s])); + const byVolunteer = new Map>(); + for (const a of assignments.data) { + const shift = shiftById.get(a.shift_id); + const position = shift ? positionById.get(shift.position_id) : undefined; + if (!shift || !position) continue; + const rows = byVolunteer.get(a.volunteer_id) ?? []; + rows.push({ position, shift }); + byVolunteer.set(a.volunteer_id, rows); + } + for (const rows of byVolunteer.values()) { + rows.sort((a, b) => a.shift.starts_at.localeCompare(b.shift.starts_at)); + } + // Ordre d'impression du PDF : nom, puis prénom. + const sorted = [...volunteers.data].sort( + (a, b) => + a.last_name.localeCompare(b.last_name, 'fr') || + a.first_name.localeCompare(b.first_name, 'fr'), + ); + return { + volunteers: sorted, + assignments: byVolunteer, + leadVolunteerIds: new Set( + (accounts.data ?? []) + .filter((acc) => acc.role === 'LEAD' && acc.volunteer_id) + .map((acc) => acc.volunteer_id as string), + ), + dayBoundary: dayBoundaryFromDayParts(dayParts.data ?? []), + }; + }, [volunteers.data, positions.data, shifts.data, assignments.data, accounts.data, dayParts.data]); + + const error = template.error ?? volunteers.error ?? positions.error ?? shifts.error ?? assignments.error; + return ( +
+ {error ? ( + <> + + + + ) : !template.data || !data ? ( + <> + + + + ) : ( + + )} +
+ ); +} + +function Toolbar({ tabBar, children }: { tabBar: ReactNode; children?: ReactNode }) { + return ( +
+ {tabBar} + {children ?
{children}
: null} +
+ ); +} + +// --------------------------------------------------------------------------- // +// Éditeur // +// --------------------------------------------------------------------------- // +function BadgeEditor({ + tabBar, + template, + templateUpdatedAt, + data, +}: { + tabBar: ReactNode; + template: BadgeTemplateRead; + templateUpdatedAt: number; + data: BadgeData; +}) { + const badgeW = template.badge_width_mm || DEFAULT_BADGE_W_MM; + const badgeH = template.badge_height_mm || DEFAULT_BADGE_H_MM; + + const frontUrl = useBadgeImageUrl('front', template.has_front_image, templateUpdatedAt); + const backUrl = useBadgeImageUrl('back', template.has_back_image, templateUpdatedAt); + const uploadFront = useUploadBadgeFrontImage(); + const uploadBack = useUploadBadgeBackImage(); + const setFieldsMutation = useSetBadgeFields(); + const setSize = useSetBadgeSize(); + + // Initialisé une seule fois depuis le serveur : un rechargement du modèle + // (nouvelle image, nouveau format, enregistrement) ne doit jamais écraser + // les modifications en cours. + const [state, dispatch] = useReducer(editorReducer, undefined, () => ({ + fields: fieldsFromServer(template.fields, badgeW, badgeH), + history: [], + })); + const { fields } = state; + const [savedPayload, setSavedPayload] = useState(() => + JSON.stringify(toPayload(fieldsFromServer(template.fields, badgeW, badgeH))), + ); + const [savedAt, setSavedAt] = useState(null); + const [selected, setSelected] = useState(null); + const [volIndex, setVolIndex] = useState(0); + const [zoom, setZoom] = useState(1); + const [guides, setGuides] = useState(true); + + // Nouveau format (préréglage, largeur, image du recto) : les champs sont + // ramenés à l'intérieur du badge. + useEffect(() => { + dispatch({ type: 'fit', badgeW, badgeH }); + }, [badgeW, badgeH]); + + const payload = useMemo(() => JSON.stringify(toPayload(fields)), [fields]); + const dirty = payload !== savedPayload; + + const patch = (ref: FieldRef, change: Partial, record = true) => + dispatch({ type: 'patch', ref, patch: change, record, badgeW, badgeH }); + + const select = (ref: FieldRef) => { + const field = fields.find((f) => sameRef({ face: f.face, type: f.field_type }, ref)); + if (field && !field.enabled) patch(ref, { enabled: true }); + setSelected(ref); + }; + + const save = () => { + const body = toPayload(fields); + setFieldsMutation.mutate( + { fields: body }, + { + onSuccess: () => { + setSavedPayload(JSON.stringify(body)); + const now = new Date(); + setSavedAt( + `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`, + ); + }, + }, + ); + }; + + // Bénévole affiché dans l'aperçu (exemple fictif si la liste est vide). + const sample = useMemo( + () => + ({ + id: '', + first_name: t('badges.sample.firstName'), + last_name: t('badges.sample.lastName'), + has_photo: false, + badge_text: null, + badge_count: 1, + }) as VolunteerRead, + [], + ); + const total = data.volunteers.length; + const index = total ? Math.min(volIndex, total - 1) : 0; + const volunteer = total ? data.volunteers[index] : sample; + const photoUrl = useVolunteerPhotoUrl( + volunteer.id || null, + volunteer.has_photo, + volunteer.updated_at, + ); + + const longestIndex = useMemo(() => { + let best = 0; + let bestWidth = -1; + data.volunteers.forEach((v, i) => { + const w = Math.max( + stringWidthPt(v.first_name, true, 10), + stringWidthPt(v.last_name, true, 10), + stringWidthPt(`${v.first_name} ${v.last_name}`, true, 10) / 2, + ); + if (w > bestWidth) { + best = i; + bestWidth = w; + } + }); + return best; + }, [data.volunteers]); + + // Contrôle sur tous les bénévoles : calcul différé pour ne pas ralentir + // le glisser-déposer. + const deferredFields = useDeferredValue(fields); + const { issues, stats } = useMemo( + () => computeIssues(deferredFields, data, badgeW, badgeH), + [deferredFields, data, badgeW, badgeH], + ); + + // Échelle d'affichage : l'aperçu tient dans la colonne centrale. + const centerRef = useRef(null); + const [centerWidth, setCenterWidth] = useState(Number.POSITIVE_INFINITY); + useEffect(() => { + const el = centerRef.current; + if (!el || typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(([entry]) => setCenterWidth(entry.contentRect.width)); + observer.observe(el); + return () => observer.disconnect(); + }, []); + const baseScale = Math.max( + 1, + Math.min(PREVIEW_MAX_H_PX / badgeH, PREVIEW_MAX_W_PX / badgeW, (centerWidth - 24) / badgeW), + ); + const scale = baseScale * zoom; + + // Clavier : flèches (déplacer), Échap (désélectionner), Ctrl/Cmd + Z. + const keyHandler = useRef<(e: KeyboardEvent) => void>(() => {}); + useEffect(() => { + keyHandler.current = (e: KeyboardEvent) => { + const target = e.target as HTMLElement | null; + const tag = target?.tagName ?? ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) { + return; + } + if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === 'z') { + e.preventDefault(); + dispatch({ type: 'undo' }); + return; + } + if (!selected) return; + if (e.key === 'Escape') { + setSelected(null); + return; + } + const dir = ( + { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] } as Record< + string, + [number, number] | undefined + > + )[e.key]; + if (!dir) return; + const field = fields.find((f) => sameRef({ face: f.face, type: f.field_type }, selected)); + if (!field?.enabled) return; + e.preventDefault(); + const step = e.shiftKey ? KEY_STEP_FAST_MM : KEY_STEP_MM; + patch(selected, { + x_mm: Math.max(0, Math.min(badgeW - field.width_mm, field.x_mm + dir[0] * step)), + y_mm: Math.max(0, Math.min(badgeH - field.height_mm, field.y_mm + dir[1] * step)), + }); + }; + }); + useEffect(() => { + const onKey = (e: KeyboardEvent) => keyHandler.current(e); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, []); + + const selectedField = selected + ? fields.find((f) => f.enabled && sameRef({ face: f.face, type: f.field_type }, selected)) ?? null + : null; + const selectedRender = selectedField ? renderField(selectedField, volunteer, data) : null; + const uploads: Record = { FRONT: uploadFront, BACK: uploadBack }; + + const showExample = (issue: BadgeIssue) => { + if (issue.exampleIndex !== null) setVolIndex(issue.exampleIndex); + setSelected(issue.ref); + }; + + return ( + <> + + + {dirty ? ( + + + {t('badges.dirty')} + + ) : ( + + {savedAt ? t('badges.savedAt', { time: savedAt }) : t('badges.clean')} + + )} + {setFieldsMutation.isError ? ( + + {errorMessage(setFieldsMutation.error)} + + ) : null} + + {setFieldsMutation.isPending ? t('badges.saving') : t('badges.save')} + + + +
+ {/* Colonne gauche : format + liste des champs */} + + + {/* Colonne centrale : aperçu recto/verso + contrôle */} +
+ setVolIndex((index - 1 + total) % Math.max(1, total))} + onNext={() => setVolIndex((index + 1) % Math.max(1, total))} + onLongest={() => setVolIndex(longestIndex)} + onToggleGuides={() => setGuides((g) => !g)} + onZoom={(delta) => + setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round((z + delta) * 100) / 100))) + } + /> + +
setSelected(null)} + > + {FACES.map((face) => { + const upload = uploads[face]; + return ( +
+ f.face === face)} + data={data} + volunteer={volunteer} + photoUrl={photoUrl} + imageUrl={face === 'FRONT' ? frontUrl : backUrl} + badgeW={badgeW} + badgeH={badgeH} + scale={scale} + showGuides={guides} + selectedType={selected?.face === face ? selected.type : null} + uploading={upload.isPending} + onSelect={(f) => setSelected({ face, type: f.field_type })} + onChange={(f, change, record) => patch({ face, type: f.field_type }, change, record)} + onDropImage={(file) => upload.mutate(file)} + /> + upload.mutate(file)} + /> +
+ ); + })} +
+ + patch(issue.ref, { auto_fit: true })} + onShow={showExample} + /> +
+ + {/* Colonne droite : inspecteur */} + +
+ + ); +} + +// --------------------------------------------------------------------------- // +// Colonne gauche // +// --------------------------------------------------------------------------- // +function ColumnHeader({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function FormatSection({ + badgeW, + badgeH, + pending, + error, + onApply, +}: { + badgeW: number; + badgeH: number; + pending: boolean; + error: unknown; + onApply: (width: number, height: number) => void; +}) { + const matches = (w: number, h: number) => + Math.abs(w - badgeW) < 0.05 && Math.abs(h - badgeH) < 0.05; + const known = BADGE_PRESETS.some((p) => matches(p.width, p.height)); + return ( +
+ {t('badges.format.title')} +
+ {!known ? ( +
+ + + + {t('badges.format.current')} + + + {t('badges.dims', { width: fmt(badgeW), height: fmt(badgeH) })} + + +
+ ) : null} + {BADGE_PRESETS.map((preset) => { + const active = matches(preset.width, preset.height); + return ( + + ); + })} + + {error ? ( + {errorMessage(error)} + ) : null} +
+
+ ); +} + +/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de + * l'image importée). Sert à agrandir un badge devenu minuscule parce que + * l'image de fond n'était pas à une résolution d'impression (300 dpi + * supposés à l'import, voir `useBadgeTemplate`). */ +function BadgeSizeControl({ + badgeW, + badgeH, + pending, + onApply, +}: { + badgeW: number; + badgeH: number; + pending: boolean; + onApply: (width_mm: number, height_mm: number) => void; +}) { + const rounded = Math.round(badgeW * 10) / 10; + const [width, setWidth] = useState(String(rounded)); + + useEffect(() => setWidth(String(rounded)), [rounded]); + + const apply = () => { + const w = Number(width.replace(',', '.')); + if (!Number.isFinite(w) || w <= 0) { + setWidth(String(rounded)); + return; + } + if (Math.abs(w - badgeW) < 0.05) return; + const ratio = badgeH / badgeW; + onApply(w, Math.round(w * ratio * 10) / 10); + }; + + return ( +
+ {t('badges.size.width')} +
+ setWidth(e.target.value)} + onBlur={apply} + onKeyDown={(e) => { + if (e.key === 'Enter') apply(); + }} + className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]" + /> + + {t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })} + +
+
+ ); +} + +function FieldList({ + face, + fields, + stats, + selected, + onToggle, + onSelect, +}: { + face: BadgeFace; + fields: FieldState[]; + stats: Map; + selected: FieldRef | null; + onToggle: (field: FieldState) => void; + onSelect: (field: FieldState) => void; +}) { + const shown = fields.filter((f) => f.enabled).length; + return ( +
+ + {t('badges.fieldsOf', { face: t(FACE_LABEL_KEY[face]) })} + + {t('badges.fieldsShown', { count: shown })} + + + {fields.map((field) => { + const isSelected = sameRef(selected, { face, type: field.field_type }); + const s = stats.get(statsKey(face, field.field_type)); + const issueCount = field.enabled && s ? s.truncated || s.reduced : 0; + const meta = !field.enabled + ? t('badges.layer.hidden') + : field.field_type === 'PHOTO' + ? t(field.photo_shape === 'OVAL' ? 'badges.layer.oval' : 'badges.layer.rect') + : t('badges.layer.meta', { + size: fmt(field.font_size), + weight: t(field.bold ? 'badges.layer.bold' : 'badges.layer.regular'), + }); + return ( +
+ + +
+ ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- // +// Colonne centrale // +// --------------------------------------------------------------------------- // +function PreviewToolbar({ + volunteer, + index, + total, + isLead, + zoom, + guides, + onPrev, + onNext, + onLongest, + onToggleGuides, + onZoom, +}: { + volunteer: VolunteerRead; + index: number; + total: number; + isLead: boolean; + zoom: number; + guides: boolean; + onPrev: () => void; + onNext: () => void; + onLongest: () => void; + onToggleGuides: () => void; + onZoom: (delta: number) => void; +}) { + const stepBtn = + 'w-8 flex-none cursor-pointer border-0 bg-cream font-display text-[14px] font-black hover:bg-yellow disabled:cursor-not-allowed disabled:opacity-40'; + return ( +
+ {t('badges.preview.with')} +
+ +
+
+ {volunteer.first_name} {volunteer.last_name} +
+
+ {total + ? t('badges.preview.counter', { + index: index + 1, + total, + role: t(isLead ? 'role.lead' : 'role.volunteer'), + }) + : t('badges.preview.noVolunteer')} +
+
+ +
+ + {t('badges.preview.longest')} + +
+ +
+ + + {t('badges.preview.zoom', { percent: Math.round(zoom * 100) })} + + +
+
+
+ ); +} + +function ImagePicker({ + hasImage, + pending, + error, + onPick, +}: { + hasImage: boolean; + pending: boolean; + error: unknown; + onPick: (file: File) => void; +}) { + const inputRef = useRef(null); + return ( +
+ inputRef.current?.click()} disabled={pending}> + {pending ? t('badges.uploading') : hasImage ? t('badges.image.replace') : t('badges.image.choose')} + + { + const file = e.target.files?.[0]; + if (file) onPick(file); + e.target.value = ''; + }} + /> + {error ? ( + {errorMessage(error)} + ) : null} +
+ ); +} + +function IssuesPanel({ + issues, + total, + onEnableAutoFit, + onShow, +}: { + issues: BadgeIssue[]; + total: number; + onEnableAutoFit: (issue: BadgeIssue) => void; + onShow: (issue: BadgeIssue) => void; +}) { + const describe = (issue: BadgeIssue): string => { + const where = t('badges.issue.where', { + field: t(FIELD_LABEL_KEY[issue.ref.type]), + face: t(`${FACE_LABEL_KEY[issue.ref.face]}.short`), + }); + switch (issue.kind) { + case 'truncated': + return plural('badges.issue.truncated', issue.count, { where }); + case 'reduced': + return plural('badges.issue.reduced', issue.count, { where, size: fmt(issue.minPt) }); + case 'margin': + return t('badges.issue.margin', { where }); + case 'contrast': + return t('badges.issue.contrast', { where }); + case 'noPhoto': + return plural('badges.issue.noPhoto', issue.count); + } + }; + + return ( +
+
+
+ + {t('badges.check.title', { count: total })} + + {t('badges.check.subtitle')} +
+ {issues.map((issue, i) => ( +
+ {issue.critical ? ( + + {t('badges.check.critical')} + + ) : ( + + {t('badges.check.warning')} + + )} + + {describe(issue)} + + {issue.canEnableAutoFit ? ( + + ) : null} + {issue.exampleIndex !== null ? ( + + ) : null} +
+ ))} + {issues.length === 0 ? ( +
+ + ✓ + + + {t('badges.check.ok', { count: total })} + +
+ ) : null} +

{t('badges.check.estimate')}

+
+
+ ); +} diff --git a/frontend/src/pages/badges/BadgeInspector.tsx b/frontend/src/pages/badges/BadgeInspector.tsx new file mode 100644 index 0000000..f2811a3 --- /dev/null +++ b/frontend/src/pages/badges/BadgeInspector.tsx @@ -0,0 +1,413 @@ +import { useEffect, useState, type ReactNode } from 'react'; + +import type { BadgeAlign } from '@/api/schema'; +import { t } from '@/i18n'; +import { cx } from '@/lib/cx'; + +import { contrastRatio, FONT_STEP_PT, MAX_FONT_PT, MIN_CONTRAST, MIN_FONT_PT, MM_PER_PT } from './layout'; +import { FACE_LABEL_KEY, FIELD_LABEL_KEY, fitToBadge, fmt, round1, type FieldState } from './model'; + +const TEXT_COLORS: Array<{ hex: string; key: string }> = [ + { hex: '#FFFFFF', key: 'badges.color.white' }, + { hex: '#14110E', key: 'badges.color.black' }, + { hex: '#FFD23F', key: 'badges.color.yellow' }, + { hex: '#FF5C39', key: 'badges.color.coral' }, +]; + +const BG_COLORS: Array<{ hex: string | null; key: string }> = [ + { hex: null, key: 'badges.color.none' }, + { hex: '#FFFFFF', key: 'badges.color.white' }, + { hex: '#14110E', key: 'badges.color.black' }, + { hex: '#FFD23F', key: 'badges.color.yellow' }, +]; + +const ALIGNS: Array<{ value: BadgeAlign; key: string; items: string }> = [ + { value: 'LEFT', key: 'badges.align.left', items: 'items-start' }, + { value: 'CENTER', key: 'badges.align.center', items: 'items-center' }, + { value: 'RIGHT', key: 'badges.align.right', items: 'items-end' }, +]; + +function sameColor(a: string | null, b: string | null): boolean { + return (a ?? '').toLowerCase() === (b ?? '').toLowerCase(); +} + +function Section({ title, aside, children }: { title: string; aside?: ReactNode; children: ReactNode }) { + return ( +
+
+ {title} + {aside ? {aside} : null} +
+ {children} +
+ ); +} + +function Segment({ + active, + onClick, + children, + className, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; + className?: string; +}) { + return ( + + ); +} + +function Swatch({ + hex, + label, + active, + onClick, +}: { + hex: string | null; + label: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +/** Saisie d'une coordonnée en mm, appliquée à la validation (sortie du + * champ ou Entrée) pour ne pas bousculer la saisie en cours. */ +function MmInput({ label, value, onCommit }: { label: string; value: number; onCommit: (v: number) => void }) { + const [text, setText] = useState(fmt(value)); + useEffect(() => setText(fmt(value)), [value]); + const commit = () => { + const n = Number(text.replace(',', '.')); + if (Number.isFinite(n) && round1(n) !== round1(value)) onCommit(Math.max(0, n)); + else setText(fmt(value)); + }; + return ( + + ); +} + +export function BadgeInspector({ + field, + badgeW, + badgeH, + effectiveFontSize, + photoCount, + volunteerCount, + onChange, + onHide, +}: { + field: FieldState; + badgeW: number; + badgeH: number; + /** Taille réellement utilisée sur le badge affiché (réduction auto). */ + effectiveFontSize: number | null; + photoCount: number; + volunteerCount: number; + onChange: (patch: Partial) => void; + onHide: () => void; +}) { + const isPhoto = field.field_type === 'PHOTO'; + const lowContrast = Boolean(field.bg_color) && contrastRatio(field.color, field.bg_color as string) < MIN_CONTRAST; + const reduced = effectiveFontSize !== null && effectiveFontSize < field.font_size; + const setGeometry = (patch: Partial) => + onChange(fitToBadge({ ...field, ...patch }, badgeW, badgeH)); + const fontSize = (delta: number) => + onChange({ + font_size: Math.min(MAX_FONT_PT, Math.max(MIN_FONT_PT, round1(field.font_size + delta))), + }); + + return ( +
+
+
+ {t('badges.inspector.kicker', { face: t(FACE_LABEL_KEY[field.face]) })} +
+
+ {t(FIELD_LABEL_KEY[field.field_type])} +
+
+ +
+ {field.field_type === 'FREE_TEXT' ? ( +