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
|
effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
|
||||||
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
|
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
|
||||||
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
|
`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`,
|
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
||||||
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
||||||
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
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
|
54 mm, `DEFAULT_BADGE_WIDTH_MM`/`HEIGHT_MM`). Le verso est ajusté (sans
|
||||||
déformation, `preserveAspectRatio=True`) à ce même format.
|
déformation, `preserveAspectRatio=True`) à ce même format.
|
||||||
- **`BadgeField`** : un champ positionné sur le recto (`field_type` ∈
|
- **`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`/
|
`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,
|
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge,
|
||||||
`font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer)
|
`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
|
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,
|
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 ;
|
`\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?}` —
|
`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
|
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
|
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 |
|
| 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 |
|
| 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 |
|
| 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
|
**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 +
|
||||||
|
|||||||
@@ -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
|
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,
|
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
|
catégorie, rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py`
|
||||||
`app/models/domain.py` (`BadgeTemplate`, `BadgeField`) pour le modèle.
|
pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
|
||||||
|
`BadgeField`) pour le modèle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
@@ -278,6 +279,8 @@ def _field_text(
|
|||||||
) -> str:
|
) -> str:
|
||||||
if field.field_type == BadgeFieldType.FULL_NAME:
|
if field.field_type == BadgeFieldType.FULL_NAME:
|
||||||
return f"{volunteer.first_name} {volunteer.last_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, [])
|
assignments = assignments_by_volunteer.get(volunteer.id, [])
|
||||||
if field.field_type == BadgeFieldType.POSITIONS:
|
if field.field_type == BadgeFieldType.POSITIONS:
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
@@ -355,6 +358,10 @@ def generate_badges(
|
|||||||
align=field.align.value,
|
align=field.align.value,
|
||||||
bold=field.bold,
|
bold=field.bold,
|
||||||
color=field.color,
|
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
|
for field in fields
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ class VolunteerUpdate(ApiModel):
|
|||||||
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
|
# Issus du formulaire public (`app/api/forms.py`), éditables ensuite
|
||||||
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
|
# depuis la fiche — voir CLAUDE.md § 2 (Volunteer).
|
||||||
companion_wish_note: str | None = None
|
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
|
self_declared_available: bool | None = None
|
||||||
willing_to_pay_fee: bool | None = None
|
willing_to_pay_fee: bool | None = None
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ class VolunteerRead(ApiModel):
|
|||||||
wished_companion_ids: list[uuid.UUID]
|
wished_companion_ids: list[uuid.UUID]
|
||||||
has_photo: bool
|
has_photo: bool
|
||||||
companion_wish_note: str | None
|
companion_wish_note: str | None
|
||||||
|
badge_text: str | None
|
||||||
self_declared_available: bool
|
self_declared_available: bool
|
||||||
willing_to_pay_fee: bool
|
willing_to_pay_fee: bool
|
||||||
# Paiement de la cotisation via SumUp (voir CLAUDE.md § 2, `Volunteer`) —
|
# 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
|
import io
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||||
from reportlab.lib.colors import HexColor
|
from reportlab.lib.colors import HexColor
|
||||||
from reportlab.lib.pagesizes import A4
|
from reportlab.lib.pagesizes import A4
|
||||||
from reportlab.lib.units import mm
|
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:
|
class BadgeFieldSpec:
|
||||||
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
|
"""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
|
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
|
text: str
|
||||||
x_mm: float
|
x_mm: float
|
||||||
@@ -61,6 +66,8 @@ class BadgeFieldSpec:
|
|||||||
align: str # "LEFT" | "CENTER" | "RIGHT"
|
align: str # "LEFT" | "CENTER" | "RIGHT"
|
||||||
bold: bool
|
bold: bool
|
||||||
color: str # "#rrggbb"
|
color: str # "#rrggbb"
|
||||||
|
is_photo: bool = False
|
||||||
|
image: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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(
|
def _draw_fields(
|
||||||
c: canvas.Canvas,
|
c: canvas.Canvas,
|
||||||
fields: tuple[BadgeFieldSpec, ...],
|
fields: tuple[BadgeFieldSpec, ...],
|
||||||
@@ -122,6 +170,9 @@ def _draw_fields(
|
|||||||
badge_height_mm: float,
|
badge_height_mm: float,
|
||||||
) -> None:
|
) -> None:
|
||||||
for f in fields:
|
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.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size)
|
||||||
c.setFillColor(HexColor(f.color))
|
c.setFillColor(HexColor(f.color))
|
||||||
left_x = x0_mm + f.x_mm
|
left_x = x0_mm + f.x_mm
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ _HEADER = [
|
|||||||
"Mécénat — code postal",
|
"Mécénat — code postal",
|
||||||
"Mécénat — pays",
|
"Mécénat — pays",
|
||||||
"Commentaire",
|
"Commentaire",
|
||||||
|
"Texte du badge",
|
||||||
"Inscrit le",
|
"Inscrit le",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -123,6 +124,7 @@ def generate_volunteers_xlsx(
|
|||||||
_safe_text(v.mecenat_postal_code or ""),
|
_safe_text(v.mecenat_postal_code or ""),
|
||||||
_safe_text(v.mecenat_country or ""),
|
_safe_text(v.mecenat_country or ""),
|
||||||
_safe_text(v.comment or ""),
|
_safe_text(v.comment or ""),
|
||||||
|
_safe_text(v.badge_text or ""),
|
||||||
_fmt_dt(v.created_at),
|
_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
|
# cette note sur la fiche et la transforme, si besoin, en un vrai
|
||||||
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
||||||
companion_wish_note: str | None = None
|
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
|
# Cases à cocher du formulaire public — purement informatives : aucune
|
||||||
# logique n'y est attachée (pas de workflow de paiement).
|
# logique n'y est attachée (pas de workflow de paiement).
|
||||||
self_declared_available: bool = Field(
|
self_declared_available: bool = Field(
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ class BadgeFieldType(enum.StrEnum):
|
|||||||
POSITIONS = "POSITIONS"
|
POSITIONS = "POSITIONS"
|
||||||
CATEGORY = "CATEGORY"
|
CATEGORY = "CATEGORY"
|
||||||
ROLE = "ROLE"
|
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):
|
class BadgeAlign(enum.StrEnum):
|
||||||
|
|||||||
@@ -207,3 +207,40 @@ def test_generating_produces_a_pdf(client, edition, session):
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.headers["content-type"] == "application/pdf"
|
assert resp.headers["content-type"] == "application/pdf"
|
||||||
assert resp.content.startswith(b"%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=set()) == "Bénévole"
|
||||||
assert _field_text(field, volunteer, {}, lead_volunteer_ids={volunteer.id}) == "Référent"
|
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)."""
|
"""Génération des planches de badges (module pur, sans base)."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
from app.badges.pdf import (
|
from app.badges.pdf import (
|
||||||
|
ASSUMED_DPI,
|
||||||
DEFAULT_BADGE_HEIGHT_MM,
|
DEFAULT_BADGE_HEIGHT_MM,
|
||||||
DEFAULT_BADGE_WIDTH_MM,
|
DEFAULT_BADGE_WIDTH_MM,
|
||||||
BadgeFieldSpec,
|
BadgeFieldSpec,
|
||||||
BadgeSpec,
|
BadgeSpec,
|
||||||
_cell_origin,
|
_cell_origin,
|
||||||
|
_fit_photo,
|
||||||
_grid_size,
|
_grid_size,
|
||||||
generate_badge_sheets_pdf,
|
generate_badge_sheets_pdf,
|
||||||
image_size_mm,
|
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
|
[_badge() for _ in range(per_page + 1)], front_image=None, back_image=None
|
||||||
)
|
)
|
||||||
assert len(two_pages) > len(one_page)
|
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,
|
wished_companion_ids,
|
||||||
has_photo: false,
|
has_photo: false,
|
||||||
companion_wish_note: null,
|
companion_wish_note: null,
|
||||||
|
badge_text: null,
|
||||||
self_declared_available: false,
|
self_declared_available: false,
|
||||||
willing_to_pay_fee: false,
|
willing_to_pay_fee: false,
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
|||||||
wished_companion_ids: [],
|
wished_companion_ids: [],
|
||||||
has_photo: pending.photo !== null,
|
has_photo: pending.photo !== null,
|
||||||
companion_wish_note: pending.companionWishNote,
|
companion_wish_note: pending.companionWishNote,
|
||||||
|
badge_text: null,
|
||||||
self_declared_available: pending.available,
|
self_declared_available: pending.available,
|
||||||
willing_to_pay_fee: true,
|
willing_to_pay_fee: true,
|
||||||
fee_payment_label: pending.paymentLabel,
|
fee_payment_label: pending.paymentLabel,
|
||||||
@@ -995,6 +996,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
|
wished_companion_ids: resolveCompanionWishes(body.wished_companion_ids ?? []),
|
||||||
has_photo: false,
|
has_photo: false,
|
||||||
companion_wish_note: null,
|
companion_wish_note: null,
|
||||||
|
badge_text: null,
|
||||||
self_declared_available: false,
|
self_declared_available: false,
|
||||||
willing_to_pay_fee: false,
|
willing_to_pay_fee: false,
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
@@ -1039,6 +1041,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
...('companion_wish_note' in body
|
...('companion_wish_note' in body
|
||||||
? { companion_wish_note: body.companion_wish_note ?? null }
|
? { 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' in body && body.self_declared_available != null
|
||||||
? { self_declared_available: body.self_declared_available }
|
? { self_declared_available: body.self_declared_available }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -1302,6 +1305,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
wished_companion_ids: [],
|
wished_companion_ids: [],
|
||||||
has_photo: false,
|
has_photo: false,
|
||||||
companion_wish_note: null,
|
companion_wish_note: null,
|
||||||
|
badge_text: null,
|
||||||
self_declared_available: false,
|
self_declared_available: false,
|
||||||
willing_to_pay_fee: false,
|
willing_to_pay_fee: false,
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
@@ -1772,6 +1776,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
wished_companion_ids: [],
|
wished_companion_ids: [],
|
||||||
has_photo: photo !== null,
|
has_photo: photo !== null,
|
||||||
companion_wish_note: companionWishNote,
|
companion_wish_note: companionWishNote,
|
||||||
|
badge_text: null,
|
||||||
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
||||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
||||||
fee_payment_label: null,
|
fee_payment_label: null,
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ export interface VolunteerRead {
|
|||||||
/** Renseignés par le formulaire public d'inscription (voir schema forms). */
|
/** Renseignés par le formulaire public d'inscription (voir schema forms). */
|
||||||
has_photo: boolean;
|
has_photo: boolean;
|
||||||
companion_wish_note: string | null;
|
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;
|
self_declared_available: boolean;
|
||||||
willing_to_pay_fee: boolean;
|
willing_to_pay_fee: boolean;
|
||||||
/** Paiement de la cotisation via SumUp (voir schema forms) —
|
/** Paiement de la cotisation via SumUp (voir schema forms) —
|
||||||
@@ -157,6 +160,7 @@ export interface VolunteerUpdate {
|
|||||||
wished_position_ids?: string[] | null;
|
wished_position_ids?: string[] | null;
|
||||||
wished_companion_ids?: string[] | null;
|
wished_companion_ids?: string[] | null;
|
||||||
companion_wish_note?: string | null;
|
companion_wish_note?: string | null;
|
||||||
|
badge_text?: string | null;
|
||||||
self_declared_available?: boolean | null;
|
self_declared_available?: boolean | null;
|
||||||
willing_to_pay_fee?: boolean | null;
|
willing_to_pay_fee?: boolean | null;
|
||||||
}
|
}
|
||||||
@@ -419,7 +423,15 @@ export interface MatrixResponse {
|
|||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
// Badges (planches PDF) //
|
// 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 type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
|
||||||
|
|
||||||
export interface BadgeFieldIn {
|
export interface BadgeFieldIn {
|
||||||
|
|||||||
@@ -230,6 +230,8 @@
|
|||||||
"config.vol.field.email": "Email (lien magique)",
|
"config.vol.field.email": "Email (lien magique)",
|
||||||
"config.vol.field.phone": "Téléphone",
|
"config.vol.field.phone": "Téléphone",
|
||||||
"config.vol.field.comment": "Commentaire",
|
"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.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.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",
|
"config.vol.access.grant": "Donner un accès de connexion",
|
||||||
@@ -340,7 +342,7 @@
|
|||||||
"badges.tab.customize": "PERSONNALISER",
|
"badges.tab.customize": "PERSONNALISER",
|
||||||
"badges.tab.generate": "GÉNÉRER",
|
"badges.tab.generate": "GÉNÉRER",
|
||||||
"badges.front": "Recto",
|
"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": "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.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",
|
"badges.fields": "Champs du recto",
|
||||||
@@ -352,6 +354,12 @@
|
|||||||
"badges.field.category.placeholder": "Bar",
|
"badges.field.category.placeholder": "Bar",
|
||||||
"badges.field.role": "Rôle (référent / bénévole)",
|
"badges.field.role": "Rôle (référent / bénévole)",
|
||||||
"badges.field.role.placeholder": "Référent",
|
"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.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.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)",
|
"badges.size.width": "Largeur du badge (mm)",
|
||||||
|
|||||||
@@ -45,13 +45,29 @@ function useEditorScale(badgeWidthMm: number): number {
|
|||||||
|
|
||||||
type FieldState = BadgeFieldIn & { enabled: boolean };
|
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> = {
|
const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
|
||||||
FULL_NAME: 'badges.field.fullName',
|
FULL_NAME: 'badges.field.fullName',
|
||||||
POSITIONS: 'badges.field.positions',
|
POSITIONS: 'badges.field.positions',
|
||||||
CATEGORY: 'badges.field.category',
|
CATEGORY: 'badges.field.category',
|
||||||
ROLE: 'badges.field.role',
|
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> = {
|
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
|
||||||
@@ -59,6 +75,8 @@ const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
|
|||||||
POSITIONS: 'badges.field.positions.placeholder',
|
POSITIONS: 'badges.field.positions.placeholder',
|
||||||
CATEGORY: 'badges.field.category.placeholder',
|
CATEGORY: 'badges.field.category.placeholder',
|
||||||
ROLE: 'badges.field.role.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
|
// 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,
|
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
|
||||||
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
|
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 {
|
function clamp(value: number, min: number, max: number): number {
|
||||||
@@ -445,6 +473,11 @@ function FieldBox({
|
|||||||
height: field.height_mm * scale,
|
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
|
<span
|
||||||
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
||||||
style={{
|
style={{
|
||||||
@@ -457,6 +490,7 @@ function FieldBox({
|
|||||||
>
|
>
|
||||||
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
onPointerDown={startResize}
|
onPointerDown={startResize}
|
||||||
onPointerMove={onResizeMove}
|
onPointerMove={onResizeMove}
|
||||||
@@ -482,6 +516,7 @@ function FieldEditorRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
|
<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]">
|
<label className="flex items-center gap-[8px]">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -493,8 +528,14 @@ function FieldEditorRow({
|
|||||||
{t(FIELD_LABEL_KEY[type])}
|
{t(FIELD_LABEL_KEY[type])}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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]">
|
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
|
||||||
<label className="flex items-center gap-[5px]">
|
<label className="flex items-center gap-[5px]">
|
||||||
<FieldLabel>{t('badges.field.size')}</FieldLabel>
|
<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"
|
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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} />
|
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||||
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
|
<VolunteerReferentSection volunteerId={selected.id} email={selected.email} />
|
||||||
|
|||||||
Reference in New Issue
Block a user