From cfed76c239a4225e9f412c00f7412a5385aa5e5e Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Tue, 27 Oct 2026 18:30:34 +0100 Subject: [PATCH] =?UTF-8?q?Badges=20:=20texte=20libre=20saisi=20dans=20l'?= =?UTF-8?q?=C3=A9diteur,=20champs=20ramen=C3=A9s=20dans=20le=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Texte libre commun à tous les badges, saisi dans l'éditeur (BadgeField.text, migration 0025) ; le « Texte du badge » de la fiche le remplace pour un bénévole. Champs ramenés à l'intérieur du badge et champ sélectionné au premier plan : la photo restait bloquée hors d'un badge plus étroit que le CR80 (bornes de glisser-déposer négatives). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 17 +++-- backend/app/api/badges.py | 4 +- backend/app/api/schemas.py | 2 + .../versions/0025_badge_field_text.py | 28 ++++++++ backend/app/models/domain.py | 9 ++- backend/app/models/enums.py | 5 +- backend/tests/test_badges_api.py | 5 +- backend/tests/test_badges_fields.py | 13 ++++ frontend/src/api/schema.ts | 3 + frontend/src/i18n/locales/fr.json | 5 +- frontend/src/pages/BadgesPage.tsx | 65 ++++++++++++++++--- 11 files changed, 134 insertions(+), 22 deletions(-) create mode 100644 backend/app/migrations/versions/0025_badge_field_text.py diff --git a/CLAUDE.md b/CLAUDE.md index 5d32e23..d79e229 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,9 +86,10 @@ 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). `badge_text` : texte libre imprimé sur le badge + paiement SumUp plus bas). `badge_text` : texte du badge propre à ce bénévole (champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la - fiche (« Texte du badge ») — jamais par le formulaire public. + fiche (« Texte du badge ») — jamais par le formulaire public — et qui + remplace pour lui le texte commun de l'éditeur. - **`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 @@ -176,13 +177,20 @@ les tables sauf les tables d'association. 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 ») ; - `FREE_TEXT` est `Volunteer.badge_text` (vide ⇒ rien) ; `PHOTO` imprime + `FREE_TEXT` imprime `Volunteer.badge_text` s'il est renseigné, sinon le + texte commun saisi dans l'éditeur (`BadgeField.text`, ignoré pour les + autres types), sinon 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. + planche. L'éditeur ramène tout champ à l'intérieur du badge + (`BadgesPage.tsx:fitToBadge`) et met le champ sélectionné au premier + plan : les positions par défaut visent un CR80 paysage, et sur un badge + plus étroit un champ pouvait tomber hors du badge, avec des bornes de + glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo + impossible à déplacer ni redimensionner). `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 @@ -933,6 +941,7 @@ Aucune dépendance locale requise hors Docker. | 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 | | 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider | +| 48 | Badges : texte libre saisi dans l'éditeur (`BadgeField.text`, migration `0025`), commun à tous les badges, le « Texte du badge » de la fiche le remplaçant pour un bénévole ; champs ramenés dans le badge et champ sélectionné au premier plan (la photo restait bloquée hors d'un badge plus étroit que le CR80) | à 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 20bb484..dd66fcc 100644 --- a/backend/app/api/badges.py +++ b/backend/app/api/badges.py @@ -280,7 +280,9 @@ def _field_text( 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 "" + # Texte commun saisi dans l'éditeur, remplacé pour un bénévole par + # le « Texte du badge » de sa fiche s'il en a un. + return volunteer.badge_text or field.text or "" assignments = assignments_by_volunteer.get(volunteer.id, []) if field.field_type == BadgeFieldType.POSITIONS: return "\n".join( diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 823f5d1..440a571 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -355,6 +355,8 @@ class BadgeFieldIn(ApiModel): align: BadgeAlign = BadgeAlign.LEFT bold: bool = False color: str = "#000000" + # `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon. + text: str | None = None class BadgeFieldsSet(ApiModel): diff --git a/backend/app/migrations/versions/0025_badge_field_text.py b/backend/app/migrations/versions/0025_badge_field_text.py new file mode 100644 index 0000000..721a135 --- /dev/null +++ b/backend/app/migrations/versions/0025_badge_field_text.py @@ -0,0 +1,28 @@ +"""Texte commun du champ libre des badges + +Revision ID: 0025_badge_field_text +Revises: 0024_volunteer_badge_text +Create Date: 2026-10-27 + +`badge_field.text` : texte du champ `FREE_TEXT` saisi dans l'éditeur de +badges, imprimé sur tous les badges — sauf ceux dont la fiche porte un +`volunteer.badge_text`, qui le remplace pour ce bénévole. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0025_badge_field_text" +down_revision: str | None = "0024_volunteer_badge_text" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("badge_field", sa.Column("text", sa.String(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("badge_field", "text") diff --git a/backend/app/models/domain.py b/backend/app/models/domain.py index 06911ea..7c63f0d 100644 --- a/backend/app/models/domain.py +++ b/backend/app/models/domain.py @@ -183,8 +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 ». + # Texte du badge propre à ce bénévole (champ `BadgeFieldType.FREE_TEXT`), + # saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide : + # le texte commun de l'éditeur (`BadgeField.text`) s'applique. badge_text: str | None = None # Cases à cocher du formulaire public — purement informatives : aucune # logique n'y est attachée (pas de workflow de paiement). @@ -466,6 +467,10 @@ class BadgeField(TimestampMixin, table=True): sa_column=Column(Boolean, nullable=False, server_default="false"), ) color: str = Field(default="#000000") + # Champ `FREE_TEXT` seulement : texte saisi dans l'éditeur, imprimé sur + # tous les badges — sauf ceux dont la fiche porte un « Texte du badge » + # (`Volunteer.badge_text`), qui le remplace pour ce bénévole. + text: str | None = None # --------------------------------------------------------------------------- # diff --git a/backend/app/models/enums.py b/backend/app/models/enums.py index c7b648a..634aabc 100644 --- a/backend/app/models/enums.py +++ b/backend/app/models/enums.py @@ -39,8 +39,9 @@ class BadgeFieldType(enum.StrEnum): 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`). + # Texte libre saisi par la coordination dans l'éditeur (`BadgeField.text`, + # commun à tous les badges), remplacé pour un bénévole par le « Texte du + # badge » de sa fiche (`Volunteer.badge_text`) s'il en a un. FREE_TEXT = "FREE_TEXT" diff --git a/backend/tests/test_badges_api.py b/backend/tests/test_badges_api.py index 2cf6f9e..2d254ee 100644 --- a/backend/tests/test_badges_api.py +++ b/backend/tests/test_badges_api.py @@ -221,12 +221,15 @@ def test_generating_with_photo_and_free_text_fields(client, edition, session): { "field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40, "width_mm": 50, "height_mm": 8, "font_size": 9, + "text": "Staff 2026", }, ] }, ) assert resp.status_code == 200 - assert {f["field_type"] for f in resp.json()} == {"PHOTO", "FREE_TEXT"} + by_type = {f["field_type"]: f for f in resp.json()} + assert set(by_type) == {"PHOTO", "FREE_TEXT"} + assert by_type["FREE_TEXT"]["text"] == "Staff 2026" with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin") with_photo.photo = _PNG diff --git a/backend/tests/test_badges_fields.py b/backend/tests/test_badges_fields.py index bcf7e5e..7ad4331 100644 --- a/backend/tests/test_badges_fields.py +++ b/backend/tests/test_badges_fields.py @@ -111,3 +111,16 @@ def test_field_text_free_text_is_the_volunteer_badge_text(): assert _field_text(field, with_text, {}, set()) == "Accès backstage" assert _field_text(field, without, {}, set()) == "" + + +def test_field_text_free_text_falls_back_to_the_common_editor_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", + text="Staff 2026", + ) + own = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage") + other = _volunteer(first_name="Sam", last_name="Nuit") + + assert _field_text(field, own, {}, set()) == "Accès backstage" + assert _field_text(field, other, {}, set()) == "Staff 2026" diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 95cae7b..b63fb67 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -444,6 +444,9 @@ export interface BadgeFieldIn { align: BadgeAlign; bold: boolean; color: string; + /** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour + * un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */ + text?: string | null; } export interface BadgeFieldsSet { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index cb4f77b..4307ad3 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -231,7 +231,7 @@ "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.field.badgeText.help": "Remplace, pour ce bénévole seulement, le texte libre commun saisi dans l'écran Badges. Vide = texte commun.", "config.vol.photo.title": "Photo", "config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.", "config.vol.photo.none": "Aucune photo", @@ -366,7 +366,8 @@ "badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), 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.freeText.help": "Texte saisi ci-dessous, imprimé sur tous les badges. Pour un bénévole en particulier, le « Texte du badge » de sa fiche (Configuration › Bénévoles) le remplace.", + "badges.field.freeText.input": "Texte à imprimer (ex. STAFF 2026)", "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 df0a1db..60882b3 100644 --- a/frontend/src/pages/BadgesPage.tsx +++ b/frontend/src/pages/BadgesPage.tsx @@ -117,6 +117,24 @@ function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } +/** Ramène un champ à l'intérieur du badge (réduit s'il est plus grand, + * décalé s'il dépasse). Les positions par défaut sont pensées pour un CR80 + * paysage (85,6 × 54 mm) : sur un badge plus étroit (portrait…), un champ + * activé pouvait tomber hors du badge — invisible, et les bornes négatives + * du glisser-déposer le bloquaient (bug corrigé une fois : photo + * impossible à déplacer ni redimensionner). */ +function fitToBadge(field: F, badgeW: number, badgeH: number): F { + const width_mm = Math.min(field.width_mm, badgeW); + const height_mm = Math.min(field.height_mm, badgeH); + return { + ...field, + width_mm, + height_mm, + x_mm: clamp(field.x_mm, 0, badgeW - width_mm), + y_mm: clamp(field.y_mm, 0, badgeH - height_mm), + }; +} + function fieldsFromServer(saved: BadgeFieldIn[]): Record { const byType = new Map(saved.map((f) => [f.field_type, f])); const out = {} as Record; @@ -168,19 +186,28 @@ function BadgeCustomizeTab() { const [fields, setFieldsState] = useState | null>(null); const [selected, setSelected] = useState(null); - useEffect(() => { - if (template.data) setFieldsState(fieldsFromServer(template.data.fields)); - }, [template.data]); - const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM; const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM; + + useEffect(() => { + if (!template.data) return; + const loaded = fieldsFromServer(template.data.fields); + for (const type of FIELD_TYPES) loaded[type] = fitToBadge(loaded[type], badgeW, badgeH); + setFieldsState(loaded); + }, [template.data, badgeW, badgeH]); const scale = useEditorScale(badgeW); if (template.isLoading || !fields) return ; if (template.error) return ; const updateField = (type: BadgeFieldType, patch: Partial) => { - setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev)); + setFieldsState((prev) => + prev + ? { ...prev, [type]: fitToBadge({ ...prev[type], ...patch }, badgeW, badgeH) } + : prev, + ); + // Un champ qu'on vient d'activer passe au premier plan, sélectionné. + if (patch.enabled) setSelected(type); }; const save = () => { @@ -197,6 +224,7 @@ function BadgeCustomizeTab() { align: f.align, bold: f.bold, color: f.color, + text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null, }; }, ); @@ -434,8 +462,8 @@ function FieldBox({ const dxMm = (e.clientX - d.pointerXPx) / scale; const dyMm = (e.clientY - d.pointerYPx) / scale; onChange({ - x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm), - y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm), + x_mm: clamp(d.startXMm + dxMm, 0, Math.max(0, badgeW - field.width_mm)), + y_mm: clamp(d.startYMm + dyMm, 0, Math.max(0, badgeH - field.height_mm)), }); }; @@ -451,9 +479,11 @@ function FieldBox({ const d = resizeRef.current; const dwMm = (e.clientX - d.pointerXPx) / scale; const dhMm = (e.clientY - d.pointerYPx) / scale; + const maxW = badgeW - field.x_mm; + const maxH = badgeH - field.y_mm; onChange({ - width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm), - height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm), + width_mm: clamp(d.startWMm + dwMm, Math.min(10, maxW), maxW), + height_mm: clamp(d.startHMm + dhMm, Math.min(5, maxH), maxH), }); }; @@ -471,6 +501,9 @@ function FieldBox({ top: field.y_mm * scale, width: field.width_mm * scale, height: field.height_mm * scale, + // Le champ sélectionné passe devant les autres : sinon un champ + // recouvert (ex. la photo sous un texte) ne se saisit plus. + zIndex: selected ? 10 : undefined, }} > {field.field_type === 'PHOTO' ? ( @@ -488,7 +521,9 @@ function FieldBox({ textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left', }} > - {t(FIELD_PLACEHOLDER_KEY[field.field_type])} + {field.field_type === 'FREE_TEXT' && field.text?.trim() + ? field.text + : t(FIELD_PLACEHOLDER_KEY[field.field_type])} )}
+ {field.enabled && type === 'FREE_TEXT' ? ( + onChange({ text: e.target.value })} + placeholder={t('badges.field.freeText.input')} + className="ml-[24px] border-2 border-ink bg-cream px-[8px] py-[5px] font-display text-[12px] font-bold" + /> + ) : null} + {/* La photo n'a pas de style de texte : seuls sa place et son cadre (sur l'aperçu) comptent. */} {field.enabled && type !== 'PHOTO' ? (