Compare commits

..
2 Commits
Author SHA1 Message Date
AdamThomasandClaude Opus 5.5 1a23881e81 Badges : retour à la ligne et créneaux en tableau par jour
ci-cd / build-backend (push) Successful in 37s
ci-cd / build-frontend (push) Successful in 22s
ci-cd / deploy (push) Successful in 13s
Le texte des champs passe à la ligne pour tenir dans la largeur de son
cadre (wrap_text) au lieu de déborder. 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 (plus de référent sur le badge) ; même tableau dans
l'aperçu de l'éditeur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:44:55 +01:00
AdamThomasandClaude Opus 5.5 cfed76c239 Badges : texte libre saisi dans l'éditeur, champs ramenés dans le badge
Texte libre commun à tous les badges, saisi dans l'éditeur
(BadgeField.text, migration 0025) ; le « Texte du badge » de la fiche le
remplace pour un bénévole. Champs ramenés à l'intérieur du badge et champ
sélectionné au premier plan : la photo restait bloquée hors d'un badge
plus étroit que le CR80 (bornes de glisser-déposer négatives).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:30:34 +01:00
14 changed files with 524 additions and 107 deletions
+29 -9
View File
@@ -86,9 +86,10 @@ les tables sauf les tables d'association.
effet sur les affectations) et `willing_to_pay_fee` (« je participe à la effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/ cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le `fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
paiement SumUp plus bas). `badge_text` : texte libre imprimé sur le badge 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. fiche (« Texte du badge ») — jamais par le formulaire public — et qui
remplace pour lui le texte commun de l'éditeur.
- **`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
@@ -170,19 +171,35 @@ les tables sauf les tables d'association.
depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`), depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`),
qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel
du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`). du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`).
`POSITIONS` résout **une ligne par créneau affecté**, triée `POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`,
chronologiquement : nom du poste + horaire (`HH:MM–HH:MM`), suivi du dessiné par `app/badges/pdf.py:_draw_table`) des créneaux affectés,
référent du poste (`Position.lead_account_id` → son bénévole) s'il y en a **regroupés par jour de festival** (§ 3 — une soirée qui passe minuit
un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes, reste sur le jour où elle a commencé) : le jour en en-tête sur toute la
`\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ; largeur (« Samedi 11 »), puis une ligne par créneau, colonne horaire
(`HH:MM–HH:MM`, à sa largeur naturelle) et colonne poste (le reste,
repliée si besoin), dans l'ordre chronologique. Pas de référent sur le
badge (choix de la coordination). Une ligne qui ne tient plus dans la
hauteur du cadre n'est pas imprimée ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ; `ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ;
`FREE_TEXT` est `Volunteer.badge_text` (vide ⇒ rien) ; `PHOTO` imprime `FREE_TEXT` imprime `Volunteer.badge_text` s'il est renseigné, sinon le
texte commun saisi dans l'éditeur (`BadgeField.text`, ignoré pour les
autres types), sinon rien ; `PHOTO` imprime
`Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée `Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée
au ratio du cadre sans déformation, orientation EXIF appliquée et réduite 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, le cadre reste vide plutôt que de faire échouer la
planche. planche. Le texte d'un champ passe à la ligne pour tenir dans la largeur
de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au
milieu d'un mot trop long seul ; les `\n` restent des retours à la
ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme
l'aperçu de l'éditeur, qui fait de même côté navigateur. Avant, chaque
ligne s'imprimait d'un bloc et débordait du cadre. L'éditeur ramène tout champ à l'intérieur du badge
(`BadgesPage.tsx:fitToBadge`) et met le champ sélectionné au premier
plan : les positions par défaut visent un CR80 paysage, et sur un badge
plus étroit un champ pouvait tomber hors du badge, avec des bornes de
glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo
impossible à déplacer ni redimensionner).
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` — `POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` —
absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
@@ -933,6 +950,9 @@ Aucune dépendance locale requise hors Docker.
| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider | | 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
| 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider | | 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider |
| 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider | | 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider |
| 48 | Badges : texte libre saisi dans l'éditeur (`BadgeField.text`, migration `0025`), commun à tous les badges, le « Texte du badge » de la fiche le remplaçant pour un bénévole ; champs ramenés dans le badge et champ sélectionné au premier plan (la photo restait bloquée hors d'un badge plus étroit que le CR80) | à valider |
| 49 | Badges : le texte des champs (nom, postes et horaires…) passe à la ligne pour tenir dans la largeur de son cadre au lieu de déborder (`app/badges/pdf.py:wrap_text`) | à valider |
| 50 | Badges : les créneaux affectés s'affichent en tableau — le jour de festival en en-tête, puis une colonne horaire et une colonne poste (le référent n'est plus imprimé sur le badge), filets à la couleur du champ, cellules repliées dans leur colonne ; même tableau dans l'aperçu de l'éditeur | à valider |
**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 +
+46 -25
View File
@@ -8,6 +8,7 @@ pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
`BadgeField`) pour le modèle. `BadgeField`) pour le modèle.
""" """
import datetime as dt
import io import io
import uuid import uuid
from collections import defaultdict from collections import defaultdict
@@ -15,7 +16,6 @@ from collections import defaultdict
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.orm import aliased
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api.deps import get_edition from app.api.deps import get_edition
@@ -32,6 +32,8 @@ from app.badges.pdf import (
DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec, BadgeFieldSpec,
BadgeSpec, BadgeSpec,
BadgeTable,
BadgeTableSection,
generate_badge_sheets_pdf, generate_badge_sheets_pdf,
image_size_mm, image_size_mm,
) )
@@ -43,12 +45,14 @@ from app.models import (
BadgeField, BadgeField,
BadgeFieldType, BadgeFieldType,
BadgeTemplate, BadgeTemplate,
DayPart,
Edition, Edition,
Position, Position,
Role, Role,
Shift, Shift,
Volunteer, Volunteer,
) )
from app.validation.engine import day_boundary, festival_day
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"]) router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
@@ -246,7 +250,7 @@ def set_size(
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Génération du PDF # # Génération du PDF #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
AssignmentRow = tuple[Position, Shift, Volunteer | None] # (poste, créneau, référent) AssignmentRow = tuple[Position, Shift]
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str: def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
@@ -259,16 +263,29 @@ def _category_label(category) -> str:
return t(f"position.category.{category.value.lower()}") return t(f"position.category.{category.value.lower()}")
def _position_line( def _day_label(day: dt.date) -> str:
position: Position, shift: Shift, referent: Volunteer | None, volunteer_id: uuid.UUID """En-tête d'un jour dans le tableau des créneaux : « Samedi 11 »."""
) -> str: return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
"""Une ligne par créneau affecté : poste + horaire, et le référent du
poste s'il y en a un — sauf si le bénévole badgé est lui-même ce
référent (pas besoin de se l'indiquer à soi-même).""" def _positions_table(assignments: list[AssignmentRow], boundary: dt.time) -> BadgeTable | None:
line = f"{position.name} {shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}" """Créneaux affectés regroupés par jour de festival (une soirée qui
if referent is not None and referent.id != volunteer_id: passe minuit reste sur le jour où elle a commencé — `festival_day`) :
line += f" ({t('badge.role.referent')} : {referent.first_name} {referent.last_name})" le jour en en-tête, puis une ligne horaire / poste par créneau, dans
return line l'ordre chronologique. `None` sans aucun créneau (rien à dessiner)."""
by_day: dict[dt.date, list[tuple[str, str]]] = defaultdict(list)
for position, shift in assignments:
by_day[festival_day(shift.starts_at, boundary)].append(
(f"{shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}", position.name)
)
if not by_day:
return None
return BadgeTable(
sections=tuple(
BadgeTableSection(title=_day_label(day), rows=tuple(rows))
for day, rows in sorted(by_day.items())
)
)
def _field_text( def _field_text(
@@ -277,19 +294,18 @@ def _field_text(
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]], assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
lead_volunteer_ids: set[uuid.UUID], lead_volunteer_ids: set[uuid.UUID],
) -> str: ) -> str:
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
(`_positions_table`)."""
if field.field_type == BadgeFieldType.FULL_NAME: if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}" return f"{volunteer.first_name} {volunteer.last_name}"
if field.field_type == BadgeFieldType.FREE_TEXT: if field.field_type == BadgeFieldType.FREE_TEXT:
return volunteer.badge_text or "" # 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 or field.text or ""
assignments = assignments_by_volunteer.get(volunteer.id, []) assignments = assignments_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.POSITIONS:
return "\n".join(
_position_line(position, shift, referent, volunteer.id)
for position, shift, referent in assignments
)
if field.field_type == BadgeFieldType.CATEGORY: if field.field_type == BadgeFieldType.CATEGORY:
categories = sorted( categories = sorted(
{_category_label(position.category) for position, _shift, _r in assignments} {_category_label(position.category) for position, _shift in assignments}
) )
return ", ".join(categories) return ", ".join(categories)
if field.field_type == BadgeFieldType.ROLE: if field.field_type == BadgeFieldType.ROLE:
@@ -317,20 +333,20 @@ def generate_badges(
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"}) raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
volunteer_ids = [v.id for v in volunteers] volunteer_ids = [v.id for v in volunteers]
Referent = aliased(Volunteer)
rows = session.exec( rows = session.exec(
select(Assignment.volunteer_id, Position, Shift, Referent) select(Assignment.volunteer_id, Position, Shift)
.join(Shift, Shift.id == Assignment.shift_id) .join(Shift, Shift.id == Assignment.shift_id)
.join(Position, Position.id == Shift.position_id) .join(Position, Position.id == Shift.position_id)
.join(Account, Account.id == Position.lead_account_id, isouter=True)
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
.where(Assignment.volunteer_id.in_(volunteer_ids)) .where(Assignment.volunteer_id.in_(volunteer_ids))
.order_by(Shift.starts_at) .order_by(Shift.starts_at)
).all() ).all()
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list) assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
for volunteer_id, position, shift, referent in rows: for volunteer_id, position, shift in rows:
assignments_by_volunteer[volunteer_id].append((position, shift, referent)) assignments_by_volunteer[volunteer_id].append((position, shift))
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
lead_volunteer_ids = { lead_volunteer_ids = {
account.volunteer_id account.volunteer_id
for account in session.exec( for account in session.exec(
@@ -362,6 +378,11 @@ def generate_badges(
image=volunteer.photo image=volunteer.photo
if field.field_type == BadgeFieldType.PHOTO if field.field_type == BadgeFieldType.PHOTO
else None, else None,
table=_positions_table(
assignments_by_volunteer.get(volunteer.id, []), boundary
)
if field.field_type == BadgeFieldType.POSITIONS
else None,
) )
for field in fields for field in fields
) )
+2
View File
@@ -355,6 +355,8 @@ class BadgeFieldIn(ApiModel):
align: BadgeAlign = BadgeAlign.LEFT align: BadgeAlign = BadgeAlign.LEFT
bold: bool = False bold: bool = False
color: str = "#000000" color: str = "#000000"
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
text: str | None = None
class BadgeFieldsSet(ApiModel): class BadgeFieldsSet(ApiModel):
+158 -3
View File
@@ -23,6 +23,7 @@ from reportlab.lib.colors import HexColor
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader from reportlab.lib.utils import ImageReader
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
DEFAULT_BADGE_WIDTH_MM = 85.6 DEFAULT_BADGE_WIDTH_MM = 85.6
@@ -30,6 +31,11 @@ DEFAULT_BADGE_HEIGHT_MM = 54.0
_MARGIN_MM = 10.0 _MARGIN_MM = 10.0
_GUTTER_MM = 4.0 _GUTTER_MM = 4.0
_LINE_HEIGHT_FACTOR = 1.25 _LINE_HEIGHT_FACTOR = 1.25
# Tableau (champ « postes ») : marges intérieures des cellules et épaisseur
# des filets.
_CELL_PAD_X_MM = 0.8
_CELL_PAD_Y_MM = 0.5
_TABLE_LINE_WIDTH_PT = 0.4
# 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
@@ -47,6 +53,23 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu
return (width_px / dpi * 25.4, height_px / dpi * 25.4) return (width_px / dpi * 25.4, height_px / dpi * 25.4)
@dataclass(frozen=True)
class BadgeTableSection:
"""Un bloc du tableau : une ligne d'en-tête sur toute la largeur (le
jour), puis ses lignes (horaire, poste)."""
title: str
rows: tuple[tuple[str, ...], ...]
@dataclass(frozen=True)
class BadgeTable:
"""Contenu d'un champ affiché en tableau plutôt qu'en texte libre : les
créneaux du bénévole regroupés par jour."""
sections: tuple[BadgeTableSection, ...]
@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`
@@ -55,7 +78,9 @@ class BadgeFieldSpec:
`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 — `text` et le style sont
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné.""" alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné.
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`."""
text: str text: str
x_mm: float x_mm: float
@@ -68,6 +93,7 @@ class BadgeFieldSpec:
color: str # "#rrggbb" color: str # "#rrggbb"
is_photo: bool = False is_photo: bool = False
image: bytes | None = None image: bytes | None = None
table: BadgeTable | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -162,6 +188,130 @@ def _draw_photo(
) )
def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
"""Découpe `text` en lignes qui tiennent dans `max_width_pt` : les `\\n`
du texte restent des retours à la ligne, puis chaque ligne trop longue
passe à la ligne entre deux mots — ou au milieu d'un mot qui, seul, ne
tient pas (nom très long dans un cadre étroit). Sans cela, un prénom +
nom ou une ligne de poste avec horaire débordait du cadre du champ."""
def fits(candidate: str) -> bool:
return stringWidth(candidate, font_name, font_size) <= max_width_pt
lines: list[str] = []
for paragraph in text.split("\n"):
current = ""
# `split(" ")` (et non `split()`) garde les doubles espaces voulus,
# ex. entre le nom du poste et son horaire.
for word in paragraph.split(" "):
candidate = f"{current} {word}" if current else word
if fits(candidate):
current = candidate
continue
if current:
lines.append(current.rstrip())
current = word
while current and not fits(current):
cut = len(current) - 1
while cut > 1 and not fits(current[:cut]):
cut -= 1
lines.append(current[:cut])
current = current[cut:]
lines.append(current.rstrip())
return lines
def _column_widths(natural: list[float], available: float) -> list[float]:
"""Largeurs des colonnes d'un tableau dans `available` : une colonne
courte (jour, horaire) garde sa largeur naturelle, les plus longues
(poste, référent) se partagent le reste à parts égales et passent à la
ligne. S'il reste de la place, la dernière colonne la prend."""
widths = [0.0] * len(natural)
pending = sorted(range(len(natural)), key=lambda i: natural[i])
remaining = available
while pending:
share = remaining / len(pending)
if natural[pending[0]] > share:
for i in pending:
widths[i] = share
return widths
i = pending.pop(0)
widths[i] = natural[i]
remaining -= natural[i]
widths[-1] += remaining
return widths
def _draw_table(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
) -> None:
"""Tableau dans le cadre du champ : pour chaque section, une ligne
d'en-tête en gras sur toute la largeur (le jour), puis ses lignes ;
filets à la couleur du champ, texte de chaque cellule replié dans sa
colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
texte."""
table = f.table
if table is None or not table.sections:
return
regular, bold = "Helvetica", "Helvetica-Bold"
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
line_height = f.font_size * _LINE_HEIGHT_FACTOR
body = [row for section in table.sections for row in section.rows]
columns = max(len(row) for row in body) if body else 1
natural = [
max(stringWidth(row[col], regular, f.font_size) for row in body) + 2 * pad_x
for col in range(columns)
] if body else [0.0]
widths = _column_widths(natural, f.width_mm * mm)
total_width = sum(widths)
# (cellules, police, en-tête de section ?) — un en-tête n'a qu'une
# cellule, sur toute la largeur.
lines_to_draw = []
for section in table.sections:
lines_to_draw.append(((section.title,), bold, True))
lines_to_draw.extend((row, regular, False) for row in section.rows)
left = (x0_mm + f.x_mm) * mm
top = (y0_mm + badge_height_mm - f.y_mm) * mm
bottom_limit = top - f.height_mm * mm
color = HexColor(f.color)
c.setFillColor(color)
c.setStrokeColor(color)
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
y = top
for cells, font, is_title in lines_to_draw:
cell_widths = [total_width] if is_title else widths
wrapped = [
wrap_text(text, font, f.font_size, max(1.0, cell_widths[col] - 2 * pad_x))
for col, text in enumerate(cells)
]
row_height = max(len(lines) for lines in wrapped) * line_height + 2 * pad_y
if y - row_height < bottom_limit - 0.01:
break # la ligne déborderait du cadre du champ
c.setFont(font, f.font_size)
x = left
for col, lines in enumerate(wrapped):
for i, line in enumerate(lines):
baseline = y - pad_y - f.font_size * 0.85 - i * line_height
c.drawString(x + pad_x, baseline, line)
x += cell_widths[col]
if not is_title:
# Séparations de colonnes, sur la hauteur de cette ligne seulement.
x = left
for width in widths[:-1]:
x += width
c.line(x, y, x, y - row_height)
y -= row_height
c.line(left, y, left + total_width, y)
if y == top:
return # pas même le premier en-tête ne tient
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
def _draw_fields( def _draw_fields(
c: canvas.Canvas, c: canvas.Canvas,
fields: tuple[BadgeFieldSpec, ...], fields: tuple[BadgeFieldSpec, ...],
@@ -173,7 +323,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
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size) if f.table is not None:
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
continue
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
c.setFont(font_name, f.font_size)
c.setFillColor(HexColor(f.color)) c.setFillColor(HexColor(f.color))
left_x = x0_mm + f.x_mm left_x = x0_mm + f.x_mm
# `f.font_size` est en points (unité reportlab) ; le reste du calcul # `f.font_size` est en points (unité reportlab) ; le reste du calcul
@@ -185,7 +339,8 @@ def _draw_fields(
# dessine depuis le BAS de la page. # dessine depuis le BAS de la page.
top_y = y0_mm + badge_height_mm - f.y_mm top_y = y0_mm + badge_height_mm - f.y_mm
bottom_limit = top_y - f.height_mm bottom_limit = top_y - f.height_mm
for i, line in enumerate(f.text.split("\n")): 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 baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
if baseline_y < bottom_limit: if baseline_y < bottom_limit:
break # la ligne déborderait de la boîte du champ break # la ligne déborderait de la boîte du champ
+8
View File
@@ -65,6 +65,14 @@
"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",
"badge.table.day_label": "{weekday} {day}",
"badge.weekday.0": "Lundi",
"badge.weekday.1": "Mardi",
"badge.weekday.2": "Mercredi",
"badge.weekday.3": "Jeudi",
"badge.weekday.4": "Vendredi",
"badge.weekday.5": "Samedi",
"badge.weekday.6": "Dimanche",
"position.category.bar": "Bar", "position.category.bar": "Bar",
"position.category.ticketing": "Billetterie", "position.category.ticketing": "Billetterie",
"position.category.welcome": "Accueil", "position.category.welcome": "Accueil",
@@ -0,0 +1,28 @@
"""Texte commun du champ libre des badges
Revision ID: 0025_badge_field_text
Revises: 0024_volunteer_badge_text
Create Date: 2026-10-27
`badge_field.text` : texte du champ `FREE_TEXT` saisi dans l'éditeur de
badges, imprimé sur tous les badges — sauf ceux dont la fiche porte un
`volunteer.badge_text`, qui le remplace pour ce bénévole.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0025_badge_field_text"
down_revision: str | None = "0024_volunteer_badge_text"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("badge_field", sa.Column("text", sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column("badge_field", "text")
+7 -2
View File
@@ -183,8 +183,9 @@ class Volunteer(TimestampMixin, table=True):
# cette note sur la fiche et la transforme, si besoin, en un vrai # cette note sur la fiche et la transforme, si besoin, en un vrai
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits. # `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
companion_wish_note: str | None = None companion_wish_note: str | None = None
# Texte libre imprimé sur le badge (champ `BadgeFieldType.FREE_TEXT`), # Texte du badge propre à ce bénévole (champ `BadgeFieldType.FREE_TEXT`),
# saisi par la coordination sur la fiche — ex. « Accès backstage ». # saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
badge_text: str | None = None badge_text: str | None = None
# Cases à cocher du formulaire public — purement informatives : aucune # Cases à cocher du formulaire public — purement informatives : aucune
# logique n'y est attachée (pas de workflow de paiement). # logique n'y est attachée (pas de workflow de paiement).
@@ -466,6 +467,10 @@ class BadgeField(TimestampMixin, table=True):
sa_column=Column(Boolean, nullable=False, server_default="false"), sa_column=Column(Boolean, nullable=False, server_default="false"),
) )
color: str = Field(default="#000000") color: str = Field(default="#000000")
# Champ `FREE_TEXT` seulement : texte saisi dans l'éditeur, imprimé sur
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
text: str | None = None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+3 -2
View File
@@ -39,8 +39,9 @@ class BadgeFieldType(enum.StrEnum):
ROLE = "ROLE" ROLE = "ROLE"
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ. # Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
PHOTO = "PHOTO" PHOTO = "PHOTO"
# Texte libre propre à chaque bénévole, saisi par la coordination sur sa # Texte libre saisi par la coordination dans l'éditeur (`BadgeField.text`,
# fiche (`Volunteer.badge_text`). # commun à tous les badges), remplacé pour un bénévole par le « Texte du
# badge » de sa fiche (`Volunteer.badge_text`) s'il en a un.
FREE_TEXT = "FREE_TEXT" FREE_TEXT = "FREE_TEXT"
+4 -1
View File
@@ -221,12 +221,15 @@ def test_generating_with_photo_and_free_text_fields(client, edition, session):
{ {
"field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40, "field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40,
"width_mm": 50, "height_mm": 8, "font_size": 9, "width_mm": 50, "height_mm": 8, "font_size": 9,
"text": "Staff 2026",
}, },
] ]
}, },
) )
assert resp.status_code == 200 assert resp.status_code == 200
assert {f["field_type"] for f in resp.json()} == {"PHOTO", "FREE_TEXT"} by_type = {f["field_type"]: f for f in resp.json()}
assert set(by_type) == {"PHOTO", "FREE_TEXT"}
assert by_type["FREE_TEXT"]["text"] == "Staff 2026"
with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin") with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
with_photo.photo = _PNG with_photo.photo = _PNG
+44 -51
View File
@@ -3,7 +3,7 @@
import datetime as dt import datetime as dt
import uuid import uuid
from app.api.badges import _field_text, _position_line from app.api.badges import _field_text, _positions_table
from app.models import ( from app.models import (
BadgeField, BadgeField,
BadgeFieldType, BadgeFieldType,
@@ -29,65 +29,45 @@ def _shift(start: dt.datetime, end: dt.datetime) -> Shift:
) )
def test_position_line_shows_the_schedule(): _BOUNDARY = dt.time(2, 0) # journée de festival de 02:00 à 02:00
volunteer = _volunteer(first_name="Alex", last_name="Martin")
position = _position(name="Bar central")
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
line = _position_line(position, shift, referent=None, volunteer_id=volunteer.id)
assert line == "Bar central 19:00–22:00"
def test_position_line_shows_the_referent_when_there_is_one(): def test_positions_table_groups_shifts_under_their_day():
volunteer = _volunteer(first_name="Alex", last_name="Martin") accueil = (
position = _position(name="Bar central")
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
referent = _volunteer(first_name="Sam", last_name="Nuit")
line = _position_line(position, shift, referent=referent, volunteer_id=volunteer.id)
assert line == "Bar central 19:00–22:00 (Référent : Sam Nuit)"
def test_position_line_hides_the_referent_when_it_is_the_badged_volunteer():
# Pas besoin de se dire à soi-même qu'on est referent de son propre poste.
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
line = _position_line(
_position(name="Bar central"),
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
referent=volunteer,
volunteer_id=volunteer.id,
)
assert "Référent" not in line
def test_field_text_positions_lists_one_line_per_shift_in_chronological_order():
volunteer = _volunteer(first_name="Alex", last_name="Martin")
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.POSITIONS,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
)
morning = (
_position(name="Accueil"), _position(name="Accueil"),
_shift(dt.datetime(2026, 7, 9, 9, 0), dt.datetime(2026, 7, 9, 13, 0)), _shift(dt.datetime(2026, 7, 11, 9, 0), dt.datetime(2026, 7, 11, 13, 0)),
None,
) )
evening = ( bar = (
_position(name="Bar central"), _position(name="Bar central"),
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)), _shift(dt.datetime(2026, 7, 11, 19, 0), dt.datetime(2026, 7, 11, 22, 0)),
None, )
demontage = (
_position(name="Démontage"),
_shift(dt.datetime(2026, 7, 12, 14, 0), dt.datetime(2026, 7, 12, 18, 0)),
) )
text = _field_text( table = _positions_table([accueil, bar, demontage], _BOUNDARY)
field, volunteer,
assignments_by_volunteer={volunteer.id: [morning, evening]}, assert [(s.title, s.rows) for s in table.sections] == [
lead_volunteer_ids=set(), ("Samedi 11", (("09:00–13:00", "Accueil"), ("19:00–22:00", "Bar central"))),
("Dimanche 12", (("14:00–18:00", "Démontage"),)),
]
def test_positions_table_keeps_a_night_shift_on_the_day_it_started():
night = (
_position(name="Bar central"),
_shift(dt.datetime(2026, 7, 12, 0, 0), dt.datetime(2026, 7, 12, 1, 0)),
) )
assert text == "Accueil 09:00–13:00\nBar central 19:00–22:00" table = _positions_table([night], _BOUNDARY)
assert table.sections[0].title == "Samedi 11"
assert table.sections[0].rows == (("00:00–01:00", "Bar central"),)
def test_positions_table_is_empty_without_any_shift():
assert _positions_table([], _BOUNDARY) is None
def test_field_text_role_is_referent_only_for_lead_accounts(): def test_field_text_role_is_referent_only_for_lead_accounts():
@@ -111,3 +91,16 @@ def test_field_text_free_text_is_the_volunteer_badge_text():
assert _field_text(field, with_text, {}, set()) == "Accès backstage" assert _field_text(field, with_text, {}, set()) == "Accès backstage"
assert _field_text(field, without, {}, set()) == "" assert _field_text(field, without, {}, set()) == ""
def test_field_text_free_text_falls_back_to_the_common_editor_text():
field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000",
text="Staff 2026",
)
own = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage")
other = _volunteer(first_name="Sam", last_name="Nuit")
assert _field_text(field, own, {}, set()) == "Accès backstage"
assert _field_text(field, other, {}, set()) == "Staff 2026"
+85
View File
@@ -3,6 +3,8 @@
import io import io
from PIL import Image from PIL import Image
from reportlab.lib.units import mm
from reportlab.pdfbase.pdfmetrics import stringWidth
from app.badges.pdf import ( from app.badges.pdf import (
ASSUMED_DPI, ASSUMED_DPI,
@@ -10,11 +12,15 @@ from app.badges.pdf import (
DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_WIDTH_MM,
BadgeFieldSpec, BadgeFieldSpec,
BadgeSpec, BadgeSpec,
BadgeTable,
BadgeTableSection,
_cell_origin, _cell_origin,
_column_widths,
_fit_photo, _fit_photo,
_grid_size, _grid_size,
generate_badge_sheets_pdf, generate_badge_sheets_pdf,
image_size_mm, image_size_mm,
wrap_text,
) )
@@ -151,3 +157,82 @@ def test_unreadable_photo_does_not_crash_generation():
[_photo_badge(b"pas une image")], front_image=None, back_image=None [_photo_badge(b"pas une image")], front_image=None, back_image=None
) )
assert pdf.startswith(b"%PDF") assert pdf.startswith(b"%PDF")
def test_wrap_text_keeps_a_short_text_on_one_line():
assert wrap_text("Alex Martin", "Helvetica", 12, 60 * mm) == ["Alex Martin"]
def test_wrap_text_breaks_between_words_to_fit_the_width():
# Assez large pour « Marie-Christine » seul, trop étroit pour le tout.
width = 40 * mm
lines = wrap_text("Marie-Christine de La Fontaine", "Helvetica-Bold", 12, width)
assert len(lines) > 1
assert " ".join(lines) == "Marie-Christine de La Fontaine"
assert all(stringWidth(line, "Helvetica-Bold", 12) <= width for line in lines)
def test_wrap_text_keeps_explicit_line_breaks_and_wraps_each_line():
text = "Bar central 19:00–22:00 (Référent : Sam Nuit)\nAccueil 09:00–13:00"
width = 40 * mm
lines = wrap_text(text, "Helvetica", 8, width)
assert lines[0].startswith("Bar central 19:00–22:00")
assert "Accueil 09:00–13:00" in lines
assert all(stringWidth(line, "Helvetica", 8) <= width for line in lines)
def test_wrap_text_cuts_a_single_word_too_long_for_the_frame():
width = 10 * mm
lines = wrap_text("Anticonstitutionnellement", "Helvetica", 12, width)
assert "".join(lines) == "Anticonstitutionnellement"
assert all(stringWidth(line, "Helvetica", 12) <= width for line in lines)
def _table_badge(rows: int, height_mm: float = 30) -> BadgeSpec:
return BadgeSpec(
fields=(
BadgeFieldSpec(
text="", x_mm=5, y_mm=5, width_mm=60, height_mm=height_mm,
font_size=7, align="LEFT", bold=False, color="#000000",
table=BadgeTable(
sections=(
BadgeTableSection(
title="Samedi 11",
rows=tuple(
("19:00–22:00", "Bar central très très long")
for _ in range(rows)
),
),
)
),
),
)
)
def test_column_widths_keep_short_columns_and_share_the_rest():
widths = _column_widths([20, 30, 200, 100], available=200)
assert widths[:2] == [20, 30]
assert widths[2] == widths[3] == 75
assert sum(widths) == 200
def test_column_widths_give_the_spare_room_to_the_last_column():
assert _column_widths([20, 30, 40], available=150) == [20, 30, 100]
def test_table_field_generates_a_valid_pdf():
pdf = generate_badge_sheets_pdf([_table_badge(3)], front_image=None, back_image=None)
assert pdf.startswith(b"%PDF")
def test_table_rows_beyond_the_frame_are_not_drawn():
# Cadre trop petit pour 50 lignes : celles qui débordent sont omises,
# la planche fait la même taille qu'avec quelques lignes seulement.
few = generate_badge_sheets_pdf([_table_badge(4, height_mm=12)], None, None)
many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None)
assert abs(len(many) - len(few)) < 50
+3
View File
@@ -444,6 +444,9 @@ export interface BadgeFieldIn {
align: BadgeAlign; align: BadgeAlign;
bold: boolean; bold: boolean;
color: string; color: string;
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
text?: string | null;
} }
export interface BadgeFieldsSet { export interface BadgeFieldsSet {
+8 -3
View File
@@ -231,7 +231,7 @@
"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.badgeText.help": "Imprimé sur le badge si le champ « Texte libre » y est placé (écran Badges).", "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.",
"config.vol.photo.none": "Aucune photo", "config.vol.photo.none": "Aucune photo",
@@ -355,8 +355,12 @@
"badges.fields": "Champs du recto", "badges.fields": "Champs du recto",
"badges.field.fullName": "Prénom + nom", "badges.field.fullName": "Prénom + nom",
"badges.field.fullName.placeholder": "Prénom Nom", "badges.field.fullName.placeholder": "Prénom Nom",
"badges.field.positions": "Poste(s) affecté(s)", "badges.field.positions": "Créneaux affectés (tableau)",
"badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)", "badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
"badges.field.positions.sample1.day": "Vendredi 10",
"badges.field.positions.sample1.rows": "18:00–22:00|Bar central",
"badges.field.positions.sample2.day": "Samedi 11",
"badges.field.positions.sample2.rows": "09:00–13:00|Accueil\n23:00–01:00|Bar central",
"badges.field.category": "Catégorie", "badges.field.category": "Catégorie",
"badges.field.category.placeholder": "Bar", "badges.field.category.placeholder": "Bar",
"badges.field.role": "Rôle (référent / bénévole)", "badges.field.role": "Rôle (référent / bénévole)",
@@ -366,7 +370,8 @@
"badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.", "badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
"badges.field.freeText": "Texte libre", "badges.field.freeText": "Texte libre",
"badges.field.freeText.placeholder": "Accès backstage", "badges.field.freeText.placeholder": "Accès backstage",
"badges.field.freeText.help": "Texte propre à chaque bénévole, à saisir sur sa fiche (Configuration › Bénévoles, « Texte du badge »). Vide = rien d'imprimé.", "badges.field.freeText.help": "Texte saisi ci-dessous, imprimé sur tous les badges. Pour un bénévole en particulier, le « Texte du badge » de sa fiche (Configuration › Bénévoles) le remplace.",
"badges.field.freeText.input": "Texte à imprimer (ex. STAFF 2026)",
"badges.field.size": "Taille", "badges.field.size": "Taille",
"badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.", "badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
"badges.size.width": "Largeur du badge (mm)", "badges.size.width": "Largeur du badge (mm)",
+99 -11
View File
@@ -117,6 +117,24 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max); 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> { function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> {
const byType = new Map(saved.map((f) => [f.field_type, f])); const byType = new Map(saved.map((f) => [f.field_type, f]));
const out = {} as Record<BadgeFieldType, FieldState>; const out = {} as Record<BadgeFieldType, FieldState>;
@@ -168,19 +186,28 @@ function BadgeCustomizeTab() {
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null); const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
const [selected, setSelected] = useState<BadgeFieldType | null>(null); const [selected, setSelected] = useState<BadgeFieldType | null>(null);
useEffect(() => {
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
}, [template.data]);
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM; const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM; const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
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); const scale = useEditorScale(badgeW);
if (template.isLoading || !fields) return <Loading />; if (template.isLoading || !fields) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />; if (template.error) return <ErrorNote error={template.error} />;
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => { const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev)); setFieldsState((prev) =>
prev
? { ...prev, [type]: 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 save = () => {
@@ -197,6 +224,7 @@ function BadgeCustomizeTab() {
align: f.align, align: f.align,
bold: f.bold, bold: f.bold,
color: f.color, color: f.color,
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
}; };
}, },
); );
@@ -434,8 +462,8 @@ function FieldBox({
const dxMm = (e.clientX - d.pointerXPx) / scale; const dxMm = (e.clientX - d.pointerXPx) / scale;
const dyMm = (e.clientY - d.pointerYPx) / scale; const dyMm = (e.clientY - d.pointerYPx) / scale;
onChange({ onChange({
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm), x_mm: clamp(d.startXMm + dxMm, 0, Math.max(0, badgeW - field.width_mm)),
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm), y_mm: clamp(d.startYMm + dyMm, 0, Math.max(0, badgeH - field.height_mm)),
}); });
}; };
@@ -451,9 +479,11 @@ function FieldBox({
const d = resizeRef.current; const d = resizeRef.current;
const dwMm = (e.clientX - d.pointerXPx) / scale; const dwMm = (e.clientX - d.pointerXPx) / scale;
const dhMm = (e.clientY - d.pointerYPx) / scale; const dhMm = (e.clientY - d.pointerYPx) / scale;
const maxW = badgeW - field.x_mm;
const maxH = badgeH - field.y_mm;
onChange({ onChange({
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm), width_mm: clamp(d.startWMm + dwMm, Math.min(10, maxW), maxW),
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm), height_mm: clamp(d.startHMm + dhMm, Math.min(5, maxH), maxH),
}); });
}; };
@@ -471,9 +501,14 @@ function FieldBox({
top: field.y_mm * scale, top: field.y_mm * scale,
width: field.width_mm * scale, width: field.width_mm * scale,
height: field.height_mm * scale, height: field.height_mm * scale,
// 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 === 'PHOTO' ? ( {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"> <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)} {t(FIELD_PLACEHOLDER_KEY.PHOTO)}
</span> </span>
@@ -488,7 +523,9 @@ function FieldBox({
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left', textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}} }}
> >
{t(FIELD_PLACEHOLDER_KEY[field.field_type])} {field.field_type === 'FREE_TEXT' && field.text?.trim()
? field.text
: t(FIELD_PLACEHOLDER_KEY[field.field_type])}
</span> </span>
)} )}
<div <div
@@ -501,6 +538,47 @@ function FieldBox({
); );
} }
/** 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({ function FieldEditorRow({
type, type,
field, field,
@@ -533,6 +611,16 @@ function FieldEditorRow({
) : null} ) : null}
</div> </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 {/* La photo n'a pas de style de texte : seuls sa place et son cadre
(sur l'aperçu) comptent. */} (sur l'aperçu) comptent. */}
{field.enabled && type !== 'PHOTO' ? ( {field.enabled && type !== 'PHOTO' ? (