Étape 20 : planches de badges bénévoles (PDF)

Recto personnalisable (fond image + champs positionnés : nom, poste(s)
affectés, catégorie, rôle) via un éditeur visuel glisser-déposer ; verso
fixe, la même image pour tous les bénévoles. Nouvel écran « Badges »
(COORDINATION uniquement, /badges).

Backend : modèle BadgeTemplate/BadgeField (migration 0011), génération
PDF isolée (app/badges/pdf.py, ReportLab — planches recto suivies des
planches verso correspondantes pour une impression recto-verso), route
POST /editions/{id}/badges/generate, tests (module pur + API).

Frontend : client HTTP (upload multipart, réponses binaires images/PDF),
client mock (mode démonstration), éditeur de positionnement en
millimètres (coordonnées partagées avec le backend), écran de génération
avec sélection des bénévoles et téléchargement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-20 20:05:03 +02:00
co-authored by Claude Sonnet 5
parent 4bcdafca96
commit b7fc80a7dc
25 changed files with 1800 additions and 3 deletions
+22
View File
@@ -134,6 +134,25 @@ les tables sauf les tables d'association.
`volunteer_id` (nullable, unique).
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
`used_at`.
- **`BadgeTemplate`** : recto/verso d'une édition (une ligne, créée à la
demande). `front_image`/`front_image_content_type` (fond du recto),
`back_image`/`back_image_content_type` (verso — **le même pour tous les
bénévoles**). Images stockées en base (`LargeBinary`), pas de volume de
fichiers à gérer.
- **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈
`FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE`, au plus un par édition —
`PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge
(format CR80, 85,6 × 54 mm), `font_size`, `align`, `bold`, `color`. Édité
visuellement (glisser-déposer) depuis l'écran « Badges » du front-end
(`frontend/src/pages/BadgesPage.tsx`), qui envoie ces mêmes millimètres.
`POSITIONS`/`CATEGORY` résolus depuis les affectations décidées du
bénévole, `ROLE` depuis son `Account.role` (`LEAD` ⇒ « Référent »).
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` —
absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
par planche, de la page de verso correspondante (même image répétée à
chaque emplacement) — impression recto-verso classique.
Conventions techniques :
@@ -170,6 +189,7 @@ Matrice appliquée par les endpoints :
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
| `badges` (config + génération) | ✔ | — | — |
**Authentification — deux systèmes selon le compte**, une même session JWT
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
@@ -424,6 +444,7 @@ fonctions d'IA. *(L'envoi d'emails par SMTP est en place — voir § 2.)*
│ │ ├── models/ # tables SQLModel
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
│ │ ├── i18n/
@@ -530,6 +551,7 @@ Aucune dépendance locale requise hors Docker.
| 17 | Vœu de compagnon (`VolunteerCompanionWish`, 0 à N, à sens unique) : fiche bénévole, pris en compte par le solveur (bonus) et le classement par défaut | à valider |
| 18 | Gestion des référents depuis la fiche bénévole : `PATCH /accounts` (rôle LEAD ↔ VOLUNTEER), `lead_account_id` vérifié (doit être un compte référent de l'édition) | à valider |
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
| 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+291
View File
@@ -0,0 +1,291 @@
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
et génération du PDF.
Le verso est une image fixe, la même pour tous les bénévoles. Le recto
combine une image de fond et des champs positionnés (nom, poste(s) affectés,
catégorie, rôle) — voir `app/badges/pdf.py` pour la composition du PDF et
`app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle.
"""
import uuid
from collections import defaultdict
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
from sqlalchemy import delete
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import (
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeTemplateRead,
)
from app.auth.deps import require_coordinator
from app.badges.pdf import BadgeFieldSpec, BadgeSpec, generate_badge_sheets_pdf
from app.db import get_session
from app.i18n import t
from app.models import (
Account,
Assignment,
BadgeField,
BadgeFieldType,
BadgeTemplate,
Edition,
Position,
Role,
Shift,
Volunteer,
)
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
# --------------------------------------------------------------------------- #
# Configuration (recto/verso, champs) #
# --------------------------------------------------------------------------- #
def _get_or_create_template(session: Session, edition_id: uuid.UUID) -> BadgeTemplate:
template = session.exec(
select(BadgeTemplate).where(BadgeTemplate.edition_id == edition_id)
).first()
if template is None:
template = BadgeTemplate(edition_id=edition_id)
session.add(template)
session.commit()
session.refresh(template)
return template
def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
return list(
session.exec(
select(BadgeField)
.where(BadgeField.edition_id == edition_id)
.order_by(BadgeField.field_type)
)
)
def _template_read(template: BadgeTemplate, fields: list[BadgeField]) -> BadgeTemplateRead:
return BadgeTemplateRead(
edition_id=template.edition_id,
has_front_image=template.front_image is not None,
has_back_image=template.back_image is not None,
fields=fields,
)
@router.get("/template", response_model=BadgeTemplateRead)
def get_template(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> BadgeTemplateRead:
template = _get_or_create_template(session, edition.id)
return _template_read(template, _fields(session, edition.id))
async def _read_image(file: UploadFile) -> tuple[bytes, str]:
content_type = file.content_type or ""
if content_type not in _ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=422, detail={"code": "badge.image_type_not_supported"}
)
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail={"code": "badge.empty_image"})
if len(content) > _MAX_IMAGE_BYTES:
raise HTTPException(status_code=413, detail={"code": "badge.image_too_large"})
return content, content_type
@router.put("/template/front-image", response_model=BadgeTemplateRead)
async def set_front_image(
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> BadgeTemplateRead:
template = _get_or_create_template(session, edition.id)
content, content_type = await _read_image(file)
template.front_image = content
template.front_image_content_type = content_type
session.add(template)
session.commit()
return _template_read(template, _fields(session, edition.id))
@router.put("/template/back-image", response_model=BadgeTemplateRead)
async def set_back_image(
file: UploadFile = File(...),
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> BadgeTemplateRead:
template = _get_or_create_template(session, edition.id)
content, content_type = await _read_image(file)
template.back_image = content
template.back_image_content_type = content_type
session.add(template)
session.commit()
return _template_read(template, _fields(session, edition.id))
@router.get("/template/front-image")
def get_front_image(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
template = _get_or_create_template(session, edition.id)
if template.front_image is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response(
content=template.front_image,
media_type=template.front_image_content_type or "application/octet-stream",
)
@router.get("/template/back-image")
def get_back_image(
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
template = _get_or_create_template(session, edition.id)
if template.back_image is None:
raise HTTPException(status_code=404, detail={"code": "error.not_found"})
return Response(
content=template.back_image,
media_type=template.back_image_content_type or "application/octet-stream",
)
@router.put("/template/fields", response_model=list[BadgeFieldRead])
def set_fields(
payload: BadgeFieldsSet,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> list[BadgeField]:
seen: set[BadgeFieldType] = set()
for f in payload.fields:
if f.field_type in seen:
raise HTTPException(
status_code=422,
detail={
"code": "badge.duplicate_field_type",
"params": {"field_type": f.field_type},
},
)
seen.add(f.field_type)
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
for f in payload.fields:
session.add(BadgeField(edition_id=edition.id, **f.model_dump()))
session.commit()
return _fields(session, edition.id)
# --------------------------------------------------------------------------- #
# Génération du PDF #
# --------------------------------------------------------------------------- #
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
return t("badge.role.referent") if volunteer_id in lead_volunteer_ids else t("badge.role.volunteer")
def _category_label(category) -> str:
return t(f"position.category.{category.value.lower()}")
def _field_text(
field: BadgeField,
volunteer: Volunteer,
positions_by_volunteer: dict[uuid.UUID, list[Position]],
lead_volunteer_ids: set[uuid.UUID],
) -> str:
if field.field_type == BadgeFieldType.FULL_NAME:
return f"{volunteer.first_name} {volunteer.last_name}"
positions = positions_by_volunteer.get(volunteer.id, [])
if field.field_type == BadgeFieldType.POSITIONS:
names = sorted({p.name for p in positions})
return ", ".join(names)
if field.field_type == BadgeFieldType.CATEGORY:
categories = sorted({_category_label(p.category) for p in positions})
return ", ".join(categories)
if field.field_type == BadgeFieldType.ROLE:
return _role_label(volunteer.id, lead_volunteer_ids)
return ""
@router.post("/generate")
def generate_badges(
payload: BadgeGenerateRequest,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
template = _get_or_create_template(session, edition.id)
fields = _fields(session, edition.id)
if not fields:
raise HTTPException(status_code=422, detail={"code": "badge.no_fields"})
query = select(Volunteer).where(Volunteer.edition_id == edition.id)
if payload.volunteer_ids is not None:
query = query.where(Volunteer.id.in_(payload.volunteer_ids))
volunteers = list(session.exec(query.order_by(Volunteer.last_name, Volunteer.first_name)))
if not volunteers:
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
volunteer_ids = [v.id for v in volunteers]
rows = session.exec(
select(Assignment.volunteer_id, Position)
.join(Shift, Shift.id == Assignment.shift_id)
.join(Position, Position.id == Shift.position_id)
.where(Assignment.volunteer_id.in_(volunteer_ids))
).all()
positions_by_volunteer: dict[uuid.UUID, list[Position]] = defaultdict(list)
for volunteer_id, position in rows:
positions_by_volunteer[volunteer_id].append(position)
lead_volunteer_ids = {
account.volunteer_id
for account in session.exec(
select(Account).where(
Account.edition_id == edition.id,
Account.role == Role.LEAD,
Account.volunteer_id.in_(volunteer_ids),
)
)
if account.volunteer_id is not None
}
badges = [
BadgeSpec(
fields=tuple(
BadgeFieldSpec(
text=_field_text(field, volunteer, positions_by_volunteer, lead_volunteer_ids),
x_mm=field.x_mm,
y_mm=field.y_mm,
width_mm=field.width_mm,
height_mm=field.height_mm,
font_size=field.font_size,
align=field.align.value,
bold=field.bold,
color=field.color,
)
for field in fields
)
)
for volunteer in volunteers
]
pdf_bytes = generate_badge_sheets_pdf(badges, template.front_image, template.back_image)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="badges.pdf"'},
)
+41
View File
@@ -7,6 +7,8 @@ from pydantic import BaseModel, ConfigDict, Field
from app.models import (
AssignmentSource,
BadgeAlign,
BadgeFieldType,
PositionCategory,
Role,
)
@@ -334,6 +336,45 @@ class MatrixResponse(ApiModel):
positions: list[MatrixPosition]
# --------------------------------------------------------------------------- #
# Badges (planches PDF) #
# --------------------------------------------------------------------------- #
class BadgeFieldIn(ApiModel):
field_type: BadgeFieldType
x_mm: float
y_mm: float
width_mm: float
height_mm: float
font_size: float = 10.0
align: BadgeAlign = BadgeAlign.LEFT
bold: bool = False
color: str = "#000000"
class BadgeFieldsSet(ApiModel):
"""Remplace l'intégralité des champs du recto — au plus un par
`field_type` (décocher un champ dans l'éditeur retire sa ligne)."""
fields: list[BadgeFieldIn] = Field(default_factory=list)
class BadgeFieldRead(BadgeFieldIn):
id: uuid.UUID
edition_id: uuid.UUID
class BadgeTemplateRead(ApiModel):
edition_id: uuid.UUID
has_front_image: bool
has_back_image: bool
fields: list[BadgeFieldRead]
class BadgeGenerateRequest(ApiModel):
# None/absent = tous les bénévoles de l'édition.
volunteer_ids: list[uuid.UUID] | None = None
# --------------------------------------------------------------------------- #
# Authentification et comptes #
# --------------------------------------------------------------------------- #
+3
View File
@@ -0,0 +1,3 @@
from app.badges.pdf import BadgeFieldSpec, BadgeSpec, generate_badge_sheets_pdf
__all__ = ["BadgeFieldSpec", "BadgeSpec", "generate_badge_sheets_pdf"]
+131
View File
@@ -0,0 +1,131 @@
"""Génération des planches de badges bénévoles (PDF).
Module isolé, fonction pure (`generate_badge_sheets_pdf`) : aucun accès base
ici — voir `app/api/badges.py` pour l'assemblage des données (bénévole →
champs résolus en texte).
Format badge : CR80 (85,6 × 54 mm — taille standard des badges/cartes
d'événement). Planche : grille sur page A4 portrait. Le verso est identique
pour tous les bénévoles : la planche imprime toutes les pages de recto
(personnalisées) puis, pour chacune, la page de verso correspondante (même
disposition, même image répétée à chaque emplacement rempli) — impression
recto-verso classique, page de verso n directement après la page de recto n.
"""
import io
from dataclasses import dataclass
from reportlab.lib.colors import HexColor
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
BADGE_WIDTH_MM = 85.6
BADGE_HEIGHT_MM = 54.0
_MARGIN_MM = 10.0
_GUTTER_MM = 4.0
_PAGE_WIDTH_MM = A4[0] / mm
_PAGE_HEIGHT_MM = A4[1] / mm
@dataclass(frozen=True)
class BadgeFieldSpec:
"""Un champ déjà résolu en texte, positionné depuis le coin haut-gauche
du badge (comme dans l'éditeur visuel du front-end)."""
text: str
x_mm: float
y_mm: float
width_mm: float
height_mm: float
font_size: float
align: str # "LEFT" | "CENTER" | "RIGHT"
bold: bool
color: str # "#rrggbb"
@dataclass(frozen=True)
class BadgeSpec:
"""Un badge = un bénévole, avec ses champs déjà résolus."""
fields: tuple[BadgeFieldSpec, ...]
def _grid_size() -> tuple[int, int]:
usable_w = _PAGE_WIDTH_MM - 2 * _MARGIN_MM
usable_h = _PAGE_HEIGHT_MM - 2 * _MARGIN_MM
cols = max(1, int((usable_w + _GUTTER_MM) // (BADGE_WIDTH_MM + _GUTTER_MM)))
rows = max(1, int((usable_h + _GUTTER_MM) // (BADGE_HEIGHT_MM + _GUTTER_MM)))
return cols, rows
def _cell_origin(index: int, cols: int) -> tuple[float, float]:
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
en mm depuis le coin bas-gauche de la page."""
col, row = index % cols, index // cols
x0 = _MARGIN_MM + col * (BADGE_WIDTH_MM + _GUTTER_MM)
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * BADGE_HEIGHT_MM - row * _GUTTER_MM
return x0, y0
def _draw_background(c: canvas.Canvas, image: bytes | None, x0_mm: float, y0_mm: float) -> None:
if not image:
return
c.drawImage(
ImageReader(io.BytesIO(image)),
x0_mm * mm,
y0_mm * mm,
width=BADGE_WIDTH_MM * mm,
height=BADGE_HEIGHT_MM * mm,
preserveAspectRatio=False,
mask="auto",
)
def _draw_fields(
c: canvas.Canvas, fields: tuple[BadgeFieldSpec, ...], x0_mm: float, y0_mm: float
) -> None:
for f in fields:
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size)
c.setFillColor(HexColor(f.color))
left_x = x0_mm + f.x_mm
# `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
# dessine depuis le BAS de la page. On approxime un alignement en
# haut de la boîte plutôt qu'un centrage vertical exact.
baseline_y = y0_mm + BADGE_HEIGHT_MM - f.y_mm - f.font_size * 0.8
if f.align == "CENTER":
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, f.text)
elif f.align == "RIGHT":
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, f.text)
else:
c.drawString(left_x * mm, baseline_y * mm, f.text)
def generate_badge_sheets_pdf(
badges: list[BadgeSpec],
front_image: bytes | None,
back_image: bytes | None,
) -> bytes:
"""`badges` : un par bénévole, dans l'ordre d'impression. Retourne les
octets du PDF (recto puis verso, planche par planche)."""
cols, rows = _grid_size()
per_page = cols * rows
pages = [badges[i : i + per_page] for i in range(0, len(badges), per_page)]
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
for page_badges in pages:
for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols)
_draw_background(c, front_image, x0, y0)
_draw_fields(c, badge.fields, x0, y0)
c.showPage()
for i in range(len(page_badges)):
x0, y0 = _cell_origin(i, cols)
_draw_background(c, back_image, x0, y0)
c.showPage()
c.save()
return buffer.getvalue()
+18 -1
View File
@@ -54,5 +54,22 @@
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
"email.planning.shift_line": "- {position} : {start} → {end}",
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes."
"availability.empty_window": "Une disponibilité doit avoir une heure de début et de fin différentes.",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.",
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
"badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.",
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
"badge.role.referent": "Référent",
"badge.role.volunteer": "Bénévole",
"position.category.bar": "Bar",
"position.category.ticketing": "Billetterie",
"position.category.welcome": "Accueil",
"position.category.parking": "Parking",
"position.category.cleaning": "Propreté",
"position.category.catering": "Catering",
"position.category.setup": "Montage",
"position.category.stage": "Régie",
"position.category.other": "Autre"
}
+2
View File
@@ -8,6 +8,7 @@ from app.api.artists import router as artist_router
from app.api.assignments import router as assignment_router
from app.api.auth import router as auth_router
from app.api.availability import router as availability_router
from app.api.badges import router as badge_router
from app.api.day_parts import router as day_part_router
from app.api.editions import router as edition_router
from app.api.health import router as health_router
@@ -61,3 +62,4 @@ app.include_router(assignment_router)
app.include_router(solver_router)
app.include_router(matrix_router)
app.include_router(notification_router)
app.include_router(badge_router)
@@ -0,0 +1,82 @@
"""Planches de badges bénévoles : recto/verso et champs positionnés
Revision ID: 0011_badge_template
Revises: 0010_companion_wish
Create Date: 2026-09-20
Deux tables : `badge_template` (images recto/verso, une ligne par édition) et
`badge_field` (champs positionnés sur le recto — nom, poste(s), catégorie,
rôle). Voir `app/badges/pdf.py` pour la génération des planches PDF.
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0011_badge_template"
down_revision: str | None = "0010_companion_wish"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"badge_template",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column("front_image", sa.LargeBinary(), nullable=True),
sa.Column("front_image_content_type", sa.String(), nullable=True),
sa.Column("back_image", sa.LargeBinary(), nullable=True),
sa.Column("back_image_content_type", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("edition_id"),
)
op.create_table(
"badge_field",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("edition_id", sa.Uuid(), nullable=False),
sa.Column(
"field_type",
sa.Enum(
"FULL_NAME", "POSITIONS", "CATEGORY", "ROLE",
name="badgefieldtype", native_enum=False,
),
nullable=False,
),
sa.Column("x_mm", sa.Float(), nullable=False),
sa.Column("y_mm", sa.Float(), nullable=False),
sa.Column("width_mm", sa.Float(), nullable=False),
sa.Column("height_mm", sa.Float(), nullable=False),
sa.Column("font_size", sa.Float(), nullable=False),
sa.Column(
"align",
sa.Enum(
"LEFT", "CENTER", "RIGHT", name="badgealign", native_enum=False
),
nullable=False,
server_default="LEFT",
),
sa.Column(
"bold", sa.Boolean(), server_default="false", nullable=False
),
sa.Column("color", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["edition_id"], ["edition.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"edition_id", "field_type", name="uq_badge_field_edition_type"
),
)
op.create_index("ix_badge_field_edition_id", "badge_field", ["edition_id"])
def downgrade() -> None:
op.drop_index("ix_badge_field_edition_id", table_name="badge_field")
op.drop_table("badge_field")
op.drop_table("badge_template")
+8
View File
@@ -10,6 +10,8 @@ from app.models.domain import (
Artist,
Assignment,
Availability,
BadgeField,
BadgeTemplate,
DayPart,
Edition,
MagicLinkToken,
@@ -22,6 +24,8 @@ from app.models.domain import (
)
from app.models.enums import (
AssignmentSource,
BadgeAlign,
BadgeFieldType,
PositionCategory,
Role,
)
@@ -32,6 +36,10 @@ __all__ = [
"Assignment",
"AssignmentSource",
"Availability",
"BadgeAlign",
"BadgeField",
"BadgeFieldType",
"BadgeTemplate",
"DayPart",
"Edition",
"MagicLinkToken",
+65 -1
View File
@@ -9,13 +9,15 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
import datetime as dt
import uuid
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, Time
from sqlalchemy import Boolean, CheckConstraint, Column, Integer, LargeBinary, Time
from sqlalchemy import Enum as SAEnum
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
from app.models.base import TimestampMixin
from app.models.enums import (
AssignmentSource,
BadgeAlign,
BadgeFieldType,
PositionCategory,
Role,
)
@@ -364,3 +366,65 @@ class Assignment(TimestampMixin, table=True):
volunteer: Volunteer = Relationship(back_populates="assignments")
shift: Shift = Relationship(back_populates="assignments")
# --------------------------------------------------------------------------- #
# Badges (planches recto/verso, voir app/badges/) #
# --------------------------------------------------------------------------- #
class BadgeTemplate(TimestampMixin, table=True):
"""Recto/verso du badge d'une édition. Une ligne par édition (créée à la
demande). Le verso (`back_image`) est le même pour tous les bénévoles ;
le recto combine `front_image` (fond) et les `BadgeField` positionnés
dessus. Images stockées en base (pas de volume de fichiers à gérer)."""
__tablename__ = "badge_template"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", unique=True, ondelete="CASCADE"
)
front_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
front_image_content_type: str | None = None
back_image: bytes | None = Field(default=None, sa_column=Column(LargeBinary))
back_image_content_type: str | None = None
class BadgeField(TimestampMixin, table=True):
"""Un champ positionné sur le recto du badge (nom, poste(s), catégorie,
rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin
haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`).
Au plus un champ par `field_type` et par édition : décocher un champ dans
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
__tablename__ = "badge_field"
__table_args__ = (
UniqueConstraint(
"edition_id", "field_type", name="uq_badge_field_edition_type"
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE"
)
field_type: BadgeFieldType = Field(
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
)
x_mm: float
y_mm: float
width_mm: float
height_mm: float
font_size: float = Field(default=10.0)
align: BadgeAlign = Field(
default=BadgeAlign.LEFT,
sa_column=Column(
SAEnum(BadgeAlign, native_enum=False),
nullable=False,
server_default=BadgeAlign.LEFT.value,
),
)
bold: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="false"),
)
color: str = Field(default="#000000")
+15
View File
@@ -28,3 +28,18 @@ class PositionCategory(enum.StrEnum):
SETUP = "SETUP"
STAGE = "STAGE"
OTHER = "OTHER"
class BadgeFieldType(enum.StrEnum):
"""Champ affichable sur le recto d'un badge (voir `app/badges/`)."""
FULL_NAME = "FULL_NAME"
POSITIONS = "POSITIONS"
CATEGORY = "CATEGORY"
ROLE = "ROLE"
class BadgeAlign(enum.StrEnum):
LEFT = "LEFT"
CENTER = "CENTER"
RIGHT = "RIGHT"
+2
View File
@@ -15,6 +15,8 @@ dependencies = [
"pyjwt>=2.9",
"bcrypt>=4.1",
"ortools>=9.11",
"reportlab>=4.2",
"pillow>=11.0",
]
[dependency-groups]
+162
View File
@@ -0,0 +1,162 @@
"""Configuration et génération des planches de badges (API)."""
import base64
from app.models import Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
# PNG 1×1 valide (transparent), le plus petit possible.
_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY"
"42YAAAAASUVORK5CYII="
)
def _badges_url(edition):
return f"/editions/{edition.id}/badges"
def _set_full_name_field(client, edition):
return client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
{
"field_type": "FULL_NAME",
"x_mm": 5,
"y_mm": 5,
"width_mm": 60,
"height_mm": 10,
"font_size": 12,
}
]
},
)
def test_template_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
client = as_account(lead)
assert client.get(f"{_badges_url(edition)}/template").status_code == 403
resp = client.put(f"{_badges_url(edition)}/template/fields", json={"fields": []})
assert resp.status_code == 403
def test_template_starts_empty(client, edition):
resp = client.get(f"{_badges_url(edition)}/template")
assert resp.status_code == 200
body = resp.json()
assert body["has_front_image"] is False
assert body["has_back_image"] is False
assert body["fields"] == []
def test_uploading_front_and_back_images(client, edition):
front = client.put(
f"{_badges_url(edition)}/template/front-image",
files={"file": ("front.png", _PNG, "image/png")},
)
assert front.status_code == 200
assert front.json()["has_front_image"] is True
back = client.put(
f"{_badges_url(edition)}/template/back-image",
files={"file": ("back.png", _PNG, "image/png")},
)
assert back.status_code == 200
assert back.json()["has_back_image"] is True
served = client.get(f"{_badges_url(edition)}/template/front-image")
assert served.status_code == 200
assert served.headers["content-type"] == "image/png"
assert served.content == _PNG
def test_uploading_an_unsupported_image_type_is_rejected(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/front-image",
files={"file": ("front.gif", b"not-a-real-gif", "image/gif")},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.image_type_not_supported"
def test_setting_the_same_field_type_twice_is_rejected(client, edition):
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
{
"field_type": "FULL_NAME", "x_mm": 0, "y_mm": 0,
"width_mm": 10, "height_mm": 10,
},
{
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
"width_mm": 10, "height_mm": 10,
},
]
},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
def test_generating_without_any_field_configured_is_rejected(client, edition, session):
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
resp = client.post(f"{_badges_url(edition)}/generate", json={})
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.no_fields"
def test_generating_with_no_matching_volunteers_is_rejected(client, edition, session):
_set_full_name_field(client, edition)
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
resp = client.post(
f"{_badges_url(edition)}/generate",
json={"volunteer_ids": ["00000000-0000-0000-0000-000000000000"]},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "badge.no_volunteers"
def test_generating_produces_a_pdf(client, edition, session):
from app.models import Assignment
resp = client.put(
f"{_badges_url(edition)}/template/fields",
json={
"fields": [
{
"field_type": "FULL_NAME", "x_mm": 5, "y_mm": 5,
"width_mm": 60, "height_mm": 10, "font_size": 12,
},
{
"field_type": "POSITIONS", "x_mm": 5, "y_mm": 20,
"width_mm": 60, "height_mm": 10, "font_size": 9,
},
]
},
)
assert resp.status_code == 200
client.put(
f"{_badges_url(edition)}/template/front-image",
files={"file": ("front.png", _PNG, "image/png")},
)
client.put(
f"{_badges_url(edition)}/template/back-image",
files={"file": ("back.png", _PNG, "image/png")},
)
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
position = make_position(session, edition, name="Bar central")
shift = make_shift(session, edition, position, start=dt_at(9, 18), end=dt_at(9, 22))
session.add(Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id))
session.commit()
resp = client.post(
f"{_badges_url(edition)}/generate",
json={"volunteer_ids": [str(volunteer.id)]},
)
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/pdf"
assert resp.content.startswith(b"%PDF")
+62
View File
@@ -0,0 +1,62 @@
"""Génération des planches de badges (module pur, sans base)."""
from app.badges.pdf import (
BadgeFieldSpec,
BadgeSpec,
_cell_origin,
_grid_size,
generate_badge_sheets_pdf,
)
def _badge(text="Alex Martin") -> BadgeSpec:
return BadgeSpec(
fields=(
BadgeFieldSpec(
text=text,
x_mm=5,
y_mm=5,
width_mm=60,
height_mm=10,
font_size=12,
align="LEFT",
bold=True,
color="#000000",
),
)
)
def test_grid_size_fits_a4_with_cr80_badges():
# A4 (210 × 297 mm), marge 10 mm, gouttière 4 mm, badge CR80 85,6 × 54 mm
# ⇒ 2 colonnes (190 mm utiles / 89,6 mm par badge+gouttière) x 4 rangées
# (277 mm utiles / 58 mm par badge+gouttière).
assert _grid_size() == (2, 4)
def test_cell_origin_places_badges_left_to_right_top_to_bottom():
cols, _ = _grid_size()
x0, y0 = _cell_origin(0, cols)
x1, _ = _cell_origin(1, cols)
_, y_row2 = _cell_origin(cols, cols)
assert x1 > x0 # la 2e colonne est à droite de la 1re
assert y_row2 < y0 # la 2e rangée est plus bas (repère PDF : Y croît vers le haut)
def test_generates_a_valid_pdf_for_a_single_badge():
pdf = generate_badge_sheets_pdf([_badge()], front_image=None, back_image=None)
assert pdf.startswith(b"%PDF")
assert pdf.rstrip().endswith(b"%%EOF")
def test_more_badges_than_one_sheet_produces_a_larger_pdf():
cols, rows = _grid_size()
per_page = cols * rows
one_page = generate_badge_sheets_pdf(
[_badge() for _ in range(per_page)], front_image=None, back_image=None
)
# Un badge de plus déborde sur une 2e planche (recto + verso en plus).
two_pages = generate_badge_sheets_pdf(
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
)
assert len(two_pages) > len(one_page)
+2
View File
@@ -27,6 +27,8 @@ EXPECTED_TABLES = {
"shift",
"availability",
"assignment",
"badge_template",
"badge_field",
}
+83
View File
@@ -1,3 +1,5 @@
import { useEffect, useState } from 'react';
import {
useMutation,
useQuery,
@@ -15,6 +17,8 @@ import type {
AssignmentUpdate,
AssignmentValidateResult,
AvailabilitySet,
BadgeFieldsSet,
BadgeGenerateRequest,
DayPartCreate,
DayPartUpdate,
EditionUpdate,
@@ -131,6 +135,47 @@ export function useMatrix() {
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
}
/** Configuration du recto/verso des badges (images, champs). */
export function useBadgeTemplate() {
return useQuery({
queryKey: ['badgeTemplate', edition],
queryFn: () => api.badges.getTemplate(edition),
});
}
/**
* URL objet (aperçu) de l'image recto/verso, ou `null` si non définie.
* `refreshKey` (ex. `dataUpdatedAt` de `useBadgeTemplate`) force un nouveau
* fetch après un remplacement d'image — un simple `hasImage` ne changerait
* pas de valeur entre deux images successives.
*/
export function useBadgeImageUrl(
kind: 'front' | 'back',
hasImage: boolean,
refreshKey: number | string,
) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string | null = null;
let cancelled = false;
if (!hasImage) {
setUrl(null);
return;
}
const fetchBlob = kind === 'front' ? api.badges.frontImageBlob : api.badges.backImageBlob;
fetchBlob(edition).then((blob) => {
if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [kind, hasImage, refreshKey]);
return url;
}
/**
* Dernières suggestions du solveur (lecture seule du cache posé par
* `useRunSolver`). Ne déclenche jamais de calcul elle-même.
@@ -459,3 +504,41 @@ export function useSendPlanningNotification() {
api.notifications.sendPlanning(edition, body),
});
}
function useInvalidateBadgeTemplate() {
const client = useQueryClient();
return () => client.invalidateQueries({ queryKey: ['badgeTemplate', edition] });
}
export function useUploadBadgeFrontImage() {
const invalidate = useInvalidateBadgeTemplate();
return useMutation({
mutationFn: (file: File) => api.badges.uploadFrontImage(edition, file),
onSuccess: invalidate,
});
}
export function useUploadBadgeBackImage() {
const invalidate = useInvalidateBadgeTemplate();
return useMutation({
mutationFn: (file: File) => api.badges.uploadBackImage(edition, file),
onSuccess: invalidate,
});
}
/** Remplace l'intégralité des champs du recto (positions, activation). */
export function useSetBadgeFields() {
const invalidate = useInvalidateBadgeTemplate();
return useMutation({
mutationFn: (body: BadgeFieldsSet) => api.badges.setFields(edition, body),
onSuccess: invalidate,
});
}
/** Génère la planche PDF ; ne fait que retourner le fichier (le composant
* appelant déclenche le téléchargement). */
export function useGenerateBadges() {
return useMutation({
mutationFn: (body: BadgeGenerateRequest) => api.badges.generate(edition, body),
});
}
+65
View File
@@ -7,6 +7,8 @@ import type {
AssignmentUpdate,
AssignmentValidateRequest,
AvailabilitySet,
BadgeFieldsSet,
BadgeGenerateRequest,
DayPartCreate,
DayPartUpdate,
EditionUpdate,
@@ -66,6 +68,35 @@ export function createHttpApi(baseUrl: string): PlanningApi {
return body as T;
}
// Réponses binaires (images du badge, PDF généré) : pas de JSON, on ne
// veut que les octets (ou `null` pour une image absente, en 404).
async function requestBlob(path: string, init?: RequestInit): Promise<Blob | null> {
const token = getAuthToken();
const isForm = init?.body instanceof FormData;
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
...(isForm ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init?.headers,
},
credentials: 'include',
});
if (res.status === 404) return null;
if (!res.ok) {
let code = 'error.generic';
try {
const body = (await res.json()) as { detail?: { code?: string } };
code = body.detail?.code ?? code;
} catch {
// Corps non JSON (ex. erreur réseau) : garde le code générique.
}
if (res.status === 401) notifyUnauthorized();
throw new ApiError(res.status, code, []);
}
return res.blob();
}
const query = (params: Record<string, string | undefined>) => {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
@@ -191,6 +222,40 @@ export function createHttpApi(baseUrl: string): PlanningApi {
matrix: {
get: (editionId) => request(`${base(editionId)}/matrix`),
},
badges: {
getTemplate: (editionId) => request(`${base(editionId)}/badges/template`),
uploadFrontImage: (editionId, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/badges/template/front-image`, {
method: 'PUT',
body: form,
});
},
uploadBackImage: (editionId, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/badges/template/back-image`, {
method: 'PUT',
body: form,
});
},
frontImageBlob: (editionId) => requestBlob(`${base(editionId)}/badges/template/front-image`),
backImageBlob: (editionId) => requestBlob(`${base(editionId)}/badges/template/back-image`),
setFields: (editionId, body: BadgeFieldsSet) =>
request(`${base(editionId)}/badges/template/fields`, {
method: 'PUT',
body: JSON.stringify(body),
}),
generate: async (editionId, body: BadgeGenerateRequest) => {
const blob = await requestBlob(`${base(editionId)}/badges/generate`, {
method: 'POST',
body: JSON.stringify(body),
});
if (!blob) throw new ApiError(500, 'error.generic', []);
return blob;
},
},
solver: {
run: (editionId) =>
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
+15
View File
@@ -5,6 +5,7 @@ import type {
ArtistRead,
AssignmentRead,
AvailabilityRead,
BadgeFieldRead,
DayPartRead,
EditionRead,
PositionCategory,
@@ -39,6 +40,13 @@ export interface MockState {
accounts: AccountRead[];
/** accountId -> mot de passe en clair (mock uniquement). */
passwords: Map<string, string>;
badgeTemplate: {
frontImage: string | null; // data URL
frontImageType: string | null;
backImage: string | null; // data URL
backImageType: string | null;
fields: BadgeFieldRead[];
};
seq: number;
}
@@ -412,6 +420,13 @@ export function buildMockState(): MockState {
accounts,
passwords,
availability,
badgeTemplate: {
frontImage: null,
frontImageType: null,
backImage: null,
backImageType: null,
fields: [],
},
seq,
};
}
+89
View File
@@ -10,6 +10,10 @@ import type {
AssignmentValidateResult,
AvailabilityRead,
AvailabilitySet,
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeTemplateRead,
DayPartCreate,
DayPartRead,
DayPartUpdate,
@@ -82,6 +86,28 @@ const notFound = (): never => {
throw new ApiError(404, 'error.not_found');
};
const fileToDataUrl = (file: File): Promise<string> =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
// PDF valide minimal (une page blanche) — remplace la vraie génération
// (réservée au back-end réel) pour que le téléchargement fonctionne en mode
// démonstration.
const MOCK_PDF_BLOB = () =>
new Blob(
[
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' +
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]>>endobj\n' +
'trailer<</Root 1 0 R>>\n%%EOF',
],
{ type: 'application/pdf' },
);
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
const addDays = (day: string, n: number): string => {
@@ -1301,6 +1327,69 @@ export function createMockApi(): PlanningApi {
get: () => delay(buildMatrix()),
},
badges: {
getTemplate: (): Promise<BadgeTemplateRead> =>
delay({
edition_id: state.edition.id,
has_front_image: state.badgeTemplate.frontImage !== null,
has_back_image: state.badgeTemplate.backImage !== null,
fields: state.badgeTemplate.fields,
}),
uploadFrontImage: async (_edition, file: File) => {
state.badgeTemplate.frontImage = await fileToDataUrl(file);
state.badgeTemplate.frontImageType = file.type;
return delay<BadgeTemplateRead>({
edition_id: state.edition.id,
has_front_image: true,
has_back_image: state.badgeTemplate.backImage !== null,
fields: state.badgeTemplate.fields,
});
},
uploadBackImage: async (_edition, file: File) => {
state.badgeTemplate.backImage = await fileToDataUrl(file);
state.badgeTemplate.backImageType = file.type;
return delay<BadgeTemplateRead>({
edition_id: state.edition.id,
has_front_image: state.badgeTemplate.frontImage !== null,
has_back_image: true,
fields: state.badgeTemplate.fields,
});
},
frontImageBlob: async () => {
if (!state.badgeTemplate.frontImage) return null;
return (await fetch(state.badgeTemplate.frontImage)).blob();
},
backImageBlob: async () => {
if (!state.badgeTemplate.backImage) return null;
return (await fetch(state.badgeTemplate.backImage)).blob();
},
setFields: (_edition, body: BadgeFieldsSet) => {
const seen = new Set<string>();
for (const f of body.fields) {
if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type');
seen.add(f.field_type);
}
const fields: BadgeFieldRead[] = body.fields.map((f) => ({
...f,
id: `bf${state.seq++}`,
edition_id: state.edition.id,
}));
state.badgeTemplate.fields = fields;
return delay(fields);
},
generate: (_edition, body: BadgeGenerateRequest) => {
if (state.badgeTemplate.fields.length === 0) {
throw new ApiError(422, 'badge.no_fields');
}
const ids = body.volunteer_ids ?? state.volunteers.map((v) => v.id);
const matching = state.volunteers.filter((v) => ids.includes(v.id));
if (matching.length === 0) throw new ApiError(422, 'badge.no_volunteers');
// La composition réelle du PDF (reportlab) est réservée au back-end :
// le mode démonstration renvoie un PDF minimal valide, téléchargeable.
return delay(MOCK_PDF_BLOB());
},
},
solver: {
run: () => delay(runSolver()),
},
+38
View File
@@ -416,6 +416,44 @@ export interface MatrixResponse {
positions: MatrixPosition[];
}
// --------------------------------------------------------------------------- //
// Badges (planches PDF) //
// --------------------------------------------------------------------------- //
export type BadgeFieldType = 'FULL_NAME' | 'POSITIONS' | 'CATEGORY' | 'ROLE';
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
export interface BadgeFieldIn {
field_type: BadgeFieldType;
x_mm: number;
y_mm: number;
width_mm: number;
height_mm: number;
font_size: number;
align: BadgeAlign;
bold: boolean;
color: string;
}
export interface BadgeFieldsSet {
fields: BadgeFieldIn[];
}
export interface BadgeFieldRead extends BadgeFieldIn {
id: string;
edition_id: string;
}
export interface BadgeTemplateRead {
edition_id: string;
has_front_image: boolean;
has_back_image: boolean;
fields: BadgeFieldRead[];
}
export interface BadgeGenerateRequest {
volunteer_ids?: string[] | null;
}
// --------------------------------------------------------------------------- //
// Authentification //
// --------------------------------------------------------------------------- //
+17
View File
@@ -13,6 +13,10 @@ import type {
AssignmentWriteResult,
AvailabilityRead,
AvailabilitySet,
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeTemplateRead,
DayPartCreate,
DayPartRead,
DayPartUpdate,
@@ -132,6 +136,19 @@ export interface PlanningApi {
matrix: {
get(editionId: string): Promise<MatrixResponse>;
};
badges: {
/** Configuration du recto/verso (COORDINATION uniquement). */
getTemplate(editionId: string): Promise<BadgeTemplateRead>;
uploadFrontImage(editionId: string, file: File): Promise<BadgeTemplateRead>;
uploadBackImage(editionId: string, file: File): Promise<BadgeTemplateRead>;
/** Image brute (aperçu dans l'éditeur), `null` si non définie. */
frontImageBlob(editionId: string): Promise<Blob | null>;
backImageBlob(editionId: string): Promise<Blob | null>;
/** Remplace l'intégralité des champs du recto. */
setFields(editionId: string, body: BadgeFieldsSet): Promise<BadgeFieldRead[]>;
/** Génère la planche PDF (recto personnalisé + verso fixe). */
generate(editionId: string, body: BadgeGenerateRequest): Promise<Blob>;
};
solver: {
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
run(editionId: string): Promise<SolverRunResult>;
+2
View File
@@ -4,6 +4,7 @@ import { HomeRedirect, RequireAuth } from '@/auth/RequireAuth';
import { AppShell } from './AppShell';
import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
import { BadgesPage } from '@/pages/BadgesPage';
import { ConfigPage } from '@/pages/ConfigPage';
import { GrillePage } from '@/pages/GrillePage';
import { MobileLeadPage } from '@/pages/MobileLeadPage';
@@ -26,6 +27,7 @@ export const routes: RouteObject[] = [
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
{ path: 'mobile-referent', element: <MobileLeadPage /> },
{ path: 'configuration', element: <ConfigPage /> },
{ path: 'badges', element: <BadgesPage /> },
],
},
],
+2
View File
@@ -9,6 +9,7 @@ export const ROUTE_ROLES: Record<string, Role[]> = {
'/grille': ['COORDINATOR'],
'/benevoles': ['COORDINATOR'],
'/configuration': ['COORDINATOR'],
'/badges': ['COORDINATOR'],
'/mobile-benevole': ['VOLUNTEER'],
'/mobile-referent': ['LEAD'],
};
@@ -19,6 +20,7 @@ export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
{ to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' },
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
{ to: '/badges', num: '07', labelKey: 'tab.badges' },
];
export function canAccess(pathname: string, role: Role): boolean {
+38 -1
View File
@@ -57,6 +57,7 @@
"tab.mobileVolunteer": "Bénévole · mobile",
"tab.mobileLead": "Référent · mobile",
"tab.config": "Configuration",
"tab.badges": "Badges",
"position.category.bar": "Bar",
"position.category.ticketing": "Billetterie",
"position.category.welcome": "Accueil",
@@ -320,5 +321,41 @@
"config.artists.endTime": "Heure de fin du passage",
"grille.panel.wishesPosition": "Vœu de poste",
"grille.panel.artistConflict": "Manquerait un artiste souhaité",
"grille.panel.withCompanion": "Avec {count} compagnon(s) souhaité(s)"
"grille.panel.withCompanion": "Avec {count} compagnon(s) souhaité(s)",
"badges.admin": "PLANCHES DE BADGES",
"badges.tab.customize": "PERSONNALISER",
"badges.tab.generate": "GÉNÉRER",
"badges.front": "Recto",
"badges.front.help": "Fond personnalisable + champs positionnables (nom, poste, catégorie, rôle). Propre à cette édition.",
"badges.back": "Verso",
"badges.back.help": "Une seule image, la même pour tous les badges de cette édition.",
"badges.fields": "Champs du recto",
"badges.field.fullName": "Prénom + nom",
"badges.field.fullName.placeholder": "Prénom Nom",
"badges.field.positions": "Poste(s) affecté(s)",
"badges.field.positions.placeholder": "Bar central, Accueil",
"badges.field.category": "Catégorie",
"badges.field.category.placeholder": "Bar",
"badges.field.role": "Rôle (référent / bénévole)",
"badges.field.role.placeholder": "Référent",
"badges.field.size": "Taille",
"badges.editor.hint": "Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
"badges.image.choose": "Choisir une image",
"badges.image.replace": "Remplacer l'image",
"badges.uploading": "Envoi…",
"badges.save": "Enregistrer",
"badges.saving": "Enregistrement…",
"badges.saved": "Enregistré ✓",
"badges.generate.title": "Génération de la planche PDF",
"badges.generate.all": "Tous les bénévoles ({count})",
"badges.generate.pick": "Choisir ({count} sélectionné(s))",
"badges.generate.action": "Générer le PDF",
"badges.generating": "Génération…",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.",
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
"badge.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."
}
+545
View File
@@ -0,0 +1,545 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
useBadgeImageUrl,
useBadgeTemplate,
useGenerateBadges,
useSetBadgeFields,
useUploadBadgeBackImage,
useUploadBadgeFrontImage,
useVolunteers,
} from '@/api/hooks';
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
import { ApiError } from '@/api';
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
// Badge format CR80 (85,6 × 54 mm — cartes/badges d'événement standard), voir
// `backend/app/badges/pdf.py` (même format, mêmes millimètres). `SCALE` fixe
// la taille d'édition à l'écran ; les coordonnées enregistrées restent en mm.
const BADGE_W_MM = 85.6;
const BADGE_H_MM = 54;
const SCALE = 4.6; // px par mm à l'écran
type FieldState = BadgeFieldIn & { enabled: boolean };
const FIELD_TYPES: BadgeFieldType[] = ['FULL_NAME', 'POSITIONS', 'CATEGORY', 'ROLE'];
const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
FULL_NAME: 'badges.field.fullName',
POSITIONS: 'badges.field.positions',
CATEGORY: 'badges.field.category',
ROLE: 'badges.field.role',
};
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',
};
// Position/taille par défaut à l'activation d'un champ — empilés
// verticalement pour ne pas se superposer, taille de police décroissante.
const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = {
FULL_NAME: {
x_mm: 5, y_mm: 6, width_mm: 70, height_mm: 12,
font_size: 13, align: 'LEFT', bold: true, color: '#000000',
},
POSITIONS: {
x_mm: 5, y_mm: 20, width_mm: 70, height_mm: 10,
font_size: 9, align: 'LEFT', bold: false, color: '#000000',
},
CATEGORY: {
x_mm: 5, y_mm: 32, width_mm: 70, height_mm: 9,
font_size: 9, align: 'LEFT', bold: false, color: '#000000',
},
ROLE: {
x_mm: 5, y_mm: 43, width_mm: 70, height_mm: 9,
font_size: 9, align: 'LEFT', bold: true, color: '#a1442b',
},
};
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
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() {
const [tab, setTab] = useState<'customize' | 'generate'>('customize');
return (
<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]">
<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>
</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 [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
useEffect(() => {
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
}, [template.data]);
if (template.isLoading || !fields) return <Loading />;
if (template.error) return <ErrorNote error={template.error} />;
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
};
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,
};
},
);
setFields.mutate({ fields: payload });
};
return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4 lg:flex-row lg:items-start">
<div className="flex 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)}
/>
<div
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
style={{
width: BADGE_W_MM * SCALE,
height: BADGE_H_MM * SCALE,
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
}}
onPointerDown={() => setSelected(null)}
>
{FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => (
<FieldBox
key={type}
field={fields[type]}
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')}
</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: BADGE_W_MM * SCALE, height: BADGE_H_MM * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
/>
</div>
</Panel>
</div>
<Panel className="min-w-[320px] flex-1">
<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>
);
}
function FieldBox({
field,
selected,
onSelect,
onChange,
}: {
field: FieldState;
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, BADGE_W_MM - field.width_mm),
y_mm: clamp(d.startYMm + dyMm, 0, BADGE_H_MM - 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;
onChange({
width_mm: clamp(d.startWMm + dwMm, 10, BADGE_W_MM - field.x_mm),
height_mm: clamp(d.startHMm + dhMm, 5, BADGE_H_MM - field.y_mm),
});
};
return (
<div
onPointerDown={startDrag}
onPointerMove={onDragMove}
onPointerUp={() => (dragRef.current = null)}
className={cx(
'absolute flex cursor-move items-center overflow-hidden border-[1.5px] bg-white/55 px-1',
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,
}}
>
<span
className="pointer-events-none truncate font-sans"
style={{
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',
}}
>
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
</span>
<div
onPointerDown={startResize}
onPointerMove={onResizeMove}
onPointerUp={() => (resizeRef.current = null)}
className="absolute bottom-0 right-0 h-[10px] w-[10px] cursor-nwse-resize border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
/>
</div>
);
}
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}>
<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.enabled ? (
<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>
<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>
);
}
// --------------------------------------------------------------------------- //
// Génération //
// --------------------------------------------------------------------------- //
function BadgeGenerateTab() {
const volunteers = useVolunteers();
const generate = useGenerateBadges();
const [mode, setMode] = useState<'all' | 'pick'>('all');
const [picked, setPicked] = useState<Set<string>>(new Set());
const sorted = useMemo(
() => [...(volunteers.data ?? [])].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr')),
[volunteers.data],
);
if (volunteers.isLoading) return <Loading />;
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
if (sorted.length === 0) return <EmptyNote>{t('volunteers.noData')}</EmptyNote>;
const toggle = (id: string) => {
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const download = async () => {
const volunteer_ids = mode === 'pick' ? [...picked] : undefined;
try {
const blob = await generate.mutateAsync({ volunteer_ids });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'badges.pdf';
a.click();
URL.revokeObjectURL(url);
} catch {
// L'erreur est déjà exposée via `generate.isError` / `generate.error`.
}
};
const disabled = generate.isPending || (mode === 'pick' && picked.size === 0);
return (
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4">
<Panel className="max-w-[560px]">
<PanelHeader>{t('badges.generate.title')}</PanelHeader>
<div className="flex flex-col gap-3 p-3">
<div className="flex gap-2">
<Chip active={mode === 'all'} onClick={() => setMode('all')}>
{t('badges.generate.all', { count: sorted.length })}
</Chip>
<Chip active={mode === 'pick'} onClick={() => setMode('pick')}>
{t('badges.generate.pick', { count: picked.size })}
</Chip>
</div>
{mode === 'pick' ? (
<div className="max-h-[320px] overflow-auto border-2 border-ink">
{sorted.map((v) => (
<label
key={v.id}
className="flex cursor-pointer items-center gap-[8px] border-b border-ink/15 px-[10px] py-[7px] font-mono text-[11px] hover:bg-yellow-soft"
>
<input
type="checkbox"
checked={picked.has(v.id)}
onChange={() => toggle(v.id)}
className="h-[14px] w-[14px] accent-ink"
/>
{v.first_name} {v.last_name}
</label>
))}
</div>
) : null}
<div className="flex items-center gap-3">
<Btn onClick={download} disabled={disabled}>
{generate.isPending ? t('badges.generating') : t('badges.generate.action')}
</Btn>
{generate.isError ? (
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
{errorMessage(generate.error)}
</span>
) : null}
</div>
</div>
</Panel>
</div>
);
}
function errorMessage(error: unknown): string {
if (error instanceof ApiError) return t(error.code);
return t('error.generic');
}