Badges : photo du bénévole et texte libre
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5b493c9737
commit
54cba467fc
@@ -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 +
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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`) —
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()) == ""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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<BadgeFieldType, string> = {
|
||||
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<Record<BadgeFieldType, string>> = {
|
||||
PHOTO: 'badges.field.photo.help',
|
||||
FREE_TEXT: 'badges.field.freeText.help',
|
||||
};
|
||||
|
||||
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
|
||||
@@ -59,6 +75,8 @@ const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
|
||||
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<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> =
|
||||
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,6 +473,11 @@ function FieldBox({
|
||||
height: field.height_mm * scale,
|
||||
}}
|
||||
>
|
||||
{field.field_type === 'PHOTO' ? (
|
||||
<span className="pointer-events-none flex h-full w-full items-center justify-center bg-sand-dark/70 font-mono text-[10px] font-bold uppercase text-ink/60">
|
||||
{t(FIELD_PLACEHOLDER_KEY.PHOTO)}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
||||
style={{
|
||||
@@ -457,6 +490,7 @@ function FieldBox({
|
||||
>
|
||||
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
onPointerDown={startResize}
|
||||
onPointerMove={onResizeMove}
|
||||
@@ -482,6 +516,7 @@ function FieldEditorRow({
|
||||
}) {
|
||||
return (
|
||||
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
|
||||
<div className="flex items-center">
|
||||
<label className="flex items-center gap-[8px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -493,8 +528,14 @@ function FieldEditorRow({
|
||||
{t(FIELD_LABEL_KEY[type])}
|
||||
</span>
|
||||
</label>
|
||||
{FIELD_HELP_KEY[type] ? (
|
||||
<HelpTip text={t(FIELD_HELP_KEY[type])} className="ml-[6px]" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{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' ? (
|
||||
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
|
||||
<label className="flex items-center gap-[5px]">
|
||||
<FieldLabel>{t('badges.field.size')}</FieldLabel>
|
||||
|
||||
@@ -884,6 +884,16 @@ export function ConfigVolunteers() {
|
||||
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>
|
||||
{t('config.vol.field.badgeText')}
|
||||
<HelpTip text={t('config.vol.field.badgeText.help')} className="ml-[6px]" />
|
||||
</FieldLabel>
|
||||
<TextInput
|
||||
value={selected.badge_text ?? ''}
|
||||
onChange={(v) => patch({ badge_text: v || null })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
|
||||
|
||||
Reference in New Issue
Block a user