É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:
co-authored by
Claude Sonnet 5
parent
4bcdafca96
commit
b7fc80a7dc
@@ -134,6 +134,25 @@ les tables sauf les tables d'association.
|
|||||||
`volunteer_id` (nullable, unique).
|
`volunteer_id` (nullable, unique).
|
||||||
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
|
- **`MagicLinkToken`** : `account_id`, `token_hash` (unique), `expires_at`,
|
||||||
`used_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 :
|
Conventions techniques :
|
||||||
|
|
||||||
@@ -170,6 +189,7 @@ Matrice appliquée par les endpoints :
|
|||||||
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
|
| `artists` lecture / écriture | ✔ / ✔ | ✔ / — | ✔ / — |
|
||||||
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
|
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
|
||||||
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
|
||||||
|
| `badges` (config + génération) | ✔ | — | — |
|
||||||
|
|
||||||
**Authentification — deux systèmes selon le compte**, une même session JWT
|
**Authentification — deux systèmes selon le compte**, une même session JWT
|
||||||
HS256 (`Authorization: Bearer <jwt>`, TTL `session_ttl_hours`) à l'arrivée.
|
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
|
│ │ ├── models/ # tables SQLModel
|
||||||
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
│ │ ├── validation/ # moteur pur (models, engine) + loader (base)
|
||||||
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
|
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
|
||||||
|
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
|
||||||
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
|
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte
|
||||||
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
|
│ │ ├── seed.py # jeu de démonstration (python -m app.seed)
|
||||||
│ │ ├── i18n/
|
│ │ ├── 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 |
|
| 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 |
|
| 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 |
|
| 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
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -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"'},
|
||||||
|
)
|
||||||
@@ -7,6 +7,8 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
from app.models import (
|
from app.models import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
BadgeAlign,
|
||||||
|
BadgeFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -334,6 +336,45 @@ class MatrixResponse(ApiModel):
|
|||||||
positions: list[MatrixPosition]
|
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 #
|
# Authentification et comptes #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.badges.pdf import BadgeFieldSpec, BadgeSpec, generate_badge_sheets_pdf
|
||||||
|
|
||||||
|
__all__ = ["BadgeFieldSpec", "BadgeSpec", "generate_badge_sheets_pdf"]
|
||||||
@@ -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()
|
||||||
@@ -54,5 +54,22 @@
|
|||||||
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
"email.planning.no_shifts": "Aucun créneau ne t'est affecté pour le moment.",
|
||||||
"email.planning.shift_line": "- {position} : {start} → {end}",
|
"email.planning.shift_line": "- {position} : {start} → {end}",
|
||||||
"availability.date_outside_edition": "La date {date} est hors des dates de l'édition.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.assignments import router as assignment_router
|
||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.availability import router as availability_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.day_parts import router as day_part_router
|
||||||
from app.api.editions import router as edition_router
|
from app.api.editions import router as edition_router
|
||||||
from app.api.health import router as health_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(solver_router)
|
||||||
app.include_router(matrix_router)
|
app.include_router(matrix_router)
|
||||||
app.include_router(notification_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")
|
||||||
@@ -10,6 +10,8 @@ from app.models.domain import (
|
|||||||
Artist,
|
Artist,
|
||||||
Assignment,
|
Assignment,
|
||||||
Availability,
|
Availability,
|
||||||
|
BadgeField,
|
||||||
|
BadgeTemplate,
|
||||||
DayPart,
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
MagicLinkToken,
|
MagicLinkToken,
|
||||||
@@ -22,6 +24,8 @@ from app.models.domain import (
|
|||||||
)
|
)
|
||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
BadgeAlign,
|
||||||
|
BadgeFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -32,6 +36,10 @@ __all__ = [
|
|||||||
"Assignment",
|
"Assignment",
|
||||||
"AssignmentSource",
|
"AssignmentSource",
|
||||||
"Availability",
|
"Availability",
|
||||||
|
"BadgeAlign",
|
||||||
|
"BadgeField",
|
||||||
|
"BadgeFieldType",
|
||||||
|
"BadgeTemplate",
|
||||||
"DayPart",
|
"DayPart",
|
||||||
"Edition",
|
"Edition",
|
||||||
"MagicLinkToken",
|
"MagicLinkToken",
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ au niveau base (`ondelete="CASCADE"`) ; les relations ORM utilisent
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import uuid
|
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 sqlalchemy import Enum as SAEnum
|
||||||
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
from sqlmodel import Field, Relationship, SQLModel, UniqueConstraint
|
||||||
|
|
||||||
from app.models.base import TimestampMixin
|
from app.models.base import TimestampMixin
|
||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
|
BadgeAlign,
|
||||||
|
BadgeFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
Role,
|
Role,
|
||||||
)
|
)
|
||||||
@@ -364,3 +366,65 @@ class Assignment(TimestampMixin, table=True):
|
|||||||
|
|
||||||
volunteer: Volunteer = Relationship(back_populates="assignments")
|
volunteer: Volunteer = Relationship(back_populates="assignments")
|
||||||
shift: Shift = 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")
|
||||||
|
|||||||
@@ -28,3 +28,18 @@ class PositionCategory(enum.StrEnum):
|
|||||||
SETUP = "SETUP"
|
SETUP = "SETUP"
|
||||||
STAGE = "STAGE"
|
STAGE = "STAGE"
|
||||||
OTHER = "OTHER"
|
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"
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ dependencies = [
|
|||||||
"pyjwt>=2.9",
|
"pyjwt>=2.9",
|
||||||
"bcrypt>=4.1",
|
"bcrypt>=4.1",
|
||||||
"ortools>=9.11",
|
"ortools>=9.11",
|
||||||
|
"reportlab>=4.2",
|
||||||
|
"pillow>=11.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -27,6 +27,8 @@ EXPECTED_TABLES = {
|
|||||||
"shift",
|
"shift",
|
||||||
"availability",
|
"availability",
|
||||||
"assignment",
|
"assignment",
|
||||||
|
"badge_template",
|
||||||
|
"badge_field",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
@@ -15,6 +17,8 @@ import type {
|
|||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AvailabilitySet,
|
AvailabilitySet,
|
||||||
|
BadgeFieldsSet,
|
||||||
|
BadgeGenerateRequest,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
@@ -131,6 +135,47 @@ export function useMatrix() {
|
|||||||
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
|
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
|
* Dernières suggestions du solveur (lecture seule du cache posé par
|
||||||
* `useRunSolver`). Ne déclenche jamais de calcul elle-même.
|
* `useRunSolver`). Ne déclenche jamais de calcul elle-même.
|
||||||
@@ -459,3 +504,41 @@ export function useSendPlanningNotification() {
|
|||||||
api.notifications.sendPlanning(edition, body),
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
AssignmentValidateRequest,
|
AssignmentValidateRequest,
|
||||||
AvailabilitySet,
|
AvailabilitySet,
|
||||||
|
BadgeFieldsSet,
|
||||||
|
BadgeGenerateRequest,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
@@ -66,6 +68,35 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
return body as T;
|
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 query = (params: Record<string, string | undefined>) => {
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
@@ -191,6 +222,40 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
matrix: {
|
matrix: {
|
||||||
get: (editionId) => request(`${base(editionId)}/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: {
|
solver: {
|
||||||
run: (editionId) =>
|
run: (editionId) =>
|
||||||
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
|
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
ArtistRead,
|
ArtistRead,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AvailabilityRead,
|
AvailabilityRead,
|
||||||
|
BadgeFieldRead,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
EditionRead,
|
EditionRead,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
@@ -39,6 +40,13 @@ export interface MockState {
|
|||||||
accounts: AccountRead[];
|
accounts: AccountRead[];
|
||||||
/** accountId -> mot de passe en clair (mock uniquement). */
|
/** accountId -> mot de passe en clair (mock uniquement). */
|
||||||
passwords: Map<string, string>;
|
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;
|
seq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +420,13 @@ export function buildMockState(): MockState {
|
|||||||
accounts,
|
accounts,
|
||||||
passwords,
|
passwords,
|
||||||
availability,
|
availability,
|
||||||
|
badgeTemplate: {
|
||||||
|
frontImage: null,
|
||||||
|
frontImageType: null,
|
||||||
|
backImage: null,
|
||||||
|
backImageType: null,
|
||||||
|
fields: [],
|
||||||
|
},
|
||||||
seq,
|
seq,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import type {
|
|||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AvailabilityRead,
|
AvailabilityRead,
|
||||||
AvailabilitySet,
|
AvailabilitySet,
|
||||||
|
BadgeFieldRead,
|
||||||
|
BadgeFieldsSet,
|
||||||
|
BadgeGenerateRequest,
|
||||||
|
BadgeTemplateRead,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
@@ -82,6 +86,28 @@ const notFound = (): never => {
|
|||||||
throw new ApiError(404, 'error.not_found');
|
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 touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||||
|
|
||||||
const addDays = (day: string, n: number): string => {
|
const addDays = (day: string, n: number): string => {
|
||||||
@@ -1301,6 +1327,69 @@ export function createMockApi(): PlanningApi {
|
|||||||
get: () => delay(buildMatrix()),
|
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: {
|
solver: {
|
||||||
run: () => delay(runSolver()),
|
run: () => delay(runSolver()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -416,6 +416,44 @@ export interface MatrixResponse {
|
|||||||
positions: MatrixPosition[];
|
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 //
|
// Authentification //
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import type {
|
|||||||
AssignmentWriteResult,
|
AssignmentWriteResult,
|
||||||
AvailabilityRead,
|
AvailabilityRead,
|
||||||
AvailabilitySet,
|
AvailabilitySet,
|
||||||
|
BadgeFieldRead,
|
||||||
|
BadgeFieldsSet,
|
||||||
|
BadgeGenerateRequest,
|
||||||
|
BadgeTemplateRead,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
@@ -132,6 +136,19 @@ export interface PlanningApi {
|
|||||||
matrix: {
|
matrix: {
|
||||||
get(editionId: string): Promise<MatrixResponse>;
|
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: {
|
solver: {
|
||||||
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
|
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
|
||||||
run(editionId: string): Promise<SolverRunResult>;
|
run(editionId: string): Promise<SolverRunResult>;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { HomeRedirect, RequireAuth } from '@/auth/RequireAuth';
|
|||||||
import { AppShell } from './AppShell';
|
import { AppShell } from './AppShell';
|
||||||
import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
|
import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
|
||||||
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
|
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
|
||||||
|
import { BadgesPage } from '@/pages/BadgesPage';
|
||||||
import { ConfigPage } from '@/pages/ConfigPage';
|
import { ConfigPage } from '@/pages/ConfigPage';
|
||||||
import { GrillePage } from '@/pages/GrillePage';
|
import { GrillePage } from '@/pages/GrillePage';
|
||||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||||
@@ -26,6 +27,7 @@ export const routes: RouteObject[] = [
|
|||||||
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
||||||
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
||||||
{ path: 'configuration', element: <ConfigPage /> },
|
{ path: 'configuration', element: <ConfigPage /> },
|
||||||
|
{ path: 'badges', element: <BadgesPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const ROUTE_ROLES: Record<string, Role[]> = {
|
|||||||
'/grille': ['COORDINATOR'],
|
'/grille': ['COORDINATOR'],
|
||||||
'/benevoles': ['COORDINATOR'],
|
'/benevoles': ['COORDINATOR'],
|
||||||
'/configuration': ['COORDINATOR'],
|
'/configuration': ['COORDINATOR'],
|
||||||
|
'/badges': ['COORDINATOR'],
|
||||||
'/mobile-benevole': ['VOLUNTEER'],
|
'/mobile-benevole': ['VOLUNTEER'],
|
||||||
'/mobile-referent': ['LEAD'],
|
'/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-benevole', num: '04', labelKey: 'tab.mobileVolunteer' },
|
||||||
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
||||||
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
||||||
|
{ to: '/badges', num: '07', labelKey: 'tab.badges' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function canAccess(pathname: string, role: Role): boolean {
|
export function canAccess(pathname: string, role: Role): boolean {
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
"tab.mobileVolunteer": "Bénévole · mobile",
|
"tab.mobileVolunteer": "Bénévole · mobile",
|
||||||
"tab.mobileLead": "Référent · mobile",
|
"tab.mobileLead": "Référent · mobile",
|
||||||
"tab.config": "Configuration",
|
"tab.config": "Configuration",
|
||||||
|
"tab.badges": "Badges",
|
||||||
"position.category.bar": "Bar",
|
"position.category.bar": "Bar",
|
||||||
"position.category.ticketing": "Billetterie",
|
"position.category.ticketing": "Billetterie",
|
||||||
"position.category.welcome": "Accueil",
|
"position.category.welcome": "Accueil",
|
||||||
@@ -320,5 +321,41 @@
|
|||||||
"config.artists.endTime": "Heure de fin du passage",
|
"config.artists.endTime": "Heure de fin du passage",
|
||||||
"grille.panel.wishesPosition": "Vœu de poste",
|
"grille.panel.wishesPosition": "Vœu de poste",
|
||||||
"grille.panel.artistConflict": "Manquerait un artiste souhaité",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user