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' ? ( +