Refonte du créateur de badges et nombre de badges par bénévole
ci-cd / build-backend (push) Successful in 30s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 22s

Créateur de badges refait d'après la maquette : champs sur le recto et le
verso (BadgeField.face, verso placé en miroir dans le PDF pour le
recto-verso), fond de zone, réduction automatique du texte, photo ovale,
initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en
trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles,
inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de
découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant
génération.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 19:20:42 +01:00
co-authored by Claude Opus 5.5
parent 1a23881e81
commit ced0855d47
28 changed files with 3738 additions and 803 deletions
+44 -16
View File
@@ -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 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 (champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la
fiche (« Texte du badge ») — jamais par le formulaire public — et qui 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`, - **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La lendemain). Unique (`edition_id`, `name`, `performance_date`). La
@@ -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 × et la génération retombant alors sur le format CR80 par défaut (85,6 ×
54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans 54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans
déformation, `preserveAspectRatio=True`) à ce même format. déformation, `preserveAspectRatio=True`) à ce même format.
- **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈ - **`BadgeField`** : un champ positionné sur le **recto ou le verso**
`FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`, (`face` ∈ `FRONT` / `BACK` ; `field_type` ∈ `FULL_NAME` / `POSITIONS` /
au plus un par édition — `CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`, au plus un par face et par
`PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/ édition — unique (`edition_id`, `face`, `field_type`), 422
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge, `badge.duplicate_field_type` sinon ; `PUT .../badges/template/fields`
`font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer) remplace tout, les deux faces dans la même liste), coordonnées `x_mm`/
depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`), `y_mm`/`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du
qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel badge, `font_size`, `align`, `bold`, `color`, `bg_color` (fond plein de la
du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`). 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`, `POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`,
dessiné par `app/badges/pdf.py:_draw_table`) des créneaux affectés, 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 **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 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 à 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 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 photo illisible, les **initiales** du bénévole sont imprimées à la
planche. Le texte d'un champ passe à la ligne pour tenir dans la largeur 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 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 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 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 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 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 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 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 glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo
impossible à déplacer ni redimensionner). impossible à déplacer ni redimensionner).
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` — `POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` —
absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est absent = tous ; chaque bénévole y figure `badge_count` fois, à la suite)
génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
par planche, de la page de verso correspondante (même image répétée à par planche, de la page de verso correspondante (image de verso puis
chaque emplacement) — impression recto-verso classique. champs `BACK` de chaque bénévole) — impression recto-verso classique
(retournement bord long) : sur la page de verso, chaque badge est placé
**en miroir horizontal** (colonne `cols-1-col`, `_cell_origin(mirrored=
True)`) pour tomber derrière son recto. Indispensable depuis que le verso
est personnalisé ; avant, un verso identique pour tous masquait le
décalage.
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) : - **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/ aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
`Volunteer` existants. Complément des badges (un badge identifie un `Volunteer` existants. Complément des badges (un badge identifie un
@@ -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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+60 -36
View File
@@ -1,15 +1,16 @@
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION) """Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
et génération du PDF. et génération du PDF.
Le verso est une image fixe, la même pour tous les bénévoles. Le recto Chaque face (recto, verso) combine une image de fond (la même pour tous les
combine une image de fond et des champs positionnés (nom, poste(s) affectés, bénévoles) et des champs positionnés (nom, poste(s) affectés, catégorie,
catégorie, rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` pour la
pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`, composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
`BadgeField`) pour le modèle. `BadgeField`) pour le modèle.
""" """
import datetime as dt import datetime as dt
import io import io
import re
import uuid import uuid
from collections import defaultdict from collections import defaultdict
@@ -36,12 +37,14 @@ from app.badges.pdf import (
BadgeTableSection, BadgeTableSection,
generate_badge_sheets_pdf, generate_badge_sheets_pdf,
image_size_mm, image_size_mm,
initials,
) )
from app.db import get_session from app.db import get_session
from app.i18n import t from app.i18n import t
from app.models import ( from app.models import (
Account, Account,
Assignment, Assignment,
BadgeFace,
BadgeField, BadgeField,
BadgeFieldType, BadgeFieldType,
BadgeTemplate, BadgeTemplate,
@@ -58,6 +61,9 @@ router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
_MAX_IMAGE_BYTES = 8 * 1024 * 1024 _MAX_IMAGE_BYTES = 8 * 1024 * 1024
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"} _ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}")
# Champs centrés verticalement dans leur cadre (les autres partent du haut).
_MIDDLE_ALIGNED = {BadgeFieldType.FREE_TEXT, BadgeFieldType.ROLE}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -80,7 +86,7 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
session.exec( session.exec(
select(BadgeField) select(BadgeField)
.where(BadgeField.edition_id == edition_id) .where(BadgeField.edition_id == edition_id)
.order_by(BadgeField.field_type) .order_by(BadgeField.face, BadgeField.field_type)
) )
) )
@@ -206,9 +212,11 @@ def set_fields(
_guard: Account = Depends(require_coordinator), _guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> list[BadgeField]: ) -> list[BadgeField]:
seen: set[BadgeFieldType] = set() # Au plus un champ de chaque type par face ; le même sur les deux faces
# est permis.
seen: set[tuple[BadgeFace, BadgeFieldType]] = set()
for f in payload.fields: for f in payload.fields:
if f.field_type in seen: if (f.face, f.field_type) in seen:
raise HTTPException( raise HTTPException(
status_code=422, status_code=422,
detail={ detail={
@@ -216,7 +224,12 @@ def set_fields(
"params": {"field_type": f.field_type}, "params": {"field_type": f.field_type},
}, },
) )
seen.add(f.field_type) seen.add((f.face, f.field_type))
if f.bg_color is not None and not _HEX_COLOR.fullmatch(f.bg_color):
raise HTTPException(
status_code=422,
detail={"code": "badge.invalid_color", "params": {"color": f.bg_color}},
)
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id)) session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
for f in payload.fields: for f in payload.fields:
@@ -297,7 +310,11 @@ def _field_text(
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau """Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
(`_positions_table`).""" (`_positions_table`)."""
if field.field_type == BadgeFieldType.FULL_NAME: if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}" # Prénom puis nom, chacun sur sa ligne.
return f"{volunteer.first_name}\n{volunteer.last_name}"
if field.field_type == BadgeFieldType.PHOTO:
# 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: if field.field_type == BadgeFieldType.FREE_TEXT:
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par # 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. # 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 if account.volunteer_id is not None
} }
badges = [ def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
BadgeSpec( return BadgeFieldSpec(
fields=tuple( text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
BadgeFieldSpec( x_mm=field.x_mm,
text=_field_text( y_mm=field.y_mm,
field, volunteer, assignments_by_volunteer, lead_volunteer_ids width_mm=field.width_mm,
), height_mm=field.height_mm,
x_mm=field.x_mm, font_size=field.font_size,
y_mm=field.y_mm, align=field.align.value,
width_mm=field.width_mm, bold=field.bold,
height_mm=field.height_mm, color=field.color,
font_size=field.font_size, is_photo=field.field_type == BadgeFieldType.PHOTO,
align=field.align.value, image=volunteer.photo if field.field_type == BadgeFieldType.PHOTO else None,
bold=field.bold, table=_positions_table(assignments_by_volunteer.get(volunteer.id, []), boundary)
color=field.color, if field.field_type == BadgeFieldType.POSITIONS
is_photo=field.field_type == BadgeFieldType.PHOTO, else None,
image=volunteer.photo bg_color=field.bg_color,
if field.field_type == BadgeFieldType.PHOTO auto_fit=field.auto_fit,
else None, valign="MIDDLE" if field.field_type in _MIDDLE_ALIGNED else "TOP",
table=_positions_table( photo_shape=field.photo_shape.value,
assignments_by_volunteer.get(volunteer.id, []), boundary
)
if field.field_type == BadgeFieldType.POSITIONS
else None,
)
for field in fields
)
) )
front_fields = [f for f in fields if f.face == BadgeFace.FRONT]
back_fields = [f for f in fields if f.face == BadgeFace.BACK]
# Un badge par exemplaire demandé sur la fiche (`Volunteer.badge_count`),
# à la suite les uns des autres.
badges = [
badge
for volunteer in volunteers for volunteer in volunteers
for badge in [
BadgeSpec(
fields=tuple(spec(field, volunteer) for field in front_fields),
back_fields=tuple(spec(field, volunteer) for field in back_fields),
)
]
* volunteer.badge_count
] ]
pdf_bytes = generate_badge_sheets_pdf( pdf_bytes = generate_badge_sheets_pdf(
+15 -2
View File
@@ -8,7 +8,9 @@ from pydantic import BaseModel, ConfigDict, Field
from app.models import ( from app.models import (
AssignmentSource, AssignmentSource,
BadgeAlign, BadgeAlign,
BadgeFace,
BadgeFieldType, BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus, FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
@@ -76,6 +78,9 @@ class VolunteerUpdate(ApiModel):
companion_wish_note: str | None = None companion_wish_note: str | None = None
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination. # Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
badge_text: str | None = None badge_text: str | None = None
# Exemplaires du badge sur les planches (1 à 100 ; garde-fou contre une
# faute de frappe qui imprimerait des centaines de pages).
badge_count: int | None = Field(default=None, ge=1, le=100)
self_declared_available: bool | None = None self_declared_available: bool | None = None
willing_to_pay_fee: bool | None = None willing_to_pay_fee: bool | None = None
@@ -94,6 +99,7 @@ class VolunteerRead(ApiModel):
has_photo: bool has_photo: bool
companion_wish_note: str | None companion_wish_note: str | None
badge_text: str | None badge_text: str | None
badge_count: int
self_declared_available: bool self_declared_available: bool
willing_to_pay_fee: bool willing_to_pay_fee: bool
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) — # Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
@@ -346,6 +352,7 @@ class MatrixResponse(ApiModel):
# Badges (planches PDF) # # Badges (planches PDF) #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
class BadgeFieldIn(ApiModel): class BadgeFieldIn(ApiModel):
face: BadgeFace = BadgeFace.FRONT
field_type: BadgeFieldType field_type: BadgeFieldType
x_mm: float x_mm: float
y_mm: float y_mm: float
@@ -357,11 +364,17 @@ class BadgeFieldIn(ApiModel):
color: str = "#000000" color: str = "#000000"
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon. # `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
text: str | None = None 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): class BadgeFieldsSet(ApiModel):
"""Remplace l'intégralité des champs du recto — au plus un par """Remplace l'intégralité des champs du recto et du verso — au plus un
`field_type` (décocher un champ dans l'éditeur retire sa ligne).""" par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
ligne)."""
fields: list[BadgeFieldIn] = Field(default_factory=list) fields: list[BadgeFieldIn] = Field(default_factory=list)
+234 -78
View File
@@ -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, imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée,
`DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la `DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la
taille standard des badges/cartes d'événement) servent de repère à taille standard des badges/cartes d'événement) servent de repère à
l'éditeur. Planche : grille sur page A4 portrait. Le verso est identique l'éditeur. Planche : grille sur page A4 portrait. Chaque page de recto est
pour tous les bénévoles : la planche imprime toutes les pages de recto suivie de sa page de verso (même image de fond à chaque emplacement rempli,
(personnalisées) puis, pour chacune, la page de verso correspondante (même plus les champs du verso de chaque bénévole) — impression recto-verso
disposition, même image répétée à chaque emplacement rempli) — impression classique, retournement sur le bord long : sur la page de verso, chaque
recto-verso classique, page de verso n directement après la page de recto n. badge est placé en miroir horizontal (colonne `cols - 1 - col`, même rangée)
pour tomber exactement derrière son recto — voir `_cell_origin`.
""" """
import io import io
@@ -36,6 +37,13 @@ _LINE_HEIGHT_FACTOR = 1.25
_CELL_PAD_X_MM = 0.8 _CELL_PAD_X_MM = 0.8
_CELL_PAD_Y_MM = 0.5 _CELL_PAD_Y_MM = 0.5
_TABLE_LINE_WIDTH_PT = 0.4 _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 # Résolution supposée d'une image de fond importée, pour convertir ses pixels
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution # en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
@@ -73,14 +81,21 @@ class BadgeTable:
@dataclass(frozen=True) @dataclass(frozen=True)
class BadgeFieldSpec: class BadgeFieldSpec:
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n` """Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
possibles — ex. un poste par ligne avec son horaire), positionné depuis possibles — ex. prénom puis nom), positionné depuis le coin haut-gauche
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end). du badge (comme dans l'éditeur visuel du front-end).
`is_photo` : le champ est la photo du bénévole (`image`, ses octets `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 bruts tels que stockés) plutôt qu'un texte, découpée selon
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné. `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 text: str
x_mm: float x_mm: float
@@ -94,13 +109,19 @@ class BadgeFieldSpec:
is_photo: bool = False is_photo: bool = False
image: bytes | None = None image: bytes | None = None
table: BadgeTable | None = None table: BadgeTable | None = None
bg_color: str | None = None
auto_fit: bool = False
valign: str = "TOP" # "TOP" | "MIDDLE"
photo_shape: str = "RECT" # "RECT" | "OVAL"
@dataclass(frozen=True) @dataclass(frozen=True)
class BadgeSpec: class BadgeSpec:
"""Un badge = un bénévole, avec ses champs déjà résolus.""" """Un badge = un bénévole, avec ses champs déjà résolus : `fields` pour
le recto, `back_fields` pour le verso."""
fields: tuple[BadgeFieldSpec, ...] fields: tuple[BadgeFieldSpec, ...]
back_fields: tuple[BadgeFieldSpec, ...] = ()
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]: def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
@@ -112,11 +133,22 @@ def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]
def _cell_origin( def _cell_origin(
index: int, cols: int, badge_width_mm: float, badge_height_mm: float index: int,
cols: int,
badge_width_mm: float,
badge_height_mm: float,
mirrored: bool = False,
) -> tuple[float, float]: ) -> tuple[float, float]:
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page, """Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
en mm depuis le coin bas-gauche de la page.""" en mm depuis le coin bas-gauche de la page.
`mirrored` : page de verso. Une feuille retournée sur son bord long
inverse gauche et droite : le verso du badge de la colonne `col` doit
être imprimé en colonne `cols - 1 - col` (même rangée) pour tomber
derrière son recto — y compris sur une dernière planche incomplète."""
col, row = index % cols, index // cols col, row = index % cols, index // cols
if mirrored:
col = cols - 1 - col
x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM) x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM)
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM
return x0, y0 return x0, y0
@@ -169,23 +201,73 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
return out.getvalue() 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( def _draw_photo(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None: ) -> None:
if not f.image: x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
return photo = _fit_photo(f.image, f.width_mm, f.height_mm) if f.image else None
photo = _fit_photo(f.image, f.width_mm, f.height_mm) c.saveState()
if photo is None: 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 return
c.drawImage( c.saveState()
ImageReader(io.BytesIO(photo)), c.setFillColor(HexColor(f.bg_color))
(x0_mm + f.x_mm) * mm, c.rect(*_frame_rect(f, x0_mm, y0_mm, badge_height_mm), stroke=0, fill=1)
# `f.y_mm` est le haut du cadre depuis le HAUT du badge ; reportlab c.restoreState()
# place l'image par son coin bas-gauche, depuis le BAS de la page.
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
width=f.width_mm * mm,
height=f.height_mm * mm,
)
def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]: 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 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( def _draw_table(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None: ) -> None:
@@ -250,52 +371,43 @@ def _draw_table(
filets à la couleur du champ, texte de chaque cellule replié dans sa 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 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 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 table = f.table
if table is None or not table.sections: if table is None or not table.sections:
return return
regular, bold = "Helvetica", "Helvetica-Bold"
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
line_height = f.font_size * _LINE_HEIGHT_FACTOR width_pt, height_pt = f.width_mm * mm, f.height_mm * mm
body = [row for section in table.sections for row in section.rows] font_size = f.font_size
columns = max(len(row) for row in body) if body else 1 widths, lines_to_draw = _table_layout(table, width_pt, font_size)
natural = [ if f.auto_fit:
max(stringWidth(row[col], regular, f.font_size) for row in body) + 2 * pad_x while (
for col in range(columns) sum(line[3] for line in lines_to_draw) > height_pt + 0.01
] if body else [0.0] and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
widths = _column_widths(natural, f.width_mm * mm) ):
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) 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 left = (x0_mm + f.x_mm) * mm
top = (y0_mm + badge_height_mm - f.y_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) color = HexColor(f.color)
c.setFillColor(color) c.setFillColor(color)
c.setStrokeColor(color) c.setStrokeColor(color)
c.setLineWidth(_TABLE_LINE_WIDTH_PT) c.setLineWidth(_TABLE_LINE_WIDTH_PT)
y = top 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 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: if y - row_height < bottom_limit - 0.01:
break # la ligne déborderait du cadre du champ break # la ligne déborderait du cadre du champ
c.setFont(font, f.font_size) c.setFont(font, font_size)
x = left x = left
for col, lines in enumerate(wrapped): for col, lines in enumerate(wrapped):
for i, line in enumerate(lines): 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) c.drawString(x + pad_x, baseline, line)
x += cell_widths[col] x += cell_widths[col]
if not is_title: if not is_title:
@@ -312,6 +424,71 @@ def _draw_table(
c.rect(left, y, total_width, top - y, stroke=1, fill=0) 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( def _draw_fields(
c: canvas.Canvas, c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...], fields: tuple[BadgeFieldSpec, ...],
@@ -323,33 +500,11 @@ def _draw_fields(
if f.is_photo: if f.is_photo:
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm) _draw_photo(c, f, x0_mm, y0_mm, badge_height_mm)
continue continue
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
if f.table is not None: if f.table is not None:
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm) _draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
continue else:
font_name = "Helvetica-Bold" if f.bold else "Helvetica" _draw_text(c, f, x0_mm, y0_mm, badge_height_mm)
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)
def generate_badge_sheets_pdf( def generate_badge_sheets_pdf(
@@ -374,9 +529,10 @@ def generate_badge_sheets_pdf(
_draw_fields(c, badge.fields, x0, y0, badge_height_mm) _draw_fields(c, badge.fields, x0, y0, badge_height_mm)
c.showPage() c.showPage()
for i in range(len(page_badges)): for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm) x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True)
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm) _draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm)
c.showPage() c.showPage()
c.save() c.save()
return buffer.getvalue() return buffer.getvalue()
+2
View File
@@ -37,6 +37,7 @@ _HEADER = [
"Mécénat — pays", "Mécénat — pays",
"Commentaire", "Commentaire",
"Texte du badge", "Texte du badge",
"Nombre de badges",
"Inscrit le", "Inscrit le",
] ]
@@ -125,6 +126,7 @@ def generate_volunteers_xlsx(
_safe_text(v.mecenat_country or ""), _safe_text(v.mecenat_country or ""),
_safe_text(v.comment or ""), _safe_text(v.comment or ""),
_safe_text(v.badge_text or ""), _safe_text(v.badge_text or ""),
v.badge_count,
_fmt_dt(v.created_at), _fmt_dt(v.created_at),
] ]
) )
+2 -1
View File
@@ -61,7 +61,8 @@
"badge.image_too_large": "L'image est trop volumineuse.", "badge.image_too_large": "L'image est trop volumineuse.",
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.", "badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
"badge.invalid_size": "La taille du badge doit être strictement positive.", "badge.invalid_size": "La taille du badge doit être strictement positive.",
"badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.", "badge.invalid_color": "La couleur « {color} » n'est pas valide (format attendu : #rrggbb).",
"badge.no_fields": "Aucun champ n'est configuré sur le badge.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.", "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
"badge.role.referent": "Référent", "badge.role.referent": "Référent",
"badge.role.volunteer": "Bénévole", "badge.role.volunteer": "Bénévole",
@@ -0,0 +1,62 @@
"""Éditeur de badges v2 (verso, fond de zone, auto-ajustement, photo ovale)
Revision ID: 0026_badge_editor_v2
Revises: 0025_badge_field_text
Create Date: 2026-10-27
`badge_field.face` : face du badge (`FRONT`/`BACK`) — les champs existants
deviennent des champs du recto. La contrainte d'unicité passe de
(`edition_id`, `field_type`) à (`edition_id`, `face`, `field_type`) : un même
champ peut figurer sur les deux faces. `bg_color` (fond de la zone,
transparent si `NULL`), `auto_fit` (réduction automatique du texte) et
`photo_shape` (`RECT`/`OVAL`, champ `PHOTO`).
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0026_badge_editor_v2"
down_revision: str | None = "0025_badge_field_text"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_FACE = sa.Enum("FRONT", "BACK", name="badgeface", native_enum=False)
_PHOTO_SHAPE = sa.Enum("RECT", "OVAL", name="badgephotoshape", native_enum=False)
def upgrade() -> None:
op.add_column(
"badge_field",
sa.Column("face", _FACE, server_default="FRONT", nullable=False),
)
op.add_column("badge_field", sa.Column("bg_color", sa.String(), nullable=True))
op.add_column(
"badge_field",
sa.Column("auto_fit", sa.Boolean(), server_default="false", nullable=False),
)
op.add_column(
"badge_field",
sa.Column("photo_shape", _PHOTO_SHAPE, server_default="RECT", nullable=False),
)
op.drop_constraint("uq_badge_field_edition_type", "badge_field", type_="unique")
op.create_unique_constraint(
"uq_badge_field_edition_face_type",
"badge_field",
["edition_id", "face", "field_type"],
)
def downgrade() -> None:
# Le verso n'existait pas : ses champs sont perdus (sinon l'ancienne
# contrainte d'unicité échouerait sur un champ présent sur les deux faces).
op.execute("DELETE FROM badge_field WHERE face = 'BACK'")
op.drop_constraint("uq_badge_field_edition_face_type", "badge_field", type_="unique")
op.create_unique_constraint(
"uq_badge_field_edition_type", "badge_field", ["edition_id", "field_type"]
)
op.drop_column("badge_field", "photo_shape")
op.drop_column("badge_field", "auto_fit")
op.drop_column("badge_field", "bg_color")
op.drop_column("badge_field", "face")
@@ -0,0 +1,35 @@
"""Nombre de badges par bénévole
Revision ID: 0027_volunteer_badge_count
Revises: 0026_badge_editor_v2
Create Date: 2026-10-27
`volunteer.badge_count` : nombre d'exemplaires de son badge imprimés sur les
planches (`POST .../badges/generate`), 1 par défaut — les bénévoles existants
gardent un seul badge.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0027_volunteer_badge_count"
down_revision: str | None = "0026_badge_editor_v2"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"volunteer",
sa.Column("badge_count", sa.Integer(), nullable=False, server_default="1"),
)
op.create_check_constraint(
"ck_volunteer_badge_count_positive", "volunteer", "badge_count >= 1"
)
def downgrade() -> None:
op.drop_constraint("ck_volunteer_badge_count_positive", "volunteer", type_="check")
op.drop_column("volunteer", "badge_count")
+4
View File
@@ -28,7 +28,9 @@ from app.models.domain import (
from app.models.enums import ( from app.models.enums import (
AssignmentSource, AssignmentSource,
BadgeAlign, BadgeAlign,
BadgeFace,
BadgeFieldType, BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus, FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
@@ -41,8 +43,10 @@ __all__ = [
"Assignment", "Assignment",
"AssignmentSource", "AssignmentSource",
"BadgeAlign", "BadgeAlign",
"BadgeFace",
"BadgeField", "BadgeField",
"BadgeFieldType", "BadgeFieldType",
"BadgePhotoShape",
"BadgeTemplate", "BadgeTemplate",
"DayPart", "DayPart",
"Edition", "Edition",
+43 -5
View File
@@ -18,7 +18,9 @@ from app.models.base import TimestampMixin
from app.models.enums import ( from app.models.enums import (
AssignmentSource, AssignmentSource,
BadgeAlign, BadgeAlign,
BadgeFace,
BadgeFieldType, BadgeFieldType,
BadgePhotoShape,
FeePaymentStatus, FeePaymentStatus,
FormFieldType, FormFieldType,
PositionCategory, PositionCategory,
@@ -160,6 +162,10 @@ class Artist(TimestampMixin, table=True):
class Volunteer(TimestampMixin, table=True): class Volunteer(TimestampMixin, table=True):
__table_args__ = (
CheckConstraint("badge_count >= 1", name="ck_volunteer_badge_count_positive"),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field( edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE" foreign_key="edition.id", index=True, ondelete="CASCADE"
@@ -187,6 +193,11 @@ class Volunteer(TimestampMixin, table=True):
# saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide : # saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
# le texte commun de l'éditeur (`BadgeField.text`) s'applique. # le texte commun de l'éditeur (`BadgeField.text`) s'applique.
badge_text: str | None = None badge_text: str | None = None
# Nombre d'exemplaires de son badge à imprimer sur les planches (badge
# perdu, plusieurs accréditations…) — 1 par défaut.
badge_count: int = Field(
default=1, sa_column=Column(Integer, nullable=False, server_default="1")
)
# Cases à cocher du formulaire public — purement informatives : aucune # Cases à cocher du formulaire public — purement informatives : aucune
# logique n'y est attachée (pas de workflow de paiement). # logique n'y est attachée (pas de workflow de paiement).
self_declared_available: bool = Field( self_declared_available: bool = Field(
@@ -429,16 +440,17 @@ class BadgeTemplate(TimestampMixin, table=True):
class BadgeField(TimestampMixin, table=True): class BadgeField(TimestampMixin, table=True):
"""Un champ positionné sur le recto du badge (nom, poste(s), catégorie, """Un champ positionné sur une face du badge (`face` : recto ou verso —
rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en
haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`). millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`).
Au plus un champ par `field_type` et par édition : décocher un champ dans Au plus un champ par `field_type` et par face pour une édition (le même
champ peut figurer au recto et au verso) : décocher un champ dans
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`).""" l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
__tablename__ = "badge_field" __tablename__ = "badge_field"
__table_args__ = ( __table_args__ = (
UniqueConstraint( UniqueConstraint(
"edition_id", "field_type", name="uq_badge_field_edition_type" "edition_id", "face", "field_type", name="uq_badge_field_edition_face_type"
), ),
) )
@@ -446,6 +458,14 @@ class BadgeField(TimestampMixin, table=True):
edition_id: uuid.UUID = Field( edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE" foreign_key="edition.id", index=True, ondelete="CASCADE"
) )
face: BadgeFace = Field(
default=BadgeFace.FRONT,
sa_column=Column(
SAEnum(BadgeFace, native_enum=False),
nullable=False,
server_default=BadgeFace.FRONT.value,
),
)
field_type: BadgeFieldType = Field( field_type: BadgeFieldType = Field(
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False) sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
) )
@@ -471,6 +491,24 @@ class BadgeField(TimestampMixin, table=True):
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge » # tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole. # (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
text: str | None = None 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,
),
)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+17 -1
View File
@@ -31,7 +31,7 @@ class PositionCategory(enum.StrEnum):
class BadgeFieldType(enum.StrEnum): class BadgeFieldType(enum.StrEnum):
"""Champ affichable sur le recto d'un badge (voir `app/badges/`).""" """Champ affichable sur le recto ou le verso d'un badge (voir `app/badges/`)."""
FULL_NAME = "FULL_NAME" FULL_NAME = "FULL_NAME"
POSITIONS = "POSITIONS" POSITIONS = "POSITIONS"
@@ -45,6 +45,22 @@ class BadgeFieldType(enum.StrEnum):
FREE_TEXT = "FREE_TEXT" FREE_TEXT = "FREE_TEXT"
class BadgeFace(enum.StrEnum):
"""Face du badge sur laquelle un champ est imprimé : le verso est
personnalisable comme le recto (même champ possible sur les deux)."""
FRONT = "FRONT"
BACK = "BACK"
class BadgePhotoShape(enum.StrEnum):
"""Forme de découpe du champ `PHOTO` (cadre rectangulaire ou ellipse
inscrite dans le cadre)."""
RECT = "RECT"
OVAL = "OVAL"
class BadgeAlign(enum.StrEnum): class BadgeAlign(enum.StrEnum):
LEFT = "LEFT" LEFT = "LEFT"
CENTER = "CENTER" CENTER = "CENTER"
+151
View File
@@ -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={}) resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.content.startswith(b"%PDF") 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
+18
View File
@@ -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, own, {}, set()) == "Accès backstage"
assert _field_text(field, other, {}, set()) == "Staff 2026" 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"
+167
View File
@@ -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) 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) many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None)
assert abs(len(many) - len(few)) < 50 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")
+59 -1
View File
@@ -461,6 +461,64 @@ function checkDayBoundary() {
console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`); console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`);
} }
// Éditeur de badges (onglet « Personnaliser ») : un champ par face, rendu
// avec les vraies données (nom du premier bénévole dans l'ordre
// d'impression), contrôle avant génération présent ; le mock refuse un même
// type de champ deux fois sur une face, mais l'accepte sur les deux.
async function checkBadgeEditor() {
if (!coordinator) throw new Error('badges : pas de session coordination pour ce test');
const base = {
x_mm: 4, y_mm: 4, width_mm: 40, height_mm: 12, font_size: 9, align: 'LEFT' as const,
bold: true, color: '#14110E', bg_color: null, auto_fit: true, photo_shape: 'RECT' as const,
};
let duplicate = 'non';
try {
await api.badges.setFields(e, {
fields: [
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
],
});
} catch (err) {
duplicate = err instanceof ApiError ? err.code : String(err);
}
if (duplicate !== 'badge.duplicate_field_type') throw new Error(`doublon accepté : ${duplicate}`);
await api.badges.setFields(e, {
fields: [
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
{ ...base, field_type: 'POSITIONS', face: 'FRONT', y_mm: 18, height_mm: 30, bold: false },
{ ...base, field_type: 'FULL_NAME', face: 'BACK', bg_color: '#FFD23F' },
{ ...base, field_type: 'FREE_TEXT', face: 'BACK', y_mm: 30, text: 'STAFF' },
],
});
const vols = [...(await api.volunteers.list(e))].sort(
(a, b) =>
a.last_name.localeCompare(b.last_name, 'fr') || a.first_name.localeCompare(b.first_name, 'fr'),
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await prefetchCore(client);
await Promise.all([
client.prefetchQuery({ queryKey: ['badgeTemplate', e], queryFn: () => api.badges.getTemplate(e) }),
client.prefetchQuery({ queryKey: ['dayParts', e], queryFn: () => api.dayParts.list(e) }),
client.prefetchQuery({ queryKey: ['accounts', e], queryFn: () => api.accounts.list(e) }),
]);
const html = renderToString(
createElement(
QueryClientProvider,
{ client },
createElement(
AuthContext.Provider,
{ value: authValue(coordinator) },
createElement(MemoryRouter, null, createElement(BadgesPage)),
),
),
);
const expected = [vols[0]?.last_name ?? '', 'STAFF', 'Contrôle des'];
const missing = expected.filter((text) => !html.includes(text));
if (missing.length) throw new Error(`badges : absent du rendu ${JSON.stringify(missing)}`);
console.log(`ok ${'/badges'.padEnd(26)} ${html.length} octets`);
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
try { try {
checkDayBoundary(); checkDayBoundary();
@@ -484,7 +542,7 @@ if (coordinator) {
await renderProtected('/grille', coordinator, createElement(GrillePage)); await renderProtected('/grille', coordinator, createElement(GrillePage));
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage)); await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
await renderProtected('/configuration', coordinator, createElement(ConfigPage)); await renderProtected('/configuration', coordinator, createElement(ConfigPage));
await renderProtected('/badges', coordinator, createElement(BadgesPage)); await checkBadgeEditor().catch((err) => fail('checkBadgeEditor', err));
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage)); await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => { await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => {
await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) }); await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) });
+1
View File
@@ -290,6 +290,7 @@ export function buildMockState(): MockState {
has_photo: false, has_photo: false,
companion_wish_note: null, companion_wish_note: null,
badge_text: null, badge_text: null,
badge_count: 1,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null, fee_payment_label: null,
+25 -2
View File
@@ -235,6 +235,7 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
has_photo: pending.photo !== null, has_photo: pending.photo !== null,
companion_wish_note: pending.companionWishNote, companion_wish_note: pending.companionWishNote,
badge_text: null, badge_text: null,
badge_count: 1,
self_declared_available: pending.available, self_declared_available: pending.available,
willing_to_pay_fee: true, willing_to_pay_fee: true,
fee_payment_label: pending.paymentLabel, fee_payment_label: pending.paymentLabel,
@@ -997,6 +998,7 @@ export function createMockApi(): PlanningApi {
has_photo: false, has_photo: false,
companion_wish_note: null, companion_wish_note: null,
badge_text: null, badge_text: null,
badge_count: 1,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null, fee_payment_label: null,
@@ -1042,6 +1044,14 @@ export function createMockApi(): PlanningApi {
? { companion_wish_note: body.companion_wish_note ?? null } ? { companion_wish_note: body.companion_wish_note ?? null }
: {}), : {}),
...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}), ...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}),
...(body.badge_count != null
? (() => {
if (!Number.isInteger(body.badge_count) || body.badge_count < 1 || body.badge_count > 100) {
throw new ApiError(422, 'error.generic');
}
return { badge_count: body.badge_count };
})()
: {}),
...('self_declared_available' in body && body.self_declared_available != null ...('self_declared_available' in body && body.self_declared_available != null
? { self_declared_available: body.self_declared_available } ? { self_declared_available: body.self_declared_available }
: {}), : {}),
@@ -1321,6 +1331,7 @@ export function createMockApi(): PlanningApi {
has_photo: false, has_photo: false,
companion_wish_note: null, companion_wish_note: null,
badge_text: null, badge_text: null,
badge_count: 1,
self_declared_available: false, self_declared_available: false,
willing_to_pay_fee: false, willing_to_pay_fee: false,
fee_payment_label: null, fee_payment_label: null,
@@ -1577,13 +1588,24 @@ export function createMockApi(): PlanningApi {
return (await fetch(state.badgeTemplate.backImage)).blob(); return (await fetch(state.badgeTemplate.backImage)).blob();
}, },
setFields: (_edition, body: BadgeFieldsSet) => { setFields: (_edition, body: BadgeFieldsSet) => {
// Un même type de champ peut exister une fois par face (recto/verso),
// comme côté back-end.
const seen = new Set<string>(); const seen = new Set<string>();
const hexColor = /^#[0-9a-fA-F]{6}$/;
for (const f of body.fields) { for (const f of body.fields) {
if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type'); const key = `${f.face ?? 'FRONT'}:${f.field_type}`;
seen.add(f.field_type); if (seen.has(key)) throw new ApiError(422, 'badge.duplicate_field_type');
seen.add(key);
if (!hexColor.test(f.color) || (f.bg_color != null && !hexColor.test(f.bg_color))) {
throw new ApiError(422, 'badge.invalid_color');
}
} }
const fields: BadgeFieldRead[] = body.fields.map((f) => ({ const fields: BadgeFieldRead[] = body.fields.map((f) => ({
...f, ...f,
face: f.face ?? 'FRONT',
bg_color: f.bg_color ?? null,
auto_fit: f.auto_fit ?? true,
photo_shape: f.photo_shape ?? 'RECT',
id: `bf${state.seq++}`, id: `bf${state.seq++}`,
edition_id: state.edition.id, edition_id: state.edition.id,
})); }));
@@ -1792,6 +1814,7 @@ export function createMockApi(): PlanningApi {
has_photo: photo !== null, has_photo: photo !== null,
companion_wish_note: companionWishNote, companion_wish_note: companionWishNote,
badge_text: null, badge_text: null,
badge_count: 1,
self_declared_available: fields.has('AVAILABLE') ? available : false, self_declared_available: fields.has('AVAILABLE') ? available : false,
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'), willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
fee_payment_label: null, fee_payment_label: null,
+18
View File
@@ -110,6 +110,8 @@ export interface VolunteerRead {
/** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la /** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la
* coordination. */ * coordination. */
badge_text: string | null; badge_text: string | null;
/** Exemplaires de son badge imprimés sur les planches (1 par défaut). */
badge_count: number;
self_declared_available: boolean; self_declared_available: boolean;
willing_to_pay_fee: boolean; willing_to_pay_fee: boolean;
/** Paiement de la cotisation via SumUp (voir schema forms) — /** Paiement de la cotisation via SumUp (voir schema forms) —
@@ -161,6 +163,8 @@ export interface VolunteerUpdate {
wished_companion_ids?: string[] | null; wished_companion_ids?: string[] | null;
companion_wish_note?: string | null; companion_wish_note?: string | null;
badge_text?: string | null; badge_text?: string | null;
/** 1 à 100. */
badge_count?: number | null;
self_declared_available?: boolean | null; self_declared_available?: boolean | null;
willing_to_pay_fee?: boolean | null; willing_to_pay_fee?: boolean | null;
} }
@@ -433,9 +437,16 @@ export type BadgeFieldType =
| 'PHOTO' | 'PHOTO'
| 'FREE_TEXT'; | 'FREE_TEXT';
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT'; export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
/** Face du badge où se trouve un champ : chaque type de champ peut exister
* une fois par face. */
export type BadgeFace = 'FRONT' | 'BACK';
/** Forme du cadre de la photo (`PHOTO`). */
export type BadgePhotoShape = 'RECT' | 'OVAL';
export interface BadgeFieldIn { export interface BadgeFieldIn {
field_type: BadgeFieldType; field_type: BadgeFieldType;
/** Défaut `FRONT`. */
face: BadgeFace;
x_mm: number; x_mm: number;
y_mm: number; y_mm: number;
width_mm: number; width_mm: number;
@@ -444,6 +455,13 @@ export interface BadgeFieldIn {
align: BadgeAlign; align: BadgeAlign;
bold: boolean; bold: boolean;
color: string; color: string;
/** Fond de la zone du champ (`#rrggbb`), `null` = transparent. */
bg_color: string | null;
/** Réduit la taille du texte (par pas de 0,5 pt, jusqu'à 4 pt) quand il
* ne tient pas dans le cadre. */
auto_fit: boolean;
/** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */
photo_shape: BadgePhotoShape;
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour /** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */ * un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
text?: string | null; text?: string | null;
+111 -1
View File
@@ -231,6 +231,8 @@
"config.vol.field.phone": "Téléphone", "config.vol.field.phone": "Téléphone",
"config.vol.field.comment": "Commentaire", "config.vol.field.comment": "Commentaire",
"config.vol.field.badgeText": "Texte du badge", "config.vol.field.badgeText": "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.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.title": "Photo",
"config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.", "config.vol.photo.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.saved": "Enregistré ✓",
"badges.generate.title": "Génération de la planche PDF", "badges.generate.title": "Génération de la planche PDF",
"badges.generate.all": "Tous les bénévoles ({count})", "badges.generate.all": "Tous les bénévoles ({count})",
"badges.generate.total": "{count} badge(s) à imprimer",
"badges.generate.pick": "Choisir ({count} sélectionné(s))", "badges.generate.pick": "Choisir ({count} sélectionné(s))",
"badges.generate.action": "Générer le PDF", "badges.generate.action": "Générer le PDF",
"badges.generating": "Génération…", "badges.generating": "Génération…",
"badges.face.front": "Recto",
"badges.face.back": "Verso",
"badges.face.front.short": "recto",
"badges.face.back.short": "verso",
"badges.dims": "{width} × {height} mm",
"badges.drop.front": "Déposez ici le visuel du recto (PNG ou JPEG)",
"badges.drop.back": "Déposez ici le visuel du verso (PNG ou JPEG)",
"badges.format.title": "Format du badge",
"badges.format.current": "Format actuel",
"badges.preset.cr80Portrait": "Carte CR80 portrait",
"badges.preset.cr80Landscape": "Carte CR80 paysage",
"badges.preset.a7": "A7 tour de cou",
"badges.fieldsOf": "Champs du {face}",
"badges.fieldsShown": "{count} affiché(s)",
"badges.layer.toggle": "Afficher / masquer",
"badges.layer.hidden": "masqué",
"badges.layer.meta": "{size} pt · {weight}",
"badges.layer.bold": "gras",
"badges.layer.regular": "normal",
"badges.layer.rect": "rectangle",
"badges.layer.oval": "ovale",
"badges.layer.issueCount": "▲ {count}",
"badges.layer.issueTruncated.one": "{count} badge avec texte coupé",
"badges.layer.issueTruncated.other": "{count} badges avec texte coupé",
"badges.layer.issueReduced.one": "{count} badge réduit automatiquement",
"badges.layer.issueReduced.other": "{count} badges réduits automatiquement",
"badges.keys.hint": "Clic sur un champ pour le régler. Flèches : déplacer de 0,5 mm. Maj + flèches : 2 mm. Échap : désélectionner. Ctrl/Cmd + Z : annuler.",
"badges.undo": "↶ Annuler",
"badges.undo.title": "Ctrl/Cmd + Z",
"badges.dirty": "Modifications non enregistrées",
"badges.clean": "✓ Enregistré",
"badges.savedAt": "✓ Enregistré à {time}",
"badges.sample.firstName": "Prénom",
"badges.sample.lastName": "Nom",
"badges.preview.with": "Aperçu avec",
"badges.preview.prev": "Bénévole précédent",
"badges.preview.next": "Bénévole suivant",
"badges.preview.counter": "Badge {index} / {total} · {role}",
"badges.preview.noVolunteer": "Aucun bénévole : exemple fictif",
"badges.preview.longest": "Tester le nom le plus long",
"badges.preview.guides": "Repères",
"badges.preview.guides.help": "Affiche la marge de découpe (1,5 mm) et le cadre de chaque champ",
"badges.preview.zoomIn": "Agrandir l'aperçu",
"badges.preview.zoomOut": "Réduire l'aperçu",
"badges.preview.zoom": "{percent} %",
"badges.preview.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.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.empty_image": "L'image est vide.", "badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.", "badge.image_too_large": "L'image est trop volumineuse.",
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.", "badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois par face.",
"badge.invalid_color": "Couleur invalide (format attendu : #rrggbb).",
"badge.invalid_size": "La taille du badge doit être strictement positive.", "badge.invalid_size": "La taille du badge doit être strictement positive.",
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.", "badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.", "badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
+41 -660
View File
@@ -1,677 +1,43 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useMemo, useState } from 'react';
import { import { useGenerateBadges, useVolunteers } from '@/api/hooks';
useBadgeImageUrl,
useBadgeTemplate,
useGenerateBadges,
useSetBadgeFields,
useSetBadgeSize,
useUploadBadgeBackImage,
useUploadBadgeFrontImage,
useVolunteers,
} from '@/api/hooks';
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
import { ApiError } from '@/api'; import { ApiError } from '@/api';
import { EmptyNote, ErrorNote, Loading } from '@/components/state'; import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui'; import { Btn, Chip, Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n'; import { t } from '@/i18n';
import { cx } from '@/lib/cx';
// Le format du badge est dérivé de l'image de fond du recto (voir import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab';
// `backend/app/badges/pdf.py:image_size_mm`) — CR80 (85,6 × 54 mm) tant
// qu'aucune image n'est importée. `SCALE` fixe la taille d'édition à
// l'écran ; les coordonnées enregistrées restent en mm.
const DEFAULT_BADGE_W_MM = 85.6;
const DEFAULT_BADGE_H_MM = 54;
const SCALE = 4.6; // px par mm à l'écran (au plus)
// Marges horizontales autour de l'aperçu (page + panneau + bordures), pour
// le faire tenir dans un écran étroit.
const PREVIEW_GUTTER_PX = 90;
/** Échelle d'édition : `SCALE`, réduite sur un écran trop étroit pour un // L'onglet « Personnaliser » (éditeur recto/verso, aperçu avec les vraies
* badge à pleine taille (téléphone, tablette en portrait). Les coordonnées // données, contrôle avant génération) vit dans `./badges/`.
* 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<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName',
POSITIONS: 'badges.field.positions',
CATEGORY: 'badges.field.category',
ROLE: 'badges.field.role',
PHOTO: 'badges.field.photo',
FREE_TEXT: 'badges.field.freeText',
};
// Indication affichée à côté du nom du champ, pour ceux dont l'origine du
// contenu n'est pas évidente.
const FIELD_HELP_KEY: Partial<Record<BadgeFieldType, string>> = {
PHOTO: 'badges.field.photo.help',
FREE_TEXT: 'badges.field.freeText.help',
};
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
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<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = {
FULL_NAME: {
x_mm: 5, y_mm: 4, width_mm: 70, height_mm: 10,
font_size: 13, align: 'LEFT', bold: true, color: '#000000',
},
POSITIONS: {
x_mm: 5, y_mm: 15, width_mm: 70, height_mm: 22,
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
},
CATEGORY: {
x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 7,
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
},
ROLE: {
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
},
// 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<F extends BadgeFieldIn>(field: F, badgeW: number, badgeH: number): F {
const width_mm = Math.min(field.width_mm, badgeW);
const height_mm = Math.min(field.height_mm, badgeH);
return {
...field,
width_mm,
height_mm,
x_mm: clamp(field.x_mm, 0, badgeW - width_mm),
y_mm: clamp(field.y_mm, 0, badgeH - height_mm),
};
}
function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> {
const byType = new Map(saved.map((f) => [f.field_type, f]));
const out = {} as Record<BadgeFieldType, FieldState>;
for (const type of FIELD_TYPES) {
const existing = byType.get(type);
out[type] = existing
? { ...existing, enabled: true }
: { field_type: type, enabled: false, ...FIELD_DEFAULTS[type] };
}
return out;
}
export function BadgesPage() { export function BadgesPage() {
const [tab, setTab] = useState<'customize' | 'generate'>('customize'); const [tab, setTab] = useState<'customize' | 'generate'>('customize');
const tabBar = (
<>
<span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span>
<Chip active={tab === 'customize'} onClick={() => setTab('customize')}>
{t('badges.tab.customize')}
</Chip>
<Chip active={tab === 'generate'} onClick={() => setTab('generate')}>
{t('badges.tab.generate')}
</Chip>
</>
);
return ( return (
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-2 border-b-2 border-ink bg-sand px-4 py-[9px]"> {tab === 'customize' ? (
<span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span> <BadgeCustomizeTab tabBar={tabBar} />
<Chip active={tab === 'customize'} onClick={() => setTab('customize')}>
{t('badges.tab.customize')}
</Chip>
<Chip active={tab === 'generate'} onClick={() => setTab('generate')}>
{t('badges.tab.generate')}
</Chip>
</div>
{tab === 'customize' ? <BadgeCustomizeTab /> : <BadgeGenerateTab />}
</div>
);
}
// --------------------------------------------------------------------------- //
// Personnalisation //
// --------------------------------------------------------------------------- //
function BadgeCustomizeTab() {
const template = useBadgeTemplate();
const frontUrl = useBadgeImageUrl(
'front', template.data?.has_front_image ?? false, template.dataUpdatedAt,
);
const backUrl = useBadgeImageUrl(
'back', template.data?.has_back_image ?? false, template.dataUpdatedAt,
);
const uploadFront = useUploadBadgeFrontImage();
const uploadBack = useUploadBadgeBackImage();
const setFields = useSetBadgeFields();
const setSize = useSetBadgeSize();
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
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 <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
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 (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4 lg:flex-row lg:items-start">
<div className="flex flex-none flex-col gap-4">
<Panel>
<PanelHeader>
{t('badges.front')}
<HelpTip text={t('badges.front.help')} className="ml-[6px]" />
</PanelHeader>
<div className="p-3">
<ImageUploadRow
url={frontUrl}
pending={uploadFront.isPending}
error={uploadFront.error}
onPick={(file) => uploadFront.mutate(file)}
/>
<BadgeSizeControl
badgeW={badgeW}
badgeH={badgeH}
pending={setSize.isPending}
onApply={(width_mm, height_mm) =>
setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm })
}
/>
<div
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
style={{
width: badgeW * scale,
height: badgeH * scale,
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
}}
onPointerDown={() => setSelected(null)}
>
{FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => (
<FieldBox
key={type}
field={fields[type]}
badgeW={badgeW}
badgeH={badgeH}
scale={scale}
selected={selected === type}
onSelect={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)}
/>
))}
</div>
<p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60">
{t('badges.editor.hint', {
width: Math.round(badgeW * 10) / 10,
height: Math.round(badgeH * 10) / 10,
})}
</p>
</div>
</Panel>
<Panel>
<PanelHeader>
{t('badges.back')}
<HelpTip text={t('badges.back.help')} className="ml-[6px]" />
</PanelHeader>
<div className="p-3">
<ImageUploadRow
url={backUrl}
pending={uploadBack.isPending}
error={uploadBack.error}
onPick={(file) => uploadBack.mutate(file)}
/>
<div
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
style={{ width: badgeW * scale, height: badgeH * scale, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
/>
</div>
</Panel>
</div>
<Panel className="min-w-0 flex-1 lg:min-w-[320px]">
<PanelHeader>{t('badges.fields')}</PanelHeader>
<div className="flex flex-col divide-y-2 divide-ink/10">
{FIELD_TYPES.map((type) => (
<FieldEditorRow
key={type}
type={type}
field={fields[type]}
active={selected === type}
onFocus={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)}
/>
))}
</div>
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
<Btn onClick={save} disabled={setFields.isPending}>
{setFields.isPending ? t('badges.saving') : t('badges.save')}
</Btn>
{setFields.isSuccess ? (
<span className="font-mono text-[9.5px] font-bold text-green">{t('badges.saved')}</span>
) : null}
{setFields.isError ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
{errorMessage(setFields.error)}
</span>
) : null}
</div>
</Panel>
</div>
);
}
function ImageUploadRow({
url,
pending,
error,
onPick,
}: {
url: string | null;
pending: boolean;
error: unknown;
onPick: (file: File) => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
return (
<div className="flex items-center gap-[10px]">
<Btn variant="ghost" onClick={() => inputRef.current?.click()} disabled={pending}>
{pending ? t('badges.uploading') : url ? t('badges.image.replace') : t('badges.image.choose')}
</Btn>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onPick(file);
e.target.value = '';
}}
/>
{error ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">{errorMessage(error)}</span>
) : null}
</div>
);
}
/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de
* l'image importée). Sert à agrandir un badge devenu minuscule parce que
* l'image de fond n'était pas à une résolution d'impression (300 dpi
* supposés à l'import, voir `useBadgeTemplate`). */
function BadgeSizeControl({
badgeW,
badgeH,
pending,
onApply,
}: {
badgeW: number;
badgeH: number;
pending: boolean;
onApply: (width_mm: number, height_mm: number) => void;
}) {
const rounded = Math.round(badgeW * 10) / 10;
const [width, setWidth] = useState(String(rounded));
useEffect(() => setWidth(String(rounded)), [rounded]);
const apply = () => {
const w = Number(width);
if (!Number.isFinite(w) || w <= 0) {
setWidth(String(rounded));
return;
}
const ratio = badgeH / badgeW;
onApply(w, Math.round(w * ratio * 10) / 10);
};
return (
<div className="mt-2 flex items-center gap-[8px]">
<FieldLabel>{t('badges.size.width')}</FieldLabel>
<input
type="number"
min={5}
step={1}
value={width}
disabled={pending}
onChange={(e) => setWidth(e.target.value)}
onBlur={apply}
onKeyDown={(e) => {
if (e.key === 'Enter') apply();
}}
className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
/>
<span className="font-mono text-[9.5px] opacity-60">
{t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })}
</span>
</div>
);
}
function FieldBox({
field,
badgeW,
badgeH,
scale,
selected,
onSelect,
onChange,
}: {
field: FieldState;
badgeW: number;
badgeH: number;
scale: number;
selected: boolean;
onSelect: () => void;
onChange: (patch: Partial<FieldState>) => void;
}) {
const dragRef = useRef<{ startXMm: number; startYMm: number; pointerXPx: number; pointerYPx: number } | null>(
null,
);
const resizeRef = useRef<{ startWMm: number; startHMm: number; pointerXPx: number; pointerYPx: number } | null>(
null,
);
const startDrag = (e: React.PointerEvent) => {
e.stopPropagation();
onSelect();
(e.target as Element).setPointerCapture(e.pointerId);
dragRef.current = { startXMm: field.x_mm, startYMm: field.y_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
};
const onDragMove = (e: React.PointerEvent) => {
if (!dragRef.current) return;
const d = dragRef.current;
const dxMm = (e.clientX - d.pointerXPx) / scale;
const dyMm = (e.clientY - d.pointerYPx) / scale;
onChange({
x_mm: clamp(d.startXMm + dxMm, 0, 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 (
<div
onPointerDown={startDrag}
onPointerMove={onDragMove}
onPointerUp={() => (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' ? (
<PositionsTablePreview field={field} scale={scale} />
) : field.field_type === 'PHOTO' ? (
<span className="pointer-events-none flex h-full w-full items-center justify-center bg-sand-dark/70 font-mono text-[10px] font-bold uppercase text-ink/60">
{t(FIELD_PLACEHOLDER_KEY.PHOTO)}
</span>
) : ( ) : (
<span <>
className="pointer-events-none whitespace-pre-line font-sans leading-tight" <div className="flex flex-wrap items-center gap-2 border-b-[3px] border-ink bg-sand px-3 py-2 sm:px-4">
style={{ {tabBar}
fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge
fontWeight: field.bold ? 800 : 400,
color: field.color,
width: '100%',
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}}
>
{field.field_type === 'FREE_TEXT' && field.text?.trim()
? field.text
: t(FIELD_PLACEHOLDER_KEY[field.field_type])}
</span>
)}
<div
onPointerDown={startResize}
onPointerMove={onResizeMove}
onPointerUp={() => (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"
/>
</div>
);
}
/** 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 (
<table
className="pointer-events-none w-full border-collapse font-sans leading-tight"
style={{ fontSize, color: field.color }}
>
{sections.map((section) => (
<tbody key={section.title}>
<tr>
<th colSpan={2} className={cell} style={border}>
{section.title}
</th>
</tr>
{section.rows.map(([time, position]) => (
<tr key={time}>
<td className={cx(cell, 'w-px whitespace-nowrap')} style={border}>
{time}
</td>
<td className={cell} style={border}>
{position}
</td>
</tr>
))}
</tbody>
))}
</table>
);
}
function FieldEditorRow({
type,
field,
active,
onFocus,
onChange,
}: {
type: BadgeFieldType;
field: FieldState;
active: boolean;
onFocus: () => void;
onChange: (patch: Partial<FieldState>) => void;
}) {
return (
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
<div className="flex items-center">
<label className="flex items-center gap-[8px]">
<input
type="checkbox"
checked={field.enabled}
onChange={(e) => onChange({ enabled: e.target.checked })}
className="h-[16px] w-[16px] accent-ink"
/>
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
{t(FIELD_LABEL_KEY[type])}
</span>
</label>
{FIELD_HELP_KEY[type] ? (
<HelpTip text={t(FIELD_HELP_KEY[type])} className="ml-[6px]" />
) : null}
</div>
{field.enabled && type === 'FREE_TEXT' ? (
<input
type="text"
value={field.text ?? ''}
onChange={(e) => 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' ? (
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
<label className="flex items-center gap-[5px]">
<FieldLabel>{t('badges.field.size')}</FieldLabel>
<input
type="number"
min={6}
max={36}
value={field.font_size}
onChange={(e) => onChange({ font_size: Number(e.target.value) })}
className="w-[52px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
/>
</label>
<div className="flex items-center gap-[4px]">
{(['LEFT', 'CENTER', 'RIGHT'] as BadgeAlign[]).map((align) => (
<button
key={align}
type="button"
onClick={() => onChange({ align })}
className={cx(
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-mono text-[10px] font-bold',
field.align === align ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
)}
>
{align === 'LEFT' ? '⟵' : align === 'CENTER' ? '↔' : '⟶'}
</button>
))}
</div> </div>
<BadgeGenerateTab />
<button </>
type="button" )}
onClick={() => onChange({ bold: !field.bold })}
className={cx(
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-display text-[11px] font-black',
field.bold ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
)}
>
G
</button>
<input
type="color"
value={field.color}
onChange={(e) => onChange({ color: e.target.value })}
className="h-[24px] w-[30px] cursor-pointer border-2 border-ink p-0"
/>
</div>
) : null}
</div> </div>
); );
} }
@@ -734,6 +100,18 @@ function BadgeGenerateTab() {
</Chip> </Chip>
</div> </div>
{/* Un bénévole peut demander plusieurs exemplaires (fiche,
« Nombre de badges ») : le total imprimé peut dépasser le
nombre de bénévoles. */}
<span className="font-mono text-[10px] opacity-70">
{t('badges.generate.total', {
count: (mode === 'pick' ? sorted.filter((v) => picked.has(v.id)) : sorted).reduce(
(sum, v) => sum + v.badge_count,
0,
),
})}
</span>
{mode === 'pick' ? ( {mode === 'pick' ? (
<div className="max-h-[320px] overflow-auto border-2 border-ink"> <div className="max-h-[320px] overflow-auto border-2 border-ink">
{sorted.map((v) => ( {sorted.map((v) => (
@@ -748,6 +126,9 @@ function BadgeGenerateTab() {
className="h-[14px] w-[14px] accent-ink" className="h-[14px] w-[14px] accent-ink"
/> />
{v.first_name} {v.last_name} {v.first_name} {v.last_name}
{v.badge_count > 1 ? (
<span className="ml-auto opacity-60">× {v.badge_count}</span>
) : null}
</label> </label>
))} ))}
</div> </div>
+460
View File
@@ -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<FieldState>, record: boolean) => void;
onDropImage: (file: File) => void;
}
/** Aperçu d'une face du badge, éditable (glisser, redimensionner) et zone de
* dépôt de son image de fond. */
export function BadgeCanvas({
face,
fields,
data,
volunteer,
photoUrl,
imageUrl,
badgeW,
badgeH,
scale,
showGuides,
selectedType,
uploading,
onSelect,
onChange,
onDropImage,
}: BadgeCanvasProps) {
const [dropping, setDropping] = useState(false);
const shown = fields.filter((f) => f.enabled);
const isFaceSelected = shown.some((f) => f.field_type === selectedType);
return (
<div className="flex min-w-0 flex-col gap-[9px]">
<div className="flex items-baseline gap-2">
<span
className={cx(
'border-2 border-ink px-2 py-1 font-display text-[11px] font-black uppercase tracking-wider',
isFaceSelected ? 'bg-ink text-yellow' : 'bg-paper text-ink',
)}
>
{t(FACE_LABEL_KEY[face])}
</span>
<span className="font-mono text-[10px] opacity-70">
{t('badges.dims', { width: fmt(badgeW), height: fmt(badgeH) })}
</span>
</div>
<div
className={cx(
'relative flex-none select-none border-[3px] border-ink bg-sand-dark bg-contain bg-center bg-no-repeat shadow-brutal-lg',
dropping && 'outline outline-[3px] outline-offset-2 outline-blue-strong',
)}
// + 6 : bordure de 3 px de chaque côté (les champs se placent dans
// la boîte intérieure, à l'échelle exacte du badge).
style={{
width: badgeW * scale + 6,
height: badgeH * scale + 6,
backgroundImage: imageUrl ? `url(${imageUrl})` : undefined,
}}
onDragOver={(e) => {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
setDropping(true);
}}
onDragLeave={() => setDropping(false)}
onDrop={(e) => {
e.preventDefault();
setDropping(false);
const file = e.dataTransfer.files[0];
if (file) onDropImage(file);
}}
>
{/* Invitation à déposer une image : seulement sur une face encore
vide (elle passerait sous les champs sinon), ou par-dessus tout
pendant un dépôt / un envoi. */}
{uploading || dropping || (!imageUrl && shown.length === 0) ? (
<div
className={cx(
'pointer-events-none absolute inset-0 flex items-center justify-center p-3 text-center font-mono text-[10px] font-medium leading-relaxed',
uploading || dropping ? 'z-20 bg-blue/80 text-ink' : 'text-ink/60',
)}
>
{uploading
? t('badges.uploading')
: t(face === 'FRONT' ? 'badges.drop.front' : 'badges.drop.back')}
</div>
) : null}
{showGuides ? (
<div
className="pointer-events-none absolute border-[1.5px] border-dashed border-yellow opacity-90"
style={{ inset: SAFE_MARGIN_MM * scale }}
/>
) : null}
{shown.map((field) => (
<FieldBox
key={field.field_type}
field={field}
data={data}
volunteer={volunteer}
photoUrl={photoUrl}
badgeW={badgeW}
badgeH={badgeH}
scale={scale}
showGuides={showGuides}
selected={selectedType === field.field_type}
onSelect={() => onSelect(field)}
onChange={(patch, record) => onChange(field, patch, record)}
/>
))}
</div>
</div>
);
}
function FieldBox({
field,
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<FieldState>, record: boolean) => void;
}) {
const dragRef = useRef<{
mode: 'move' | 'resize';
x: number;
y: number;
w: number;
h: number;
px: number;
py: number;
moved: boolean;
} | null>(null);
const start = (mode: 'move' | 'resize') => (e: React.PointerEvent) => {
e.stopPropagation();
e.preventDefault();
onSelect();
(e.currentTarget as Element).setPointerCapture(e.pointerId);
dragRef.current = {
mode,
x: field.x_mm,
y: field.y_mm,
w: field.width_mm,
h: field.height_mm,
px: e.clientX,
py: e.clientY,
moved: false,
};
};
const move = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
e.stopPropagation();
const dx = (e.clientX - d.px) / scale;
const dy = (e.clientY - d.py) / scale;
if (!d.moved && Math.abs(dx) < 0.2 && Math.abs(dy) < 0.2) return;
const record = !d.moved;
d.moved = true;
if (d.mode === 'move') {
// Bornes jamais négatives : un champ plus grand que le badge reste
// saisissable (voir `fitToBadge`).
onChange(
{
x_mm: clamp(snap(d.x + dx), 0, Math.max(0, badgeW - d.w)),
y_mm: clamp(snap(d.y + dy), 0, Math.max(0, badgeH - d.h)),
},
record,
);
} else {
const maxW = Math.max(0, badgeW - d.x);
const maxH = Math.max(0, badgeH - d.y);
onChange(
{
width_mm: clamp(snap(d.w + dx), Math.min(MIN_W_MM, maxW), maxW),
height_mm: clamp(snap(d.h + dy), Math.min(MIN_H_MM, maxH), maxH),
},
record,
);
}
};
const end = (e: React.PointerEvent) => {
if (dragRef.current) e.stopPropagation();
dragRef.current = null;
};
const rendered = volunteer ? renderField(field, volunteer, data) : null;
const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null;
const truncated = layout?.truncated ?? false;
return (
<div
onPointerDown={start('move')}
onPointerMove={move}
onPointerUp={end}
onPointerCancel={end}
className="absolute cursor-move touch-none"
style={{
left: field.x_mm * scale,
top: field.y_mm * scale,
width: field.width_mm * scale,
height: field.height_mm * scale,
outline: selected
? '2px solid #2A6BFF'
: truncated
? '2px dashed #FF5C39'
: showGuides
? '1px dashed rgba(20,17,14,.45)'
: 'none',
// 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,
}}
>
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
) : rendered?.kind === 'table' ? (
<TableContent field={field} layout={rendered.layout} scale={scale} />
) : rendered?.kind === 'text' ? (
<TextContent field={field} layout={rendered.layout} scale={scale} />
) : null}
{truncated ? (
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none">
{t('badges.preview.truncated')}
</span>
) : null}
{selected ? (
<>
<span className="pointer-events-none absolute -bottom-[19px] left-0 z-10 whitespace-nowrap bg-blue-strong px-[5px] py-[2px] font-mono text-[9px] font-semibold leading-tight text-white">
{t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })}
{layout?.reduced
? t('badges.preview.reducedTo', { size: fmt(layout.fontSize) })
: null}
</span>
<span
onPointerDown={start('resize')}
onPointerMove={move}
onPointerUp={end}
onPointerCancel={end}
className="absolute -bottom-[7px] -right-[7px] h-[15px] w-[15px] cursor-nwse-resize touch-none border-2 border-ink bg-yellow lg:h-[13px] lg:w-[13px]"
/>
</>
) : null}
</div>
);
}
function TextContent({ field, layout, scale }: { field: FieldState; layout: TextLayout; scale: number }) {
const fontPx = layout.fontSize * MM_PER_PT * scale;
const lineHeightPx = fontPx * LINE_HEIGHT;
// Bandeaux (texte libre, rôle) centrés verticalement, les autres en haut —
// même calcul que le PDF : bloc = interlignes + une hauteur de caractère.
const blockPx = Math.max(0, layout.visible.length - 1) * lineHeightPx + fontPx;
const boxPx = field.height_mm * scale;
const offset = isVerticallyCentered(field.field_type) ? Math.max(0, (boxPx - blockPx) / 2) : 0;
const top = offset + (FIRST_BASELINE - CSS_BASELINE) * fontPx;
return (
<div
className="pointer-events-none relative h-full w-full overflow-hidden"
style={{ background: field.bg_color ?? 'transparent' }}
>
<div
className="absolute inset-x-0"
style={{
top,
color: field.color,
fontFamily: FONT_STACK,
fontSize: fontPx,
lineHeight: `${lineHeightPx}px`,
fontWeight: field.bold ? 700 : 400,
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}}
>
{layout.visible.map((line, i) => (
<div key={i} className="whitespace-pre">
{line || ' '}
</div>
))}
</div>
</div>
);
}
/** Tableau des créneaux, mêmes colonnes, hauteurs de ligne et lignes
* retenues que le PDF (`_draw_table`). */
function TableContent({ field, layout, scale }: { field: FieldState; layout: TableLayout; scale: number }) {
const fontPx = layout.fontSize * MM_PER_PT * scale;
const lineHeightPx = fontPx * LINE_HEIGHT;
const rule = `${Math.max(0.5, 0.4 * MM_PER_PT * scale)}px solid ${field.color}`;
const padX = CELL_PAD_X_MM * scale;
const padY = CELL_PAD_Y_MM * scale;
const fullWidth = layout.widthsMm.reduce((a, b) => a + b, 0) * scale;
return (
<div
className="pointer-events-none h-full w-full overflow-hidden"
style={{ background: field.bg_color ?? 'transparent' }}
>
{layout.rows.length ? (
<div
style={{
width: fullWidth,
border: rule,
color: field.color,
fontFamily: FONT_STACK,
fontSize: fontPx,
lineHeight: `${lineHeightPx}px`,
boxSizing: 'border-box',
}}
>
{layout.rows.map((row, i) => (
<div
key={i}
className="flex"
style={{
height: row.heightMm * scale,
borderTop: i > 0 ? rule : undefined,
fontWeight: row.isTitle ? 700 : 400,
}}
>
{row.cells.map((lines, col) => (
<div
key={col}
className="flex-none overflow-hidden"
style={{
width: row.isTitle ? '100%' : layout.widthsMm[col] * scale,
padding: `${padY}px ${padX}px`,
borderLeft: col > 0 ? rule : undefined,
boxSizing: 'border-box',
}}
>
{lines.map((line, j) => (
<div key={j} className="whitespace-pre">
{line || ' '}
</div>
))}
</div>
))}
</div>
))}
</div>
) : null}
</div>
);
}
function PhotoContent({
field,
volunteer,
photoUrl,
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 (
<img
src={photoUrl}
alt=""
draggable={false}
className="pointer-events-none h-full w-full object-cover"
style={{ borderRadius: radius }}
/>
);
}
return (
<div
className="pointer-events-none flex h-full w-full items-center justify-center font-bold"
style={{
borderRadius: radius,
background: PHOTO_PLACEHOLDER_BG,
color: field.color,
fontFamily: FONT_STACK,
// Comme le PDF : 0,28 × la largeur du cadre, 4 pt au moins.
fontSize: Math.max(4 * MM_PER_PT, field.width_mm * 0.28) * scale,
}}
>
{volunteer ? initials(volunteer) : null}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -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 (
<div>
<div className="mb-[6px] flex items-baseline">
<span className="font-display text-[9.5px] font-extrabold uppercase tracking-widest">{title}</span>
{aside ? <span className="ml-auto font-mono text-[9px] uppercase opacity-60">{aside}</span> : null}
</div>
{children}
</div>
);
}
function Segment({
active,
onClick,
children,
className,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={cx(
'cursor-pointer border-0 transition-colors',
active ? 'bg-ink text-yellow' : 'bg-cream text-ink hover:bg-yellow-soft',
className,
)}
>
{children}
</button>
);
}
function Swatch({
hex,
label,
active,
onClick,
}: {
hex: string | null;
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className="flex cursor-pointer flex-col items-center gap-1 border-0 bg-transparent p-0"
>
<span
className={cx(
'flex h-[30px] w-full items-center justify-center border-2 border-ink font-display text-[12px] font-black',
hex === null && 'bg-hatch-sand',
hex === '#14110E' ? 'text-yellow' : 'text-ink',
)}
style={{
background: hex ?? undefined,
boxShadow: active ? '0 0 0 3px #2A6BFF' : undefined,
}}
>
{active ? '✓' : ''}
</span>
<span className="font-display text-[9.5px] font-semibold">{label}</span>
</button>
);
}
/** Saisie d'une coordonnée en mm, appliquée à la validation (sortie du
* champ ou Entrée) pour ne pas bousculer la saisie en cours. */
function MmInput({ label, value, onCommit }: { label: string; value: number; onCommit: (v: number) => void }) {
const [text, setText] = useState(fmt(value));
useEffect(() => setText(fmt(value)), [value]);
const commit = () => {
const n = Number(text.replace(',', '.'));
if (Number.isFinite(n) && round1(n) !== round1(value)) onCommit(Math.max(0, n));
else setText(fmt(value));
};
return (
<label className="flex items-stretch border-2 border-ink bg-cream">
<span className="flex w-[30px] flex-none items-center justify-center border-r-2 border-ink bg-sand font-display text-[11px] font-extrabold">
{label}
</span>
<input
inputMode="decimal"
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') commit();
}}
className="min-w-0 flex-1 border-0 bg-transparent px-[7px] py-2 font-mono text-[13px] font-semibold"
/>
</label>
);
}
export function BadgeInspector({
field,
badgeW,
badgeH,
effectiveFontSize,
photoCount,
volunteerCount,
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<FieldState>) => void;
onHide: () => void;
}) {
const isPhoto = field.field_type === 'PHOTO';
const lowContrast = Boolean(field.bg_color) && contrastRatio(field.color, field.bg_color as string) < MIN_CONTRAST;
const reduced = effectiveFontSize !== null && effectiveFontSize < field.font_size;
const setGeometry = (patch: Partial<FieldState>) =>
onChange(fitToBadge({ ...field, ...patch }, badgeW, badgeH));
const fontSize = (delta: number) =>
onChange({
font_size: Math.min(MAX_FONT_PT, Math.max(MIN_FONT_PT, round1(field.font_size + delta))),
});
return (
<div>
<div className="bg-ink px-3 py-[10px] text-cream">
<div className="font-mono text-[9.5px] uppercase text-yellow">
{t('badges.inspector.kicker', { face: t(FACE_LABEL_KEY[field.face]) })}
</div>
<div className="mt-[5px] font-display text-[17px] font-black leading-tight tracking-tight">
{t(FIELD_LABEL_KEY[field.field_type])}
</div>
</div>
<div className="flex flex-col gap-[15px] p-3">
{field.field_type === 'FREE_TEXT' ? (
<label className="block">
<span className="mb-[6px] block font-display text-[9.5px] font-extrabold uppercase tracking-widest">
{t('badges.inspector.content')}
</span>
<textarea
value={field.text ?? ''}
onChange={(e) => onChange({ text: e.target.value })}
rows={2}
placeholder={t('badges.field.freeText.input')}
className="w-full resize-y border-2 border-ink bg-cream p-2 font-display text-[13px] font-semibold leading-snug"
/>
<span className="mt-1 block font-mono text-[9px] leading-normal opacity-60">
{t('badges.inspector.contentHelp')}
</span>
</label>
) : null}
{!isPhoto ? (
<>
<Section
title={t('badges.inspector.fontSize')}
aside={
reduced
? t('badges.inspector.reducedHere', { size: fmt(effectiveFontSize as number) })
: t('badges.inspector.capHeight', { mm: fmt(field.font_size * MM_PER_PT) })
}
>
<div className="flex items-stretch border-2 border-ink">
<button
type="button"
onClick={() => fontSize(-FONT_STEP_PT)}
aria-label={t('badges.inspector.smaller')}
className="h-10 w-11 cursor-pointer border-0 border-r-2 border-ink bg-cream font-display text-[16px] font-black hover:bg-yellow"
>
−
</button>
<span className="flex flex-1 items-center justify-center font-mono text-[17px] font-bold">
{t('badges.pt', { size: fmt(field.font_size) })}
</span>
<button
type="button"
onClick={() => fontSize(FONT_STEP_PT)}
aria-label={t('badges.inspector.larger')}
className="h-10 w-11 cursor-pointer border-0 border-l-2 border-ink bg-cream font-display text-[16px] font-black hover:bg-yellow"
>
+
</button>
</div>
<button
type="button"
role="switch"
aria-checked={field.auto_fit}
onClick={() => onChange({ auto_fit: !field.auto_fit })}
className="mt-[7px] flex w-full cursor-pointer items-center gap-[9px] border-0 bg-transparent px-0 py-[3px] text-left"
>
<span
className={cx(
'relative h-5 w-[34px] flex-none border-2 border-ink',
field.auto_fit ? 'bg-yellow' : 'bg-sand-dark',
)}
>
<span
className="absolute top-[1px] h-[14px] w-[14px] bg-ink"
style={{ left: field.auto_fit ? 15 : 1 }}
/>
</span>
<span className="font-display text-[11.5px] font-bold leading-snug">
{t('badges.inspector.autoFit')}
</span>
</button>
</Section>
<Section title={t('badges.inspector.align')}>
<div className="grid grid-cols-3 border-2 border-ink">
{ALIGNS.map((a, i) => (
<Segment
key={a.value}
active={field.align === a.value}
onClick={() => onChange({ align: a.value })}
className={cx(
'flex h-[46px] flex-col items-center justify-center gap-1',
i < 2 && 'border-r-2 border-ink',
)}
>
<span className={cx('flex w-[18px] flex-col gap-[2px]', a.items)}>
<span className="h-[2px] w-[18px] bg-current" />
<span className="h-[2px] w-[11px] bg-current" />
<span className="h-[2px] w-[15px] bg-current" />
</span>
<span className="font-display text-[9.5px] font-bold">{t(a.key)}</span>
</Segment>
))}
</div>
</Section>
<Section title={t('badges.inspector.weight')}>
<div className="grid grid-cols-2 border-2 border-ink">
<Segment
active={!field.bold}
onClick={() => onChange({ bold: false })}
className="h-[38px] border-r-2 border-ink font-display text-[13px] font-medium"
>
{t('badges.inspector.regular')}
</Segment>
<Segment
active={field.bold}
onClick={() => onChange({ bold: true })}
className="h-[38px] font-display text-[13px] font-black"
>
{t('badges.inspector.bold')}
</Segment>
</div>
</Section>
<Section title={t('badges.inspector.textColor')}>
<div className="grid grid-cols-5 gap-[6px]">
{TEXT_COLORS.map((c) => (
<Swatch
key={c.hex}
hex={c.hex}
label={t(c.key)}
active={sameColor(field.color, c.hex)}
onClick={() => onChange({ color: c.hex })}
/>
))}
<label className="flex cursor-pointer flex-col items-center gap-1">
<input
type="color"
value={field.color}
onChange={(e) => onChange({ color: e.target.value })}
className={cx(
'h-[30px] w-full cursor-pointer border-2 border-ink bg-cream p-0',
!TEXT_COLORS.some((c) => sameColor(c.hex, field.color)) &&
'shadow-[0_0_0_3px_#2A6BFF]',
)}
/>
<span className="font-display text-[9.5px] font-semibold">
{t('badges.color.custom')}
</span>
</label>
</div>
</Section>
<Section title={t('badges.inspector.background')}>
<div className="grid grid-cols-4 gap-[6px]">
{BG_COLORS.map((c) => (
<Swatch
key={c.key}
hex={c.hex}
label={t(c.key)}
active={sameColor(field.bg_color, c.hex)}
onClick={() => onChange({ bg_color: c.hex })}
/>
))}
</div>
{lowContrast ? (
<div className="mt-[7px] border-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
{t('badges.inspector.lowContrast')}
</div>
) : null}
</Section>
</>
) : (
<Section title={t('badges.inspector.photoShape')}>
<div className="grid grid-cols-2 border-2 border-ink">
<Segment
active={field.photo_shape === 'RECT'}
onClick={() => onChange({ photo_shape: 'RECT' })}
className="h-10 border-r-2 border-ink font-display text-[12px] font-extrabold"
>
{t('badges.inspector.photoRect')}
</Segment>
<Segment
active={field.photo_shape === 'OVAL'}
onClick={() => onChange({ photo_shape: 'OVAL' })}
className="h-10 font-display text-[12px] font-extrabold"
>
{t('badges.inspector.photoOval')}
</Segment>
</div>
<div className="mt-[7px] font-mono text-[9.5px] leading-relaxed opacity-65">
{t('badges.inspector.photoStat', { count: photoCount, total: volunteerCount })}
</div>
</Section>
)}
<div className="border-t-2 border-ink pt-3">
<div className="mb-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-widest">
{t('badges.inspector.geometry')}
</div>
<div className="grid grid-cols-2 gap-[7px]">
<MmInput label={t('badges.inspector.x')} value={field.x_mm} onCommit={(v) => setGeometry({ x_mm: v })} />
<MmInput label={t('badges.inspector.y')} value={field.y_mm} onCommit={(v) => setGeometry({ y_mm: v })} />
<MmInput
label={t('badges.inspector.w')}
value={field.width_mm}
onCommit={(v) => setGeometry({ width_mm: Math.max(1, v) })}
/>
<MmInput
label={t('badges.inspector.h')}
value={field.height_mm}
onCommit={(v) => setGeometry({ height_mm: Math.max(1, v) })}
/>
</div>
<div className="mt-[7px] grid grid-cols-2 gap-[7px]">
<button
type="button"
onClick={() => setGeometry({ x_mm: (badgeW - field.width_mm) / 2 })}
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft"
>
{t('badges.inspector.center')}
</button>
<button
type="button"
onClick={() => setGeometry({ x_mm: 0, width_mm: badgeW })}
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft"
>
{t('badges.inspector.fullWidth')}
</button>
</div>
</div>
<button
type="button"
onClick={onHide}
className="cursor-pointer border-2 border-ink bg-paper p-[10px] font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-orange"
>
{t('badges.inspector.hide')}
</button>
</div>
</div>
);
}
@@ -0,0 +1,32 @@
// Métriques de la police Helvetica (et Helvetica-Bold) des 14 polices
// standard PDF, telles que ReportLab les utilise pour `stringWidth`
// (`backend/app/badges/pdf.py:wrap_text`) : largeur de chaque caractère du
// codage WinAnsi, en millièmes de la taille de police. Recopiées des fichiers
// `reportlab/pdfbase/_fontdata_widths_helvetica*.py` — mesurer avec ces mêmes
// chiffres (plutôt qu'avec la police que le navigateur trouve sous le nom
// « Helvetica ») fait tomber les retours à la ligne de l'aperçu au même
// endroit que dans le PDF, et marche aussi hors navigateur (smoke).
const CHARS = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ ¡¢£¤¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ";
const REGULAR = [278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556,1015,667,667,722,722,667,611,778,722,278,500,667,556,833,722,778,667,778,722,667,611,722,667,944,667,667,611,278,278,278,469,556,333,556,556,500,556,556,278,556,556,222,222,500,222,833,556,556,556,556,333,500,278,556,500,722,500,500,500,334,260,334,584,350,556,222,556,333,1000,556,556,333,1000,667,333,1000,611,222,222,333,333,350,556,1000,333,1000,500,333,944,500,667,278,333,556,556,556,556,260,556,333,737,370,556,584,333,737,333,400,584,333,333,333,556,537,278,333,333,365,556,834,834,834,611,667,667,667,667,667,667,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,500,556,556,556,556,278,278,278,278,556,556,556,556,556,556,556,584,611,556,556,556,556,500,556,500];
const BOLD = [278,333,474,556,556,889,722,238,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,333,333,584,584,584,611,975,722,722,722,722,667,611,778,722,278,556,722,611,833,722,778,667,778,722,667,611,722,667,944,667,667,611,333,278,333,584,556,333,556,611,556,611,556,333,611,611,278,278,556,278,889,611,611,611,611,389,556,333,611,556,778,556,556,500,389,280,389,584,350,556,278,556,500,1000,556,556,333,1000,667,333,1000,611,278,278,500,500,350,556,1000,333,1000,556,333,944,500,667,278,333,556,556,556,556,280,556,333,737,370,556,584,333,737,333,400,584,333,333,333,611,556,278,333,333,365,556,834,834,834,611,722,722,722,722,722,722,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,556,556,556,556,556,278,278,278,278,611,611,611,611,611,611,611,584,611,611,611,611,611,556,611,556];
/** Largeur (millièmes d'em) d'un caractère hors WinAnsi : ReportLab le
* remplacerait par une police de substitution, on prend une largeur moyenne. */
const FALLBACK_WIDTH = 556;
const regularWidths = new Map<string, number>();
const boldWidths = new Map<string, number>();
for (let i = 0; i < CHARS.length; i++) {
regularWidths.set(CHARS[i], REGULAR[i]);
boldWidths.set(CHARS[i], BOLD[i]);
}
/** Largeur de `text` en points, à la taille `size` (pt) — équivalent de
* `reportlab.pdfbase.pdfmetrics.stringWidth(text, "Helvetica[-Bold]", size)`. */
export function stringWidthPt(text: string, bold: boolean, size: number): number {
const widths = bold ? boldWidths : regularWidths;
let total = 0;
for (const ch of text) total += widths.get(ch) ?? FALLBACK_WIDTH;
return (total * size) / 1000;
}
+127
View File
@@ -0,0 +1,127 @@
// Contrôle avant génération : passe chaque champ affiché sur TOUS les
// bénévoles (estimation de mise en page, voir `layout.ts`) et relève ce qui
// risque de mal s'imprimer.
import type { BadgeFace, BadgeFieldType } from '@/api/schema';
import { contrastRatio, MIN_CONTRAST } from './layout';
import { renderField, SAFE_MARGIN_MM, type BadgeData, type FieldRef, type FieldState } from './model';
export interface FieldStats {
/** Bénévoles dont le texte a été réduit automatiquement (sans être coupé). */
reduced: number;
/** Bénévoles dont le texte reste coupé. */
truncated: number;
/** Plus petite taille atteinte par la réduction automatique. */
minPt: number;
/** Index (dans `BadgeData.volunteers`) d'un exemple de chaque cas. */
reducedExample: number | null;
truncatedExample: number | null;
}
export type IssueKind = 'truncated' | 'reduced' | 'margin' | 'contrast' | 'noPhoto';
export interface BadgeIssue {
kind: IssueKind;
critical: boolean;
ref: FieldRef;
count: number;
minPt: number;
/** Bénévole à afficher pour « Voir un exemple » (null : pas d'exemple). */
exampleIndex: number | null;
/** « Activer la réduction auto » proposé (texte coupé, réduction coupée). */
canEnableAutoFit: boolean;
}
export function statsKey(face: BadgeFace, type: BadgeFieldType): string {
return `${face}:${type}`;
}
export function computeIssues(
fields: FieldState[],
data: BadgeData,
badgeW: number,
badgeH: number,
): { issues: BadgeIssue[]; stats: Map<string, FieldStats> } {
const stats = new Map<string, FieldStats>();
const issues: BadgeIssue[] = [];
for (const field of fields) {
if (!field.enabled) continue;
const ref = { face: field.face, type: field.field_type };
const base = { ref, count: 0, minPt: field.font_size, exampleIndex: null, canEnableAutoFit: false };
if (field.field_type !== 'PHOTO') {
const s: FieldStats = {
reduced: 0,
truncated: 0,
minPt: field.font_size,
reducedExample: null,
truncatedExample: null,
};
data.volunteers.forEach((volunteer, index) => {
const rendered = renderField(field, volunteer, data);
if (rendered.kind === 'photo') return;
const { truncated, reduced, fontSize } = rendered.layout;
if (truncated) {
s.truncated += 1;
if (s.truncatedExample === null) s.truncatedExample = index;
} else if (reduced) {
s.reduced += 1;
if (fontSize < s.minPt) {
s.minPt = fontSize;
s.reducedExample = index;
}
}
});
stats.set(statsKey(field.face, field.field_type), s);
if (s.truncated) {
issues.push({
...base,
kind: 'truncated',
critical: true,
count: s.truncated,
exampleIndex: s.truncatedExample,
canEnableAutoFit: !field.auto_fit,
});
} else if (s.reduced) {
issues.push({
...base,
kind: 'reduced',
critical: false,
count: s.reduced,
minPt: s.minPt,
exampleIndex: s.reducedExample,
});
}
if (field.bg_color && contrastRatio(field.color, field.bg_color) < MIN_CONTRAST) {
issues.push({ ...base, kind: 'contrast', critical: true });
}
} else {
const missing = data.volunteers.findIndex((v) => !v.has_photo);
if (missing >= 0) {
issues.push({
...base,
kind: 'noPhoto',
critical: false,
count: data.volunteers.filter((v) => !v.has_photo).length,
exampleIndex: missing,
});
}
}
// Un bandeau coloré (fond de zone) peut volontairement aller jusqu'au
// bord : seul un champ sans fond qui touche la marge est signalé.
const bleed = field.bg_color !== null && field.field_type !== 'PHOTO';
const m = SAFE_MARGIN_MM - 1e-6;
const touchesEdge =
field.x_mm < m ||
field.y_mm < m ||
field.x_mm + field.width_mm > badgeW - m ||
field.y_mm + field.height_mm > badgeH - m;
if (touchesEdge && !bleed) issues.push({ ...base, kind: 'margin', critical: false });
}
issues.sort((a, b) => Number(b.critical) - Number(a.critical));
return { issues, stats };
}
+238
View File
@@ -0,0 +1,238 @@
// Estimation de la mise en page d'un champ du badge, côté navigateur — même
// algorithme que la génération PDF (`backend/app/badges/pdf.py` : `wrap_text`,
// `_draw_table`, mêmes métriques Helvetica via `stringWidthPt`). Sert à
// l'aperçu (retours à la ligne, réduction automatique, lignes coupées) et au
// contrôle avant génération. Ce n'est qu'une estimation : le PDF reste la
// référence.
import { stringWidthPt } from './helveticaMetrics';
/** 1 pt = 25,4 / 72 mm. */
export const MM_PER_PT = 25.4 / 72;
/** Interligne, en multiple de la taille de police (`_LINE_HEIGHT_FACTOR`). */
export const LINE_HEIGHT = 1.25;
/** Position de la première ligne de base sous le haut du cadre, en multiple
* de la taille de police (`font_size * 0.85` dans le PDF). */
export const FIRST_BASELINE = 0.85;
export const MIN_FONT_PT = 4;
export const MAX_FONT_PT = 30;
export const FONT_STEP_PT = 0.5;
/** Marges intérieures des cellules du tableau des créneaux (mm). */
export const CELL_PAD_X_MM = 0.8;
export const CELL_PAD_Y_MM = 0.5;
/** Découpe `text` en lignes de largeur au plus `maxWidthPt` — portage de
* `wrap_text` : les `\n` restent des retours à la ligne, puis passage à la
* ligne entre deux mots, ou au milieu d'un mot qui seul ne tient pas. */
export function wrapText(text: string, bold: boolean, size: number, maxWidthPt: number): string[] {
const fits = (candidate: string) => stringWidthPt(candidate, bold, size) <= maxWidthPt;
const lines: string[] = [];
for (const paragraph of text.split('\n')) {
let current = '';
for (const word of paragraph.split(' ')) {
const candidate = current ? `${current} ${word}` : word;
if (fits(candidate)) {
current = candidate;
continue;
}
if (current) lines.push(current.trimEnd());
current = word;
while (current && !fits(current)) {
let cut = current.length - 1;
while (cut > 1 && !fits(current.slice(0, cut))) cut -= 1;
lines.push(current.slice(0, cut));
current = current.slice(cut);
}
}
lines.push(current.trimEnd());
}
return lines;
}
/** Nombre de lignes qui tiennent dans `heightMm` : une ligne est dessinée
* tant que sa ligne de base reste dans le cadre (règle du PDF). */
export function linesThatFit(size: number, heightMm: number): number {
const sizeMm = size * MM_PER_PT;
if (FIRST_BASELINE * sizeMm > heightMm) return 0;
return Math.floor((heightMm - FIRST_BASELINE * sizeMm) / (LINE_HEIGHT * sizeMm) + 1e-9) + 1;
}
export interface TextLayout {
/** Taille réellement utilisée (réduite si `reduced`). */
fontSize: number;
/** Toutes les lignes après retour à la ligne. */
lines: string[];
/** Lignes imprimées (les suivantes débordent du cadre). */
visible: string[];
reduced: boolean;
truncated: boolean;
}
interface FitOptions {
fontSize: number;
autoFit: boolean;
}
/** Réduction automatique commune au texte et au tableau : à la taille
* demandée si tout tient ; sinon, si `autoFit`, par pas de 0,5 pt jusqu'à
* 4 pt ; le texte reste coupé s'il ne tient toujours pas. */
function withAutoFit<L>(
{ fontSize, autoFit }: FitOptions,
layoutAt: (size: number) => L,
isComplete: (layout: L) => boolean,
): { layout: L; fontSize: number; reduced: boolean; truncated: boolean } {
let size = fontSize;
let layout = layoutAt(size);
if (isComplete(layout)) return { layout, fontSize: size, reduced: false, truncated: false };
if (!autoFit) return { layout, fontSize: size, reduced: false, truncated: true };
while (size - FONT_STEP_PT >= MIN_FONT_PT - 1e-9) {
size = Math.round((size - FONT_STEP_PT) * 10) / 10;
layout = layoutAt(size);
if (isComplete(layout)) return { layout, fontSize: size, reduced: true, truncated: false };
}
return { layout, fontSize: size, reduced: size < fontSize, truncated: true };
}
export function layoutText(
text: string,
box: { width_mm: number; height_mm: number; bold: boolean; font_size: number; auto_fit: boolean },
): TextLayout {
const result = withAutoFit(
{ fontSize: box.font_size, autoFit: box.auto_fit },
(size) => {
const lines = text ? wrapText(text, box.bold, size, box.width_mm / MM_PER_PT) : [];
return { lines, capacity: linesThatFit(size, box.height_mm) };
},
({ lines, capacity }) => lines.length <= capacity,
);
const { lines, capacity } = result.layout;
return {
fontSize: result.fontSize,
lines,
visible: lines.slice(0, capacity),
reduced: result.reduced,
truncated: result.truncated,
};
}
// --------------------------------------------------------------------------- //
// Tableau des créneaux (`POSITIONS`) //
// --------------------------------------------------------------------------- //
export interface TableSection {
title: string;
rows: string[][];
}
export interface TableRowLayout {
/** Lignes de texte de chaque cellule (une seule cellule pour un en-tête). */
cells: string[][];
isTitle: boolean;
heightMm: number;
}
export interface TableLayout {
fontSize: number;
/** Largeur de chaque colonne (mm). */
widthsMm: number[];
/** Lignes du tableau qui tiennent dans le cadre. */
rows: TableRowLayout[];
reduced: boolean;
truncated: boolean;
}
/** Portage de `_column_widths` : une colonne courte garde sa largeur
* naturelle, les plus longues se partagent le reste. */
function columnWidths(natural: number[], available: number): number[] {
const widths = natural.map(() => 0);
const pending = natural.map((_, i) => i).sort((a, b) => natural[a] - natural[b]);
let remaining = available;
while (pending.length) {
const share = remaining / pending.length;
if (natural[pending[0]] > share) {
for (const i of pending) widths[i] = share;
return widths;
}
const i = pending.shift() as number;
widths[i] = natural[i];
remaining -= natural[i];
}
widths[widths.length - 1] += remaining;
return widths;
}
export function layoutTable(
sections: TableSection[],
box: { width_mm: number; height_mm: number; font_size: number; auto_fit: boolean },
): TableLayout {
const total = sections.reduce((n, s) => n + 1 + s.rows.length, 0);
const result = withAutoFit(
{ fontSize: box.font_size, autoFit: box.auto_fit },
(size) => {
const body = sections.flatMap((s) => s.rows);
const columns = body.length ? Math.max(...body.map((r) => r.length)) : 1;
const natural = body.length
? Array.from({ length: columns }, (_, col) =>
Math.max(...body.map((r) => stringWidthPt(r[col] ?? '', false, size) * MM_PER_PT)) +
2 * CELL_PAD_X_MM,
)
: [0];
const widthsMm = columnWidths(natural, box.width_mm);
const fullWidth = widthsMm.reduce((a, b) => a + b, 0);
const lineMm = size * MM_PER_PT * LINE_HEIGHT;
const rows: TableRowLayout[] = [];
let used = 0;
outer: for (const section of sections) {
const entries: Array<{ cells: string[]; isTitle: boolean }> = [
{ cells: [section.title], isTitle: true },
...section.rows.map((cells) => ({ cells, isTitle: false })),
];
for (const entry of entries) {
const widths = entry.isTitle ? [fullWidth] : widthsMm;
const cells = entry.cells.map((text, col) =>
wrapText(
text,
entry.isTitle,
size,
Math.max(1, (widths[col] - 2 * CELL_PAD_X_MM) / MM_PER_PT),
),
);
const heightMm = Math.max(...cells.map((c) => c.length)) * lineMm + 2 * CELL_PAD_Y_MM;
if (used + heightMm > box.height_mm + 0.01 * MM_PER_PT) break outer;
rows.push({ cells, isTitle: entry.isTitle, heightMm });
used += heightMm;
}
}
return { widthsMm, rows };
},
(layout) => layout.rows.length >= total,
);
return {
fontSize: result.fontSize,
widthsMm: result.layout.widthsMm,
rows: result.layout.rows,
reduced: result.reduced,
truncated: result.truncated,
};
}
// --------------------------------------------------------------------------- //
// Contraste //
// --------------------------------------------------------------------------- //
function luminance(hex: string): number {
const n = parseInt(hex.slice(1, 7), 16);
const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => {
const x = c / 255;
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
/** Rapport de contraste WCAG entre deux couleurs `#rrggbb`. */
export function contrastRatio(a: string, b: string): number {
const la = luminance(a);
const lb = luminance(b);
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
}
/** En dessous, le texte risque d'être illisible à l'impression. */
export const MIN_CONTRAST = 3;
+268
View File
@@ -0,0 +1,268 @@
// État de l'éditeur de badges et contenu réel de chaque champ pour un
// bénévole donné — mêmes règles que la génération PDF
// (`backend/app/api/badges.py:_field_text`, `_positions_table`).
import type {
BadgeFace,
BadgeFieldIn,
BadgeFieldRead,
BadgeFieldType,
PositionRead,
ShiftRead,
VolunteerRead,
} from '@/api/schema';
import { t } from '@/i18n';
import { categoryLabel, festivalDay } from '@/lib/planning';
import { longDayLabel, timePart } from '@/lib/time';
import { layoutTable, layoutText, type TableLayout, type TableSection, type TextLayout } from './layout';
export const DEFAULT_BADGE_W_MM = 85.6;
export const DEFAULT_BADGE_H_MM = 54;
/** Marge de découpe (mm) : un champ qui la touche risque d'être rogné. */
export const SAFE_MARGIN_MM = 1.5;
export const FACES: BadgeFace[] = ['FRONT', 'BACK'];
export const FIELD_TYPES: BadgeFieldType[] = [
'PHOTO',
'FULL_NAME',
'POSITIONS',
'CATEGORY',
'ROLE',
'FREE_TEXT',
];
export const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName',
POSITIONS: 'badges.field.positions',
CATEGORY: 'badges.field.category',
ROLE: 'badges.field.role',
PHOTO: 'badges.field.photo',
FREE_TEXT: 'badges.field.freeText',
};
export const FACE_LABEL_KEY: Record<BadgeFace, string> = {
FRONT: 'badges.face.front',
BACK: 'badges.face.back',
};
/** Formats prédéfinis (mm). */
export const BADGE_PRESETS: Array<{ key: string; width: number; height: number }> = [
{ key: 'badges.preset.cr80Portrait', width: 54, height: 85.6 },
{ key: 'badges.preset.cr80Landscape', width: 85.6, height: 54 },
{ key: 'badges.preset.a7', width: 74, height: 105 },
];
export type FieldState = BadgeFieldIn & { enabled: boolean };
export interface FieldRef {
face: BadgeFace;
type: BadgeFieldType;
}
export function sameRef(a: FieldRef | null, b: FieldRef | null): boolean {
return Boolean(a && b && a.face === b.face && a.type === b.type);
}
type FieldDefaults = Omit<BadgeFieldIn, 'field_type' | 'face'>;
const COMMON = { bg_color: null, auto_fit: true, photo_shape: 'RECT' } as const;
// Position/taille par défaut à l'activation d'un champ — empilés
// verticalement pour ne pas se superposer (repères pour un badge CR80
// paysage, ramenés dans le badge par `fitToBadge` pour un autre format).
const FIELD_DEFAULTS: Record<BadgeFieldType, FieldDefaults> = {
FULL_NAME: {
...COMMON, x_mm: 5, y_mm: 4, width_mm: 50, height_mm: 11,
font_size: 11, align: 'LEFT', bold: true, color: '#14110E',
},
POSITIONS: {
...COMMON, x_mm: 5, y_mm: 16, width_mm: 52, height_mm: 22,
font_size: 6, align: 'LEFT', bold: false, color: '#14110E',
},
CATEGORY: {
...COMMON, x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 5,
font_size: 7, align: 'LEFT', bold: true, color: '#14110E',
},
ROLE: {
...COMMON, x_mm: 5, y_mm: 45, width_mm: 30, height_mm: 5,
font_size: 7, align: 'CENTER', bold: true, color: '#14110E', bg_color: '#FFD23F',
},
// Format portrait d'identité, en haut à droite. Taille de police/couleur
// sans effet : la photo est une image.
PHOTO: {
...COMMON, x_mm: 60, y_mm: 4, width_mm: 21, height_mm: 27,
font_size: 8, align: 'CENTER', bold: false, color: '#14110E',
},
FREE_TEXT: {
...COMMON, x_mm: 40, y_mm: 45, width_mm: 40, height_mm: 5,
font_size: 7, align: 'CENTER', bold: true, color: '#FFFFFF', bg_color: '#14110E',
},
};
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/** Ramène un champ à l'intérieur du badge (réduit s'il est plus grand,
* décalé s'il dépasse). Sur un badge plus étroit que le CR80 paysage des
* positions par défaut, un champ activé pouvait tomber hors du badge —
* invisible, et les bornes négatives du glisser-déposer le bloquaient (bug
* corrigé une fois : photo impossible à déplacer ni redimensionner). */
export function fitToBadge<F extends BadgeFieldIn>(field: F, badgeW: number, badgeH: number): F {
const width_mm = Math.min(field.width_mm, badgeW);
const height_mm = Math.min(field.height_mm, badgeH);
return {
...field,
width_mm,
height_mm,
x_mm: clamp(field.x_mm, 0, badgeW - width_mm),
y_mm: clamp(field.y_mm, 0, badgeH - height_mm),
};
}
/** Les 12 champs possibles (6 types × 2 faces), activés ou non, dans
* l'ordre d'affichage. */
export function fieldsFromServer(
saved: BadgeFieldRead[],
badgeW: number,
badgeH: number,
): FieldState[] {
const out: FieldState[] = [];
for (const face of FACES) {
for (const type of FIELD_TYPES) {
const existing = saved.find((f) => (f.face ?? 'FRONT') === face && f.field_type === type);
const field: FieldState = existing
? {
field_type: type,
face,
x_mm: existing.x_mm,
y_mm: existing.y_mm,
width_mm: existing.width_mm,
height_mm: existing.height_mm,
font_size: existing.font_size,
align: existing.align,
bold: existing.bold,
color: existing.color,
bg_color: existing.bg_color ?? null,
auto_fit: existing.auto_fit ?? true,
photo_shape: existing.photo_shape ?? 'RECT',
text: existing.text ?? null,
enabled: true,
}
: { field_type: type, face, ...FIELD_DEFAULTS[type], text: null, enabled: false };
out.push(fitToBadge(field, badgeW, badgeH));
}
}
return out;
}
/** Corps du `PUT .../badges/template/fields` : les champs activés des deux
* faces. */
export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
return fields
.filter((f) => f.enabled)
.map((f) => ({
field_type: f.field_type,
face: f.face,
x_mm: round1(f.x_mm),
y_mm: round1(f.y_mm),
width_mm: round1(f.width_mm),
height_mm: round1(f.height_mm),
font_size: f.font_size,
align: f.align,
bold: f.bold,
color: f.color,
bg_color: f.bg_color,
auto_fit: f.auto_fit,
photo_shape: f.photo_shape,
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
}));
}
export function round1(value: number): number {
return Math.round(value * 10) / 10;
}
/** Nombre français à une décimale au plus (« 8,5 »). */
export function fmt(value: number): string {
return String(round1(value)).replace('.', ',');
}
// --------------------------------------------------------------------------- //
// Contenu réel d'un champ pour un bénévole //
// --------------------------------------------------------------------------- //
export interface BadgeData {
/** Dans l'ordre d'impression : nom puis prénom (comme le PDF). */
volunteers: VolunteerRead[];
/** Créneaux affectés de chaque bénévole, dans l'ordre chronologique. */
assignments: Map<string, Array<{ position: PositionRead; shift: ShiftRead }>>;
/** Bénévoles dont le compte est référent (`LEAD`). */
leadVolunteerIds: Set<string>;
/** Début de « journée de festival » (`HH:MM`). */
dayBoundary: string;
}
export function initials(volunteer: VolunteerRead): string {
return `${volunteer.first_name.trim().charAt(0)}${volunteer.last_name.trim().charAt(0)}`.toUpperCase();
}
export function fullNameText(volunteer: VolunteerRead): string {
return `${volunteer.first_name}\n${volunteer.last_name}`;
}
/** Texte d'un champ (hors `POSITIONS`, un tableau, et `PHOTO`). */
export function fieldText(field: FieldState, volunteer: VolunteerRead, data: BadgeData): string {
switch (field.field_type) {
case 'FULL_NAME':
return fullNameText(volunteer);
case 'FREE_TEXT':
// Texte commun saisi dans l'éditeur, remplacé pour un bénévole par le
// « Texte du badge » de sa fiche s'il en a un.
return volunteer.badge_text || field.text || '';
case 'CATEGORY': {
const rows = data.assignments.get(volunteer.id) ?? [];
return [...new Set(rows.map((r) => categoryLabel(r.position.category)))].sort().join(', ');
}
case 'ROLE':
return data.leadVolunteerIds.has(volunteer.id) ? t('role.lead') : t('role.volunteer');
default:
return '';
}
}
/** Créneaux regroupés par jour de festival : le jour en en-tête, puis une
* ligne horaire / poste par créneau. */
export function positionsTable(volunteer: VolunteerRead, data: BadgeData): TableSection[] {
const byDay = new Map<string, string[][]>();
for (const { position, shift } of data.assignments.get(volunteer.id) ?? []) {
const day = festivalDay(shift.starts_at, data.dayBoundary);
const rows = byDay.get(day) ?? [];
rows.push([`${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`, position.name]);
byDay.set(day, rows);
}
return [...byDay.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, rows]) => ({ title: longDayLabel(day), rows }));
}
export type FieldRender =
| { kind: 'text'; layout: TextLayout }
| { kind: 'table'; layout: TableLayout }
| { kind: 'photo' };
/** Mise en page estimée d'un champ pour un bénévole. */
export function renderField(field: FieldState, volunteer: VolunteerRead, data: BadgeData): FieldRender {
if (field.field_type === 'PHOTO') return { kind: 'photo' };
if (field.field_type === 'POSITIONS') {
return { kind: 'table', layout: layoutTable(positionsTable(volunteer, data), field) };
}
return { kind: 'text', layout: layoutText(fieldText(field, volunteer, data), field) };
}
/** Champs dont le texte est centré verticalement dans le cadre (bandeaux) ;
* les autres partent du haut. */
export function isVerticallyCentered(type: BadgeFieldType): boolean {
return type === 'FREE_TEXT' || type === 'ROLE';
}
@@ -169,6 +169,43 @@ const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status
EXPIRED: 'config.vol.feeStatus.expired', EXPIRED: 'config.vol.feeStatus.expired',
}; };
/**
* Nombre d'exemplaires du badge imprimés sur les planches (1 à 100). Saisie
* libre gardée en local : n'enregistre qu'un entier valide, et revient à la
* dernière valeur enregistrée si le champ est quitté vide ou hors bornes.
*/
function BadgeCountInput({ value, onChange }: { value: number; onChange: (count: number) => void }) {
const [draft, setDraft] = useState(String(value));
const parse = (raw: string) => {
const n = Number(raw);
return Number.isInteger(n) && n >= 1 && n <= 100 ? n : null;
};
return (
<label className="block">
<FieldLabel>
{t('config.vol.field.badgeCount')}
<HelpTip text={t('config.vol.field.badgeCount.help')} className="ml-[6px]" />
</FieldLabel>
<input
type="number"
min={1}
max={100}
step={1}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
const n = parse(e.target.value);
if (n !== null && n !== value) onChange(n);
}}
onBlur={() => {
if (parse(draft) === null) setDraft(String(value));
}}
className="w-[90px] border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[13px] font-bold"
/>
</label>
);
}
/** /**
* Photo du bénévole (badge, écrans bénévole/référent, feuilles de poste) : * Photo du bénévole (badge, écrans bénévole/référent, feuilles de poste) :
* celle envoyée au formulaire public, ou ajoutée ici par la coordination * celle envoyée au formulaire public, ou ajoutée ici par la coordination
@@ -960,6 +997,11 @@ export function ConfigVolunteers() {
onChange={(v) => patch({ badge_text: v || null })} onChange={(v) => patch({ badge_text: v || null })}
/> />
</label> </label>
<BadgeCountInput
key={`${selected.id}-${selected.badge_count}`}
value={selected.badge_count}
onChange={(badge_count) => patch({ badge_count })}
/>
<VolunteerPhotoSection key={selected.id} volunteer={selected} /> <VolunteerPhotoSection key={selected.id} volunteer={selected} />
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} /> <VolunteerAccessSection volunteerId={selected.id} email={selected.email} />