Badges : texte libre saisi dans l'éditeur, champs ramenés dans le badge
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6892e0307d
commit
cfed76c239
@@ -86,9 +86,10 @@ 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). `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
|
(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`,
|
- **`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
|
||||||
@@ -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,
|
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
|
`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
|
`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
|
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
|
à 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
|
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
|
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?}` —
|
`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
|
||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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
|
**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 +
|
||||||
|
|||||||
@@ -280,7 +280,9 @@ def _field_text(
|
|||||||
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:
|
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, [])
|
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 +355,8 @@ class BadgeFieldIn(ApiModel):
|
|||||||
align: BadgeAlign = BadgeAlign.LEFT
|
align: BadgeAlign = BadgeAlign.LEFT
|
||||||
bold: bool = False
|
bold: bool = False
|
||||||
color: str = "#000000"
|
color: str = "#000000"
|
||||||
|
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
|
||||||
|
text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class BadgeFieldsSet(ApiModel):
|
class BadgeFieldsSet(ApiModel):
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -183,8 +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`),
|
# Texte du badge propre à ce bénévole (champ `BadgeFieldType.FREE_TEXT`),
|
||||||
# saisi par la coordination sur la fiche — ex. « Accès backstage ».
|
# 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
|
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).
|
||||||
@@ -466,6 +467,10 @@ class BadgeField(TimestampMixin, table=True):
|
|||||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||||
)
|
)
|
||||||
color: str = Field(default="#000000")
|
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
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -39,8 +39,9 @@ class BadgeFieldType(enum.StrEnum):
|
|||||||
ROLE = "ROLE"
|
ROLE = "ROLE"
|
||||||
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
|
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
|
||||||
PHOTO = "PHOTO"
|
PHOTO = "PHOTO"
|
||||||
# Texte libre propre à chaque bénévole, saisi par la coordination sur sa
|
# Texte libre saisi par la coordination dans l'éditeur (`BadgeField.text`,
|
||||||
# fiche (`Volunteer.badge_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"
|
FREE_TEXT = "FREE_TEXT"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
"field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40,
|
||||||
"width_mm": 50, "height_mm": 8, "font_size": 9,
|
"width_mm": 50, "height_mm": 8, "font_size": 9,
|
||||||
|
"text": "Staff 2026",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
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 = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||||
with_photo.photo = _PNG
|
with_photo.photo = _PNG
|
||||||
|
|||||||
@@ -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, with_text, {}, set()) == "Accès backstage"
|
||||||
assert _field_text(field, without, {}, set()) == ""
|
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"
|
||||||
|
|||||||
@@ -444,6 +444,9 @@ export interface BadgeFieldIn {
|
|||||||
align: BadgeAlign;
|
align: BadgeAlign;
|
||||||
bold: boolean;
|
bold: boolean;
|
||||||
color: string;
|
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 {
|
export interface BadgeFieldsSet {
|
||||||
|
|||||||
@@ -231,7 +231,7 @@
|
|||||||
"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": "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.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.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",
|
"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.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": "Texte libre",
|
||||||
"badges.field.freeText.placeholder": "Accès backstage",
|
"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.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)",
|
||||||
|
|||||||
@@ -117,6 +117,24 @@ function clamp(value: number, min: number, max: number): number {
|
|||||||
return Math.min(Math.max(value, min), max);
|
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<F extends BadgeFieldIn>(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<BadgeFieldType, FieldState> {
|
function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> {
|
||||||
const byType = new Map(saved.map((f) => [f.field_type, f]));
|
const byType = new Map(saved.map((f) => [f.field_type, f]));
|
||||||
const out = {} as Record<BadgeFieldType, FieldState>;
|
const out = {} as Record<BadgeFieldType, FieldState>;
|
||||||
@@ -168,19 +186,28 @@ function BadgeCustomizeTab() {
|
|||||||
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
|
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
|
||||||
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
|
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
|
|
||||||
}, [template.data]);
|
|
||||||
|
|
||||||
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
||||||
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_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);
|
const scale = useEditorScale(badgeW);
|
||||||
|
|
||||||
if (template.isLoading || !fields) return <Loading />;
|
if (template.isLoading || !fields) return <Loading />;
|
||||||
if (template.error) return <ErrorNote error={template.error} />;
|
if (template.error) return <ErrorNote error={template.error} />;
|
||||||
|
|
||||||
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
|
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
|
||||||
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 = () => {
|
const save = () => {
|
||||||
@@ -197,6 +224,7 @@ function BadgeCustomizeTab() {
|
|||||||
align: f.align,
|
align: f.align,
|
||||||
bold: f.bold,
|
bold: f.bold,
|
||||||
color: f.color,
|
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 dxMm = (e.clientX - d.pointerXPx) / scale;
|
||||||
const dyMm = (e.clientY - d.pointerYPx) / scale;
|
const dyMm = (e.clientY - d.pointerYPx) / scale;
|
||||||
onChange({
|
onChange({
|
||||||
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
|
x_mm: clamp(d.startXMm + dxMm, 0, Math.max(0, badgeW - field.width_mm)),
|
||||||
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_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 d = resizeRef.current;
|
||||||
const dwMm = (e.clientX - d.pointerXPx) / scale;
|
const dwMm = (e.clientX - d.pointerXPx) / scale;
|
||||||
const dhMm = (e.clientY - d.pointerYPx) / scale;
|
const dhMm = (e.clientY - d.pointerYPx) / scale;
|
||||||
|
const maxW = badgeW - field.x_mm;
|
||||||
|
const maxH = badgeH - field.y_mm;
|
||||||
onChange({
|
onChange({
|
||||||
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
|
width_mm: clamp(d.startWMm + dwMm, Math.min(10, maxW), maxW),
|
||||||
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
|
height_mm: clamp(d.startHMm + dhMm, Math.min(5, maxH), maxH),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -471,6 +501,9 @@ function FieldBox({
|
|||||||
top: field.y_mm * scale,
|
top: field.y_mm * scale,
|
||||||
width: field.width_mm * scale,
|
width: field.width_mm * scale,
|
||||||
height: field.height_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' ? (
|
{field.field_type === 'PHOTO' ? (
|
||||||
@@ -488,7 +521,9 @@ function FieldBox({
|
|||||||
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
|
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])}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
@@ -533,6 +568,16 @@ function FieldEditorRow({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{field.enabled && type === 'FREE_TEXT' ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={field.text ?? ''}
|
||||||
|
onChange={(e) => 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
|
{/* La photo n'a pas de style de texte : seuls sa place et son cadre
|
||||||
(sur l'aperçu) comptent. */}
|
(sur l'aperçu) comptent. */}
|
||||||
{field.enabled && type !== 'PHOTO' ? (
|
{field.enabled && type !== 'PHOTO' ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user