From b7fc80a7dceb5a981fbd0702655aebccf4a2527d Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Sun, 20 Sep 2026 20:05:03 +0200 Subject: [PATCH] =?UTF-8?q?=C3=89tape=2020=20:=20planches=20de=20badges=20?= =?UTF-8?q?b=C3=A9n=C3=A9voles=20(PDF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 22 + backend/app/api/badges.py | 291 ++++++++++ backend/app/api/schemas.py | 41 ++ backend/app/badges/__init__.py | 3 + backend/app/badges/pdf.py | 131 +++++ backend/app/i18n/locales/fr.json | 19 +- backend/app/main.py | 2 + .../versions/0011_badge_template.py | 82 +++ backend/app/models/__init__.py | 8 + backend/app/models/domain.py | 66 ++- backend/app/models/enums.py | 15 + backend/pyproject.toml | 2 + backend/tests/test_badges_api.py | 162 ++++++ backend/tests/test_badges_pdf.py | 62 ++ backend/tests/test_models.py | 2 + frontend/src/api/hooks.ts | 83 +++ frontend/src/api/http-client.ts | 65 +++ frontend/src/api/mock/dataset.ts | 15 + frontend/src/api/mock/mock-client.ts | 89 +++ frontend/src/api/schema.ts | 38 ++ frontend/src/api/types.ts | 17 + frontend/src/app/router.tsx | 2 + frontend/src/auth/roles.ts | 2 + frontend/src/i18n/locales/fr.json | 39 +- frontend/src/pages/BadgesPage.tsx | 545 ++++++++++++++++++ 25 files changed, 1800 insertions(+), 3 deletions(-) create mode 100644 backend/app/api/badges.py create mode 100644 backend/app/badges/__init__.py create mode 100644 backend/app/badges/pdf.py create mode 100644 backend/app/migrations/versions/0011_badge_template.py create mode 100644 backend/tests/test_badges_api.py create mode 100644 backend/tests/test_badges_pdf.py create mode 100644 frontend/src/pages/BadgesPage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 1bf476b..77dfb68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `, 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 + diff --git a/backend/app/api/badges.py b/backend/app/api/badges.py new file mode 100644 index 0000000..b7f2871 --- /dev/null +++ b/backend/app/api/badges.py @@ -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"'}, + ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 02324d4..b71fc97 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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 # # --------------------------------------------------------------------------- # diff --git a/backend/app/badges/__init__.py b/backend/app/badges/__init__.py new file mode 100644 index 0000000..800089c --- /dev/null +++ b/backend/app/badges/__init__.py @@ -0,0 +1,3 @@ +from app.badges.pdf import BadgeFieldSpec, BadgeSpec, generate_badge_sheets_pdf + +__all__ = ["BadgeFieldSpec", "BadgeSpec", "generate_badge_sheets_pdf"] diff --git a/backend/app/badges/pdf.py b/backend/app/badges/pdf.py new file mode 100644 index 0000000..15d6774 --- /dev/null +++ b/backend/app/badges/pdf.py @@ -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() diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index b4c3100..53a64c0 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -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" } diff --git a/backend/app/main.py b/backend/app/main.py index 894c976..4e8aff2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/migrations/versions/0011_badge_template.py b/backend/app/migrations/versions/0011_badge_template.py new file mode 100644 index 0000000..c5b672a --- /dev/null +++ b/backend/app/migrations/versions/0011_badge_template.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 0143921..98a7db5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 52490da..35f3873 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -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") diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py index 14bc5f8..7333dc1 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -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" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7ebb6ee..8847cec 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ "pyjwt>=2.9", "bcrypt>=4.1", "ortools>=9.11", + "reportlab>=4.2", + "pillow>=11.0", ] [dependency-groups] diff --git a/backend/tests/test_badges_api.py b/backend/tests/test_badges_api.py new file mode 100644 index 0000000..8c3139b --- /dev/null +++ b/backend/tests/test_badges_api.py @@ -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") diff --git a/backend/tests/test_badges_pdf.py b/backend/tests/test_badges_pdf.py new file mode 100644 index 0000000..126d4d0 --- /dev/null +++ b/backend/tests/test_badges_pdf.py @@ -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) diff --git a/backend/tests/test_models.py b/backend/tests/test_models.py index 1d0305d..4757ef6 100644 --- a/backend/tests/test_models.py +++ b/backend/tests/test_models.py @@ -27,6 +27,8 @@ EXPECTED_TABLES = { "shift", "availability", "assignment", + "badge_template", + "badge_field", } diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index df8297f..c812d45 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -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(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), + }); +} diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 112359b..bf4ba2c 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -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 { + 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) => { 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' }), diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index 6795ada..ac80c6d 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -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; + 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, }; } diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 14a6c41..29e07a5 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -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 => + 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<>endobj\n' + + '2 0 obj<>endobj\n' + + '3 0 obj<>endobj\n' + + 'trailer<>\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 => + 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({ + 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({ + 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(); + 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()), }, diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index c3b57c6..5bccdae 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -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 // // --------------------------------------------------------------------------- // diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 0a23469..3c67397 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; }; + badges: { + /** Configuration du recto/verso (COORDINATION uniquement). */ + getTemplate(editionId: string): Promise; + uploadFrontImage(editionId: string, file: File): Promise; + uploadBackImage(editionId: string, file: File): Promise; + /** Image brute (aperçu dans l'éditeur), `null` si non définie. */ + frontImageBlob(editionId: string): Promise; + backImageBlob(editionId: string): Promise; + /** Remplace l'intégralité des champs du recto. */ + setFields(editionId: string, body: BadgeFieldsSet): Promise; + /** Génère la planche PDF (recto personnalisé + verso fixe). */ + generate(editionId: string, body: BadgeGenerateRequest): Promise; + }; solver: { /** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */ run(editionId: string): Promise; diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 3d31048..5421098 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -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: }, { path: 'mobile-referent', element: }, { path: 'configuration', element: }, + { path: 'badges', element: }, ], }, ], diff --git a/frontend/src/auth/roles.ts b/frontend/src/auth/roles.ts index c3809ad..58e2d11 100644 --- a/frontend/src/auth/roles.ts +++ b/frontend/src/auth/roles.ts @@ -9,6 +9,7 @@ export const ROUTE_ROLES: Record = { '/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 { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 3e66716..3e232ff 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -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." } diff --git a/frontend/src/pages/BadgesPage.tsx b/frontend/src/pages/BadgesPage.tsx new file mode 100644 index 0000000..5769446 --- /dev/null +++ b/frontend/src/pages/BadgesPage.tsx @@ -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 = { + FULL_NAME: 'badges.field.fullName', + POSITIONS: 'badges.field.positions', + CATEGORY: 'badges.field.category', + ROLE: 'badges.field.role', +}; + +const FIELD_PLACEHOLDER_KEY: Record = { + FULL_NAME: 'badges.field.fullName.placeholder', + POSITIONS: 'badges.field.positions.placeholder', + CATEGORY: 'badges.field.category.placeholder', + ROLE: 'badges.field.role.placeholder', +}; + +// 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> = { + 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 { + const byType = new Map(saved.map((f) => [f.field_type, f])); + const out = {} as Record; + for (const type of FIELD_TYPES) { + const existing = byType.get(type); + out[type] = existing + ? { ...existing, enabled: true } + : { field_type: type, enabled: false, ...FIELD_DEFAULTS[type] }; + } + return out; +} + +export function BadgesPage() { + const [tab, setTab] = useState<'customize' | 'generate'>('customize'); + + return ( +
+
+ {t('badges.admin')} + setTab('customize')}> + {t('badges.tab.customize')} + + setTab('generate')}> + {t('badges.tab.generate')} + +
+ + {tab === 'customize' ? : } +
+ ); +} + +// --------------------------------------------------------------------------- // +// Personnalisation // +// --------------------------------------------------------------------------- // +function BadgeCustomizeTab() { + const template = useBadgeTemplate(); + const frontUrl = useBadgeImageUrl( + 'front', template.data?.has_front_image ?? false, template.dataUpdatedAt, + ); + const backUrl = useBadgeImageUrl( + 'back', template.data?.has_back_image ?? false, template.dataUpdatedAt, + ); + const uploadFront = useUploadBadgeFrontImage(); + const uploadBack = useUploadBadgeBackImage(); + const setFields = useSetBadgeFields(); + + const [fields, setFieldsState] = useState | null>(null); + const [selected, setSelected] = useState(null); + + useEffect(() => { + if (template.data) setFieldsState(fieldsFromServer(template.data.fields)); + }, [template.data]); + + if (template.isLoading || !fields) return ; + if (template.error) return ; + + const updateField = (type: BadgeFieldType, patch: Partial) => { + 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 ( +
+
+ + + {t('badges.front')} + + +
+ uploadFront.mutate(file)} + /> +
setSelected(null)} + > + {FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => ( + setSelected(type)} + onChange={(patch) => updateField(type, patch)} + /> + ))} +
+

+ {t('badges.editor.hint')} +

+
+
+ + + + {t('badges.back')} + + +
+ uploadBack.mutate(file)} + /> +
+
+ +
+ + + {t('badges.fields')} +
+ {FIELD_TYPES.map((type) => ( + setSelected(type)} + onChange={(patch) => updateField(type, patch)} + /> + ))} +
+
+ + {setFields.isPending ? t('badges.saving') : t('badges.save')} + + {setFields.isSuccess ? ( + {t('badges.saved')} + ) : null} + {setFields.isError ? ( + + {errorMessage(setFields.error)} + + ) : null} +
+
+
+ ); +} + +function ImageUploadRow({ + url, + pending, + error, + onPick, +}: { + url: string | null; + pending: boolean; + error: unknown; + onPick: (file: File) => void; +}) { + const inputRef = useRef(null); + return ( +
+ inputRef.current?.click()} disabled={pending}> + {pending ? t('badges.uploading') : url ? t('badges.image.replace') : t('badges.image.choose')} + + { + const file = e.target.files?.[0]; + if (file) onPick(file); + e.target.value = ''; + }} + /> + {error ? ( + {errorMessage(error)} + ) : null} +
+ ); +} + +function FieldBox({ + field, + selected, + onSelect, + onChange, +}: { + field: FieldState; + selected: boolean; + onSelect: () => void; + onChange: (patch: Partial) => void; +}) { + const dragRef = useRef<{ startXMm: number; startYMm: number; pointerXPx: number; pointerYPx: number } | null>( + null, + ); + const resizeRef = useRef<{ startWMm: number; startHMm: number; pointerXPx: number; pointerYPx: number } | null>( + null, + ); + + const startDrag = (e: React.PointerEvent) => { + e.stopPropagation(); + onSelect(); + (e.target as Element).setPointerCapture(e.pointerId); + dragRef.current = { startXMm: field.x_mm, startYMm: field.y_mm, pointerXPx: e.clientX, pointerYPx: e.clientY }; + }; + + const onDragMove = (e: React.PointerEvent) => { + if (!dragRef.current) return; + const d = dragRef.current; + const dxMm = (e.clientX - d.pointerXPx) / SCALE; + const dyMm = (e.clientY - d.pointerYPx) / SCALE; + onChange({ + x_mm: clamp(d.startXMm + dxMm, 0, 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 ( +
(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, + }} + > + 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])} + +
(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" + /> +
+ ); +} + +function FieldEditorRow({ + type, + field, + active, + onFocus, + onChange, +}: { + type: BadgeFieldType; + field: FieldState; + active: boolean; + onFocus: () => void; + onChange: (patch: Partial) => void; +}) { + return ( +
+ + + {field.enabled ? ( +
+ + +
+ {(['LEFT', 'CENTER', 'RIGHT'] as BadgeAlign[]).map((align) => ( + + ))} +
+ + + + onChange({ color: e.target.value })} + className="h-[24px] w-[30px] cursor-pointer border-2 border-ink p-0" + /> +
+ ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- // +// Génération // +// --------------------------------------------------------------------------- // +function BadgeGenerateTab() { + const volunteers = useVolunteers(); + const generate = useGenerateBadges(); + const [mode, setMode] = useState<'all' | 'pick'>('all'); + const [picked, setPicked] = useState>(new Set()); + + const sorted = useMemo( + () => [...(volunteers.data ?? [])].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr')), + [volunteers.data], + ); + + if (volunteers.isLoading) return ; + if (volunteers.error) return ; + if (sorted.length === 0) return {t('volunteers.noData')}; + + 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 ( +
+ + {t('badges.generate.title')} +
+
+ setMode('all')}> + {t('badges.generate.all', { count: sorted.length })} + + setMode('pick')}> + {t('badges.generate.pick', { count: picked.size })} + +
+ + {mode === 'pick' ? ( +
+ {sorted.map((v) => ( + + ))} +
+ ) : null} + +
+ + {generate.isPending ? t('badges.generating') : t('badges.generate.action')} + + {generate.isError ? ( + + {errorMessage(generate.error)} + + ) : null} +
+
+
+
+ ); +} + +function errorMessage(error: unknown): string { + if (error instanceof ApiError) return t(error.code); + return t('error.generic'); +}