From 54cba467fc3ff14490eb94bf12f67b92503498b3 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Tue, 27 Oct 2026 18:12:09 +0100 Subject: [PATCH] =?UTF-8?q?Badges=20:=20photo=20du=20b=C3=A9n=C3=A9vole=20?= =?UTF-8?q?et=20texte=20libre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Champ « Photo du bénévole » (PHOTO) : photo du formulaire recadrée au cadre, orientation EXIF appliquée, réduite à 300 dpi ; cadre vide sans photo. Champ « Texte libre » (FREE_TEXT) alimenté par le nouveau « Texte du badge » de la fiche bénévole (Volunteer.badge_text, migration 0024), repris dans l'export Excel. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 17 +++- backend/app/api/badges.py | 11 ++- backend/app/api/schemas.py | 3 + backend/app/badges/pdf.py | 53 ++++++++++- backend/app/exporting/xlsx.py | 2 + .../versions/0024_volunteer_badge_text.py | 30 ++++++ backend/app/models/domain.py | 3 + backend/app/models/enums.py | 5 + backend/tests/test_badges_api.py | 37 ++++++++ backend/tests/test_badges_fields.py | 12 +++ backend/tests/test_badges_pdf.py | 54 +++++++++++ frontend/src/api/mock/dataset.ts | 1 + frontend/src/api/mock/mock-client.ts | 5 + frontend/src/api/schema.ts | 14 ++- frontend/src/i18n/locales/fr.json | 10 +- frontend/src/pages/BadgesPage.tsx | 91 ++++++++++++++----- .../src/pages/config/ConfigVolunteers.tsx | 10 ++ 17 files changed, 325 insertions(+), 33 deletions(-) create mode 100644 backend/app/migrations/versions/0024_volunteer_badge_text.py diff --git a/CLAUDE.md b/CLAUDE.md index 30740e3..52c7e30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,9 @@ les tables sauf les tables d'association. effet sur les affectations) et `willing_to_pay_fee` (« je participe à la cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/ `fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le - paiement SumUp plus bas). + paiement SumUp plus bas). `badge_text` : texte libre imprimé sur le badge + (champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la + fiche (« Texte du badge ») — jamais par le formulaire public. - **`Artist`** : programmation de l'édition (`name`, `performance_date`, `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le lendemain). Unique (`edition_id`, `name`, `performance_date`). La @@ -157,7 +159,8 @@ les tables sauf les tables d'association. 54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans déformation, `preserveAspectRatio=True`) à ce même format. - **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈ - `FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE`, au plus un par édition — + `FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`, + 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, `font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer) @@ -169,7 +172,14 @@ les tables sauf les tables d'association. référent du poste (`Position.lead_account_id` → son bénévole) s'il y en a un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes, `\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ; - `ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent »). + `ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ; + `FREE_TEXT` est `Volunteer.badge_text` (vide ⇒ rien) ; `PHOTO` imprime + `Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée + au ratio du cadre sans déformation, orientation EXIF appliquée et réduite + à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de + plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou + photo illisible, le cadre reste vide plutôt que de faire échouer la + planche. `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 @@ -918,6 +928,7 @@ Aucune dépendance locale requise hors Docker. | 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à valider | | 44 | Un bénévole peut être affecté à deux postes en même temps : `SHIFT_OVERLAP` passe de `BLOCKING` à `WARNING` (affectation enregistrée, avertissement affiché) ; le solveur ne l'exclut plus mais lui applique un fort malus (`_ALREADY_BUSY_PENALTY`) — proposé seulement pour un créneau sous son minimum, faute de mieux — repris dans le classement par défaut du panneau et dans le mock | à valider | | 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider | +| 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider | **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/badges.py b/backend/app/api/badges.py index bf4571e..20bb484 100644 --- a/backend/app/api/badges.py +++ b/backend/app/api/badges.py @@ -3,8 +3,9 @@ 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. +catégorie, rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` +pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`, +`BadgeField`) pour le modèle. """ import io @@ -278,6 +279,8 @@ def _field_text( ) -> str: if field.field_type == BadgeFieldType.FULL_NAME: return f"{volunteer.first_name} {volunteer.last_name}" + if field.field_type == BadgeFieldType.FREE_TEXT: + return volunteer.badge_text or "" assignments = assignments_by_volunteer.get(volunteer.id, []) if field.field_type == BadgeFieldType.POSITIONS: return "\n".join( @@ -355,6 +358,10 @@ def generate_badges( align=field.align.value, bold=field.bold, color=field.color, + is_photo=field.field_type == BadgeFieldType.PHOTO, + image=volunteer.photo + if field.field_type == BadgeFieldType.PHOTO + else None, ) for field in fields ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index a026a28..823f5d1 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -74,6 +74,8 @@ class VolunteerUpdate(ApiModel): # Issus du formulaire public (`app/api/forms.py`), éditables ensuite # depuis la fiche — voir CLAUDE.md § 2 (Volunteer). companion_wish_note: str | None = None + # Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination. + badge_text: str | None = None self_declared_available: bool | None = None willing_to_pay_fee: bool | None = None @@ -91,6 +93,7 @@ class VolunteerRead(ApiModel): wished_companion_ids: list[uuid.UUID] has_photo: bool companion_wish_note: str | None + badge_text: str | None self_declared_available: bool willing_to_pay_fee: bool # Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) — diff --git a/backend/app/badges/pdf.py b/backend/app/badges/pdf.py index 351edb7..92f7008 100644 --- a/backend/app/badges/pdf.py +++ b/backend/app/badges/pdf.py @@ -18,6 +18,7 @@ recto-verso classique, page de verso n directement après la page de recto n. import io from dataclasses import dataclass +from PIL import Image, ImageOps, UnidentifiedImageError from reportlab.lib.colors import HexColor from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm @@ -50,7 +51,11 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu class BadgeFieldSpec: """Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n` possibles — ex. un poste par ligne avec son horaire), positionné depuis - le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end).""" + le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end). + + `is_photo` : le champ est la photo du bénévole (`image`, ses octets + bruts tels que stockés) plutôt qu'un texte — `text` et le style sont + alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné.""" text: str x_mm: float @@ -61,6 +66,8 @@ class BadgeFieldSpec: align: str # "LEFT" | "CENTER" | "RIGHT" bold: bool color: str # "#rrggbb" + is_photo: bool = False + image: bytes | None = None @dataclass(frozen=True) @@ -114,6 +121,47 @@ def _draw_background( ) +def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None: + """Recadre la photo au ratio du cadre (centrée, sans déformation — une + photo de téléphone n'a jamais le ratio du cadre) et la réduit à la + résolution d'impression : une photo de plusieurs Mo répétée sur 200 + badges alourdirait inutilement le PDF. Applique l'orientation EXIF (une + photo prise en portrait au téléphone est souvent stockée couchée). + `None` si l'image est illisible : le badge s'imprime sans photo plutôt + que de faire échouer toute la planche.""" + target = ( + max(1, round(width_mm / 25.4 * ASSUMED_DPI)), + max(1, round(height_mm / 25.4 * ASSUMED_DPI)), + ) + try: + with Image.open(io.BytesIO(image)) as img: + fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target) + except (UnidentifiedImageError, OSError): + return None + out = io.BytesIO() + fitted.save(out, format="JPEG", quality=88) + return out.getvalue() + + +def _draw_photo( + c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float +) -> None: + if not f.image: + return + photo = _fit_photo(f.image, f.width_mm, f.height_mm) + if photo is None: + return + c.drawImage( + ImageReader(io.BytesIO(photo)), + (x0_mm + f.x_mm) * mm, + # `f.y_mm` est le haut du cadre depuis le HAUT du badge ; reportlab + # place l'image par son coin bas-gauche, depuis le BAS de la page. + (y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm, + width=f.width_mm * mm, + height=f.height_mm * mm, + ) + + def _draw_fields( c: canvas.Canvas, fields: tuple[BadgeFieldSpec, ...], @@ -122,6 +170,9 @@ def _draw_fields( badge_height_mm: float, ) -> None: for f in fields: + if f.is_photo: + _draw_photo(c, f, x0_mm, y0_mm, badge_height_mm) + continue c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size) c.setFillColor(HexColor(f.color)) left_x = x0_mm + f.x_mm diff --git a/backend/app/exporting/xlsx.py b/backend/app/exporting/xlsx.py index 4d4bb4e..b74731e 100644 --- a/backend/app/exporting/xlsx.py +++ b/backend/app/exporting/xlsx.py @@ -36,6 +36,7 @@ _HEADER = [ "Mécénat — code postal", "Mécénat — pays", "Commentaire", + "Texte du badge", "Inscrit le", ] @@ -123,6 +124,7 @@ def generate_volunteers_xlsx( _safe_text(v.mecenat_postal_code or ""), _safe_text(v.mecenat_country or ""), _safe_text(v.comment or ""), + _safe_text(v.badge_text or ""), _fmt_dt(v.created_at), ] ) diff --git a/backend/app/migrations/versions/0024_volunteer_badge_text.py b/backend/app/migrations/versions/0024_volunteer_badge_text.py new file mode 100644 index 0000000..143ebb5 --- /dev/null +++ b/backend/app/migrations/versions/0024_volunteer_badge_text.py @@ -0,0 +1,30 @@ +"""Texte libre du badge + +Revision ID: 0024_volunteer_badge_text +Revises: 0023_fee_transaction +Create Date: 2026-10-27 + +`volunteer.badge_text` : texte propre à chaque bénévole, saisi par la +coordination sur sa fiche et imprimé sur le badge par le champ +`FREE_TEXT`. Les nouveaux types de champ de badge (`PHOTO`, `FREE_TEXT`) +tiennent dans la colonne `badge_field.field_type` existante (VARCHAR sans +contrainte CHECK, longueur 9 inchangée) : rien à migrer de ce côté. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0024_volunteer_badge_text" +down_revision: str | None = "0023_fee_transaction" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("volunteer", sa.Column("badge_text", sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("volunteer", "badge_text") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 18e665e..06911ea 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -183,6 +183,9 @@ class Volunteer(TimestampMixin, table=True): # cette note sur la fiche et la transforme, si besoin, en un vrai # `VolunteerCompanionWish` une fois les deux bénévoles inscrits. companion_wish_note: str | None = None + # Texte libre imprimé sur le badge (champ `BadgeFieldType.FREE_TEXT`), + # saisi par la coordination sur la fiche — ex. « Accès backstage ». + badge_text: str | None = None # Cases à cocher du formulaire public — purement informatives : aucune # logique n'y est attachée (pas de workflow de paiement). self_declared_available: bool = Field( diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py index dad22f1..c7b648a 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -37,6 +37,11 @@ class BadgeFieldType(enum.StrEnum): POSITIONS = "POSITIONS" CATEGORY = "CATEGORY" ROLE = "ROLE" + # Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ. + PHOTO = "PHOTO" + # Texte libre propre à chaque bénévole, saisi par la coordination sur sa + # fiche (`Volunteer.badge_text`). + FREE_TEXT = "FREE_TEXT" class BadgeAlign(enum.StrEnum): diff --git a/backend/tests/test_badges_api.py b/backend/tests/test_badges_api.py index dac5c48..2cf6f9e 100644 --- a/backend/tests/test_badges_api.py +++ b/backend/tests/test_badges_api.py @@ -207,3 +207,40 @@ def test_generating_produces_a_pdf(client, edition, session): assert resp.status_code == 200 assert resp.headers["content-type"] == "application/pdf" assert resp.content.startswith(b"%PDF") + + +def test_generating_with_photo_and_free_text_fields(client, edition, session): + resp = client.put( + f"{_badges_url(edition)}/template/fields", + json={ + "fields": [ + { + "field_type": "PHOTO", "x_mm": 60, "y_mm": 5, + "width_mm": 20, "height_mm": 25, "font_size": 8, + }, + { + "field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40, + "width_mm": 50, "height_mm": 8, "font_size": 9, + }, + ] + }, + ) + assert resp.status_code == 200 + assert {f["field_type"] for f in resp.json()} == {"PHOTO", "FREE_TEXT"} + + with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin") + with_photo.photo = _PNG + with_photo.photo_content_type = "image/png" + without_photo = make_volunteer(session, edition, first_name="Sam", last_name="Nuit") + session.add(with_photo) + session.commit() + resp = client.patch( + f"/editions/{edition.id}/volunteers/{without_photo.id}", + json={"badge_text": "Accès backstage"}, + ) + assert resp.status_code == 200 + assert resp.json()["badge_text"] == "Accès backstage" + + resp = client.post(f"{_badges_url(edition)}/generate", json={}) + assert resp.status_code == 200 + assert resp.content.startswith(b"%PDF") diff --git a/backend/tests/test_badges_fields.py b/backend/tests/test_badges_fields.py index 63d8e91..bcf7e5e 100644 --- a/backend/tests/test_badges_fields.py +++ b/backend/tests/test_badges_fields.py @@ -99,3 +99,15 @@ def test_field_text_role_is_referent_only_for_lead_accounts(): assert _field_text(field, volunteer, {}, lead_volunteer_ids=set()) == "Bénévole" assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent" + + +def test_field_text_free_text_is_the_volunteer_badge_text(): + field = BadgeField( + edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT, + x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000", + ) + with_text = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage") + without = _volunteer(first_name="Sam", last_name="Nuit") + + assert _field_text(field, with_text, {}, set()) == "Accès backstage" + assert _field_text(field, without, {}, set()) == "" diff --git a/backend/tests/test_badges_pdf.py b/backend/tests/test_badges_pdf.py index 5abf9d5..96dbaf8 100644 --- a/backend/tests/test_badges_pdf.py +++ b/backend/tests/test_badges_pdf.py @@ -1,11 +1,17 @@ """Génération des planches de badges (module pur, sans base).""" +import io + +from PIL import Image + from app.badges.pdf import ( + ASSUMED_DPI, DEFAULT_BADGE_HEIGHT_MM, DEFAULT_BADGE_WIDTH_MM, BadgeFieldSpec, BadgeSpec, _cell_origin, + _fit_photo, _grid_size, generate_badge_sheets_pdf, image_size_mm, @@ -97,3 +103,51 @@ def test_more_badges_than_one_sheet_produces_a_larger_pdf(): [_badge() for _ in range(per_page + 1)], front_image=None, back_image=None ) assert len(two_pages) > len(one_page) + + +def _jpeg(width_px: int, height_px: int) -> bytes: + out = io.BytesIO() + Image.new("RGB", (width_px, height_px), "red").save(out, format="JPEG") + return out.getvalue() + + +def _photo_badge(image: bytes | None) -> BadgeSpec: + return BadgeSpec( + fields=( + BadgeFieldSpec( + text="", x_mm=60, y_mm=5, width_mm=20, height_mm=25, + font_size=8, align="LEFT", bold=False, color="#000000", + is_photo=True, image=image, + ), + ) + ) + + +def test_fit_photo_crops_to_the_frame_ratio_at_print_resolution(): + # Photo paysage 4000×3000 dans un cadre portrait 20×25 mm : recadrée + # (pas déformée) et réduite à 300 dpi. + fitted = _fit_photo(_jpeg(4000, 3000), width_mm=20, height_mm=25) + + with Image.open(io.BytesIO(fitted)) as img: + assert img.size == (round(20 / 25.4 * ASSUMED_DPI), round(25 / 25.4 * ASSUMED_DPI)) + + +def test_fit_photo_returns_none_for_an_unreadable_image(): + assert _fit_photo(b"pas une image", width_mm=20, height_mm=25) is None + + +def test_photo_field_embeds_the_volunteer_photo(): + without = generate_badge_sheets_pdf([_photo_badge(None)], front_image=None, back_image=None) + with_photo = generate_badge_sheets_pdf( + [_photo_badge(_jpeg(400, 500))], front_image=None, back_image=None + ) + assert without.startswith(b"%PDF") + assert b"/Subtype /Image" in with_photo + assert b"/Subtype /Image" not in without + + +def test_unreadable_photo_does_not_crash_generation(): + pdf = generate_badge_sheets_pdf( + [_photo_badge(b"pas une image")], front_image=None, back_image=None + ) + assert pdf.startswith(b"%PDF") diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index d872ff1..9899ec7 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -289,6 +289,7 @@ export function buildMockState(): MockState { wished_companion_ids, has_photo: false, companion_wish_note: null, + badge_text: null, self_declared_available: false, willing_to_pay_fee: false, fee_payment_label: null, diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 7506306..81b75b4 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -234,6 +234,7 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead { wished_companion_ids: [], has_photo: pending.photo !== null, companion_wish_note: pending.companionWishNote, + badge_text: null, self_declared_available: pending.available, willing_to_pay_fee: true, fee_payment_label: pending.paymentLabel, @@ -995,6 +996,7 @@ export function createMockApi(): PlanningApi { wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []), has_photo: false, companion_wish_note: null, + badge_text: null, self_declared_available: false, willing_to_pay_fee: false, fee_payment_label: null, @@ -1039,6 +1041,7 @@ export function createMockApi(): PlanningApi { ...('companion_wish_note' in body ? { companion_wish_note: body.companion_wish_note ?? null } : {}), + ...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}), ...('self_declared_available' in body && body.self_declared_available != null ? { self_declared_available: body.self_declared_available } : {}), @@ -1302,6 +1305,7 @@ export function createMockApi(): PlanningApi { wished_companion_ids: [], has_photo: false, companion_wish_note: null, + badge_text: null, self_declared_available: false, willing_to_pay_fee: false, fee_payment_label: null, @@ -1772,6 +1776,7 @@ export function createMockApi(): PlanningApi { wished_companion_ids: [], has_photo: photo !== null, companion_wish_note: companionWishNote, + badge_text: null, self_declared_available: fields.has('AVAILABLE') ? available : false, willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'), fee_payment_label: null, diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 3e1b7bd..95cae7b 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -107,6 +107,9 @@ export interface VolunteerRead { /** Renseignés par le formulaire public d'inscription (voir schema forms). */ has_photo: boolean; companion_wish_note: string | null; + /** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la + * coordination. */ + badge_text: string | null; self_declared_available: boolean; willing_to_pay_fee: boolean; /** Paiement de la cotisation via SumUp (voir schema forms) — @@ -157,6 +160,7 @@ export interface VolunteerUpdate { wished_position_ids?: string[] | null; wished_companion_ids?: string[] | null; companion_wish_note?: string | null; + badge_text?: string | null; self_declared_available?: boolean | null; willing_to_pay_fee?: boolean | null; } @@ -419,7 +423,15 @@ export interface MatrixResponse { // --------------------------------------------------------------------------- // // Badges (planches PDF) // // --------------------------------------------------------------------------- // -export type BadgeFieldType = 'FULL_NAME' | 'POSITIONS' | 'CATEGORY' | 'ROLE'; +/** `PHOTO` : photo du bénévole ajustée au cadre ; `FREE_TEXT` : texte libre + * de sa fiche (`VolunteerRead.badge_text`), saisi par la coordination. */ +export type BadgeFieldType = + | 'FULL_NAME' + | 'POSITIONS' + | 'CATEGORY' + | 'ROLE' + | 'PHOTO' + | 'FREE_TEXT'; export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT'; export interface BadgeFieldIn { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 5f14a7f..32d2efe 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -230,6 +230,8 @@ "config.vol.field.email": "Email (lien magique)", "config.vol.field.phone": "Téléphone", "config.vol.field.comment": "Commentaire", + "config.vol.field.badgeText": "Texte du badge", + "config.vol.field.badgeText.help": "Imprimé sur le badge si le champ « Texte libre » y est placé (écran Badges).", "config.vol.access.title": "Accès de connexion", "config.vol.access.hint": "Un email sur la fiche ne suffit pas à lui seul : sans un accès donné ici, le bénévole ne reçoit jamais de lien de connexion.", "config.vol.access.grant": "Donner un accès de connexion", @@ -340,7 +342,7 @@ "badges.tab.customize": "PERSONNALISER", "badges.tab.generate": "GÉNÉRER", "badges.front": "Recto", - "badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.", + "badges.front.help": "Fond personnalisable + champs positionnables (nom, postes avec horaires, catégorie, rôle, photo, texte libre). Le format du badge est celui de cette image (300 dpi supposés). Propre à cette édition.", "badges.back": "Verso", "badges.back.help": "Une seule image, la même pour tous les badges de cette édition — ajustée au format défini par le recto.", "badges.fields": "Champs du recto", @@ -352,6 +354,12 @@ "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.photo": "Photo du bénévole", + "badges.field.photo.placeholder": "Photo", + "badges.field.photo.help": "La photo envoyée avec le formulaire d'inscription, recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.", + "badges.field.freeText": "Texte libre", + "badges.field.freeText.placeholder": "Accès backstage", + "badges.field.freeText.help": "Texte propre à chaque bénévole, à saisir sur sa fiche (Configuration › Bénévoles, « Texte du badge »). Vide = rien d'imprimé.", "badges.field.size": "Taille", "badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.", "badges.size.width": "Largeur du badge (mm)", diff --git a/frontend/src/pages/BadgesPage.tsx b/frontend/src/pages/BadgesPage.tsx index 9b9a013..df0a1db 100644 --- a/frontend/src/pages/BadgesPage.tsx +++ b/frontend/src/pages/BadgesPage.tsx @@ -45,13 +45,29 @@ function useEditorScale(badgeWidthMm: number): number { type FieldState = BadgeFieldIn & { enabled: boolean }; -const FIELD_TYPES: BadgeFieldType[] = ['FULL_NAME', 'POSITIONS', 'CATEGORY', 'ROLE']; +const FIELD_TYPES: BadgeFieldType[] = [ + 'FULL_NAME', + 'POSITIONS', + 'CATEGORY', + 'ROLE', + 'PHOTO', + 'FREE_TEXT', +]; const FIELD_LABEL_KEY: Record = { FULL_NAME: 'badges.field.fullName', POSITIONS: 'badges.field.positions', CATEGORY: 'badges.field.category', ROLE: 'badges.field.role', + PHOTO: 'badges.field.photo', + FREE_TEXT: 'badges.field.freeText', +}; + +// Indication affichée à côté du nom du champ, pour ceux dont l'origine du +// contenu n'est pas évidente. +const FIELD_HELP_KEY: Partial> = { + PHOTO: 'badges.field.photo.help', + FREE_TEXT: 'badges.field.freeText.help', }; const FIELD_PLACEHOLDER_KEY: Record = { @@ -59,6 +75,8 @@ const FIELD_PLACEHOLDER_KEY: Record = { POSITIONS: 'badges.field.positions.placeholder', CATEGORY: 'badges.field.category.placeholder', ROLE: 'badges.field.role.placeholder', + PHOTO: 'badges.field.photo.placeholder', + FREE_TEXT: 'badges.field.freeText.placeholder', }; // Position/taille par défaut à l'activation d'un champ — empilés @@ -83,6 +101,16 @@ const FIELD_DEFAULTS: Record> = x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6, font_size: 8, align: 'LEFT', bold: true, color: '#a1442b', }, + // Format portrait d'identité (≈ 3,5 × 4,5 cm réduit), en haut à droite. + // Taille de police/couleur sans effet : la photo est une image. + PHOTO: { + x_mm: 58, y_mm: 4, width_mm: 22, height_mm: 28, + font_size: 8, align: 'CENTER', bold: false, color: '#000000', + }, + FREE_TEXT: { + x_mm: 5, y_mm: 39, width_mm: 50, height_mm: 7, + font_size: 9, align: 'LEFT', bold: true, color: '#000000', + }, }; function clamp(value: number, min: number, max: number): number { @@ -445,18 +473,24 @@ function FieldBox({ height: field.height_mm * scale, }} > - px approx. à l'échelle du badge - fontWeight: field.bold ? 800 : 400, - color: field.color, - width: '100%', - textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left', - }} - > - {t(FIELD_PLACEHOLDER_KEY[field.field_type])} - + {field.field_type === 'PHOTO' ? ( + + {t(FIELD_PLACEHOLDER_KEY.PHOTO)} + + ) : ( + 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])} + + )}
- +
+ + {FIELD_HELP_KEY[type] ? ( + + ) : null} +
- {field.enabled ? ( + {/* La photo n'a pas de style de texte : seuls sa place et son cadre + (sur l'aperçu) comptent. */} + {field.enabled && type !== 'PHOTO' ? (
+