Refonte du créateur de badges et nombre de badges par bénévole
Créateur de badges refait d'après la maquette : champs sur le recto et le verso (BadgeField.face, verso placé en miroir dans le PDF pour le recto-verso), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération. Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut, migration 0027) : champ sur la fiche, autant d'exemplaires sur les planches, total affiché à la génération, colonne 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
1a23881e81
commit
ced0855d47
@@ -89,7 +89,10 @@ les tables sauf les tables d'association.
|
|||||||
paiement SumUp plus bas). `badge_text` : texte du badge propre à ce bénévole
|
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 — et qui
|
fiche (« Texte du badge ») — jamais par le formulaire public — et qui
|
||||||
remplace pour lui le texte commun de l'éditeur.
|
remplace pour lui le texte commun de l'éditeur. `badge_count` : nombre
|
||||||
|
d'exemplaires de son badge imprimés sur les planches (1 par défaut, 1 à
|
||||||
|
100 — `CHECK >= 1` en base, borne haute à la saisie contre une faute de
|
||||||
|
frappe), « Nombre de badges » sur la fiche.
|
||||||
- **`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
|
||||||
@@ -162,15 +165,31 @@ les tables sauf les tables d'association.
|
|||||||
et la génération retombant alors sur le format CR80 par défaut (85,6 ×
|
et la génération retombant alors sur le format CR80 par défaut (85,6 ×
|
||||||
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 ou le verso**
|
||||||
`FULL_NAME` / `POSITIONS` / `CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`,
|
(`face` ∈ `FRONT` / `BACK` ; `field_type` ∈ `FULL_NAME` / `POSITIONS` /
|
||||||
au plus un par édition —
|
`CATEGORY` / `ROLE` / `PHOTO` / `FREE_TEXT`, au plus un par face et par
|
||||||
`PUT .../badges/template/fields` remplace tout), coordonnées `x_mm`/`y_mm`/
|
édition — unique (`edition_id`, `face`, `field_type`), 422
|
||||||
`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du badge,
|
`badge.duplicate_field_type` sinon ; `PUT .../badges/template/fields`
|
||||||
`font_size`, `align`, `bold`, `color`. Édité visuellement (glisser-déposer)
|
remplace tout, les deux faces dans la même liste), coordonnées `x_mm`/
|
||||||
depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`),
|
`y_mm`/`width_mm`/`height_mm` en millimètres depuis le coin haut-gauche du
|
||||||
qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel
|
badge, `font_size`, `align`, `bold`, `color`, `bg_color` (fond plein de la
|
||||||
du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`).
|
zone, `NULL` = transparent, `#rrggbb` sinon 422 `badge.invalid_color`),
|
||||||
|
`auto_fit` (taille réduite par pas de 0,5 pt, plancher 4 pt, jusqu'à ce que
|
||||||
|
tout tienne — `app/badges/pdf.py:fit_text` ; sans, les lignes en trop ne
|
||||||
|
sont pas imprimées), `photo_shape` (`RECT` / `OVAL`, champ `PHOTO`).
|
||||||
|
`FREE_TEXT` et `ROLE` sont centrés verticalement dans leur cadre (règle
|
||||||
|
dérivée du type), les autres en haut. `FULL_NAME` = prénom, puis nom à la
|
||||||
|
ligne. Édité visuellement depuis l'écran « Badges »
|
||||||
|
(`frontend/src/pages/badges/` — refonte d'après `design/Créateur de
|
||||||
|
badges.dc.html` : liste des champs recto/verso, aperçu des deux faces avec
|
||||||
|
les données d'un vrai bénévole, inspecteur, formats prédéfinis via
|
||||||
|
`PUT .../template/size`, annuler, flèches, zoom, repères de découpe
|
||||||
|
1,5 mm, contrôle de tous les badges avant génération — `issues.ts`).
|
||||||
|
L'aperçu reprend la mise en page du PDF à l'identique
|
||||||
|
(`badges/layout.ts`, avec les métriques Helvetica de ReportLab recopiées
|
||||||
|
dans `helveticaMetrics.ts`, pas une mesure du navigateur) : toute
|
||||||
|
règle de mise en page changée dans `app/badges/pdf.py` doit l'être aussi
|
||||||
|
dans `layout.ts`, sinon le contrôle avant génération ment.
|
||||||
`POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`,
|
`POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`,
|
||||||
dessiné par `app/badges/pdf.py:_draw_table`) des créneaux affectés,
|
dessiné par `app/badges/pdf.py:_draw_table`) des créneaux affectés,
|
||||||
**regroupés par jour de festival** (§ 3 — une soirée qui passe minuit
|
**regroupés par jour de festival** (§ 3 — une soirée qui passe minuit
|
||||||
@@ -188,23 +207,30 @@ les tables sauf les tables d'association.
|
|||||||
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, les **initiales** du bénévole sont imprimées à la
|
||||||
planche. Le texte d'un champ passe à la ligne pour tenir dans la largeur
|
place (fond `#E7E0CE`, même forme) plutôt que de faire échouer la
|
||||||
|
planche ; `OVAL` découpe la photo en ellipse. Le texte d'un champ passe à la ligne pour tenir dans la largeur
|
||||||
de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au
|
de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au
|
||||||
milieu d'un mot trop long seul ; les `\n` restent des retours à la
|
milieu d'un mot trop long seul ; les `\n` restent des retours à la
|
||||||
ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme
|
ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme
|
||||||
l'aperçu de l'éditeur, qui fait de même côté navigateur. Avant, chaque
|
l'aperçu de l'éditeur, qui fait de même côté navigateur. Avant, chaque
|
||||||
ligne s'imprimait d'un bloc et débordait du cadre. L'éditeur ramène tout champ à l'intérieur du badge
|
ligne s'imprimait d'un bloc et débordait du cadre. L'éditeur ramène tout champ à l'intérieur du badge
|
||||||
(`BadgesPage.tsx:fitToBadge`) et met le champ sélectionné au premier
|
(`badges/model.ts:fitToBadge`) et met le champ sélectionné au premier
|
||||||
plan : les positions par défaut visent un CR80 paysage, et sur un badge
|
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
|
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
|
glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo
|
||||||
impossible à déplacer ni redimensionner).
|
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 ; chaque bénévole y figure `badge_count` fois, à la suite)
|
||||||
|
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
|
||||||
par planche, de la page de verso correspondante (même image répétée à
|
par planche, de la page de verso correspondante (image de verso puis
|
||||||
chaque emplacement) — impression recto-verso classique.
|
champs `BACK` de chaque bénévole) — impression recto-verso classique
|
||||||
|
(retournement bord long) : sur la page de verso, chaque badge est placé
|
||||||
|
**en miroir horizontal** (colonne `cols-1-col`, `_cell_origin(mirrored=
|
||||||
|
True)`) pour tomber derrière son recto. Indispensable depuis que le verso
|
||||||
|
est personnalisé ; avant, un verso identique pour tous masquait le
|
||||||
|
décalage.
|
||||||
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
|
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
|
||||||
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
|
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
|
||||||
`Volunteer` existants. Complément des badges (un badge identifie un
|
`Volunteer` existants. Complément des badges (un badge identifie un
|
||||||
@@ -953,6 +979,8 @@ Aucune dépendance locale requise hors Docker.
|
|||||||
| 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 |
|
| 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 |
|
||||||
| 49 | Badges : le texte des champs (nom, postes et horaires…) passe à la ligne pour tenir dans la largeur de son cadre au lieu de déborder (`app/badges/pdf.py:wrap_text`) | à valider |
|
| 49 | Badges : le texte des champs (nom, postes et horaires…) passe à la ligne pour tenir dans la largeur de son cadre au lieu de déborder (`app/badges/pdf.py:wrap_text`) | à valider |
|
||||||
| 50 | Badges : les créneaux affectés s'affichent en tableau — le jour de festival en en-tête, puis une colonne horaire et une colonne poste (le référent n'est plus imprimé sur le badge), filets à la couleur du champ, cellules repliées dans leur colonne ; même tableau dans l'aperçu de l'éditeur | à valider |
|
| 50 | Badges : les créneaux affectés s'affichent en tableau — le jour de festival en en-tête, puis une colonne horaire et une colonne poste (le référent n'est plus imprimé sur le badge), filets à la couleur du champ, cellules repliées dans leur colonne ; même tableau dans l'aperçu de l'éditeur | à valider |
|
||||||
|
| 51 | Refonte du créateur de badges d'après la maquette (`design/`) : champs sur le recto **et le verso** (`BadgeField.face`, verso en miroir dans le PDF), fond de zone, réduction automatique du texte, photo ovale, initiales sans photo, nom sur deux lignes (migration `0026`) ; éditeur en 3 colonnes (champs, aperçu recto/verso avec de vrais bénévoles, inspecteur), formats prédéfinis, annuler/flèches/zoom/repères, dépôt d'image sur l'aperçu, contrôle de tous les badges avant génération | à valider |
|
||||||
|
| 52 | Nombre de badges par bénévole (`Volunteer.badge_count`, 1 par défaut, migration `0027`) : champ « Nombre de badges » sur la fiche, autant d'exemplaires sur les planches, total à imprimer affiché à la génération, colonne 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 +
|
||||||
|
|||||||
+49
-25
@@ -1,15 +1,16 @@
|
|||||||
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
|
"""Planches de badges bénévoles : configuration du recto/verso (COORDINATION)
|
||||||
et génération du PDF.
|
et génération du PDF.
|
||||||
|
|
||||||
Le verso est une image fixe, la même pour tous les bénévoles. Le recto
|
Chaque face (recto, verso) combine une image de fond (la même pour tous les
|
||||||
combine une image de fond et des champs positionnés (nom, poste(s) affectés,
|
bénévoles) et des champs positionnés (nom, poste(s) affectés, catégorie,
|
||||||
catégorie, rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py`
|
rôle, photo, texte libre de la fiche) — voir `app/badges/pdf.py` pour la
|
||||||
pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
|
composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
|
||||||
`BadgeField`) pour le modèle.
|
`BadgeField`) pour le modèle.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import io
|
import io
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
||||||
@@ -36,12 +37,14 @@ from app.badges.pdf import (
|
|||||||
BadgeTableSection,
|
BadgeTableSection,
|
||||||
generate_badge_sheets_pdf,
|
generate_badge_sheets_pdf,
|
||||||
image_size_mm,
|
image_size_mm,
|
||||||
|
initials,
|
||||||
)
|
)
|
||||||
from app.db import get_session
|
from app.db import get_session
|
||||||
from app.i18n import t
|
from app.i18n import t
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Account,
|
Account,
|
||||||
Assignment,
|
Assignment,
|
||||||
|
BadgeFace,
|
||||||
BadgeField,
|
BadgeField,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
BadgeTemplate,
|
BadgeTemplate,
|
||||||
@@ -58,6 +61,9 @@ router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
|
|||||||
|
|
||||||
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
_MAX_IMAGE_BYTES = 8 * 1024 * 1024
|
||||||
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
|
_ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg"}
|
||||||
|
_HEX_COLOR = re.compile(r"#[0-9a-fA-F]{6}")
|
||||||
|
# Champs centrés verticalement dans leur cadre (les autres partent du haut).
|
||||||
|
_MIDDLE_ALIGNED = {BadgeFieldType.FREE_TEXT, BadgeFieldType.ROLE}
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -80,7 +86,7 @@ def _fields(session: Session, edition_id: uuid.UUID) -> list[BadgeField]:
|
|||||||
session.exec(
|
session.exec(
|
||||||
select(BadgeField)
|
select(BadgeField)
|
||||||
.where(BadgeField.edition_id == edition_id)
|
.where(BadgeField.edition_id == edition_id)
|
||||||
.order_by(BadgeField.field_type)
|
.order_by(BadgeField.face, BadgeField.field_type)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -206,9 +212,11 @@ def set_fields(
|
|||||||
_guard: Account = Depends(require_coordinator),
|
_guard: Account = Depends(require_coordinator),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> list[BadgeField]:
|
) -> list[BadgeField]:
|
||||||
seen: set[BadgeFieldType] = set()
|
# Au plus un champ de chaque type par face ; le même sur les deux faces
|
||||||
|
# est permis.
|
||||||
|
seen: set[tuple[BadgeFace, BadgeFieldType]] = set()
|
||||||
for f in payload.fields:
|
for f in payload.fields:
|
||||||
if f.field_type in seen:
|
if (f.face, f.field_type) in seen:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
detail={
|
detail={
|
||||||
@@ -216,7 +224,12 @@ def set_fields(
|
|||||||
"params": {"field_type": f.field_type},
|
"params": {"field_type": f.field_type},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
seen.add(f.field_type)
|
seen.add((f.face, f.field_type))
|
||||||
|
if f.bg_color is not None and not _HEX_COLOR.fullmatch(f.bg_color):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={"code": "badge.invalid_color", "params": {"color": f.bg_color}},
|
||||||
|
)
|
||||||
|
|
||||||
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
|
session.execute(delete(BadgeField).where(BadgeField.edition_id == edition.id))
|
||||||
for f in payload.fields:
|
for f in payload.fields:
|
||||||
@@ -297,7 +310,11 @@ def _field_text(
|
|||||||
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
|
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
|
||||||
(`_positions_table`)."""
|
(`_positions_table`)."""
|
||||||
if field.field_type == BadgeFieldType.FULL_NAME:
|
if field.field_type == BadgeFieldType.FULL_NAME:
|
||||||
return f"{volunteer.first_name} {volunteer.last_name}"
|
# Prénom puis nom, chacun sur sa ligne.
|
||||||
|
return f"{volunteer.first_name}\n{volunteer.last_name}"
|
||||||
|
if field.field_type == BadgeFieldType.PHOTO:
|
||||||
|
# Imprimées à la place d'une photo absente ou illisible.
|
||||||
|
return initials(volunteer.first_name, volunteer.last_name)
|
||||||
if field.field_type == BadgeFieldType.FREE_TEXT:
|
if field.field_type == BadgeFieldType.FREE_TEXT:
|
||||||
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
|
# 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.
|
# le « Texte du badge » de sa fiche s'il en a un.
|
||||||
@@ -359,13 +376,9 @@ def generate_badges(
|
|||||||
if account.volunteer_id is not None
|
if account.volunteer_id is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
badges = [
|
def spec(field: BadgeField, volunteer: Volunteer) -> BadgeFieldSpec:
|
||||||
BadgeSpec(
|
return BadgeFieldSpec(
|
||||||
fields=tuple(
|
text=_field_text(field, volunteer, assignments_by_volunteer, lead_volunteer_ids),
|
||||||
BadgeFieldSpec(
|
|
||||||
text=_field_text(
|
|
||||||
field, volunteer, assignments_by_volunteer, lead_volunteer_ids
|
|
||||||
),
|
|
||||||
x_mm=field.x_mm,
|
x_mm=field.x_mm,
|
||||||
y_mm=field.y_mm,
|
y_mm=field.y_mm,
|
||||||
width_mm=field.width_mm,
|
width_mm=field.width_mm,
|
||||||
@@ -375,19 +388,30 @@ def generate_badges(
|
|||||||
bold=field.bold,
|
bold=field.bold,
|
||||||
color=field.color,
|
color=field.color,
|
||||||
is_photo=field.field_type == BadgeFieldType.PHOTO,
|
is_photo=field.field_type == BadgeFieldType.PHOTO,
|
||||||
image=volunteer.photo
|
image=volunteer.photo if field.field_type == BadgeFieldType.PHOTO else None,
|
||||||
if field.field_type == BadgeFieldType.PHOTO
|
table=_positions_table(assignments_by_volunteer.get(volunteer.id, []), boundary)
|
||||||
else None,
|
|
||||||
table=_positions_table(
|
|
||||||
assignments_by_volunteer.get(volunteer.id, []), boundary
|
|
||||||
)
|
|
||||||
if field.field_type == BadgeFieldType.POSITIONS
|
if field.field_type == BadgeFieldType.POSITIONS
|
||||||
else None,
|
else None,
|
||||||
|
bg_color=field.bg_color,
|
||||||
|
auto_fit=field.auto_fit,
|
||||||
|
valign="MIDDLE" if field.field_type in _MIDDLE_ALIGNED else "TOP",
|
||||||
|
photo_shape=field.photo_shape.value,
|
||||||
)
|
)
|
||||||
for field in fields
|
|
||||||
)
|
front_fields = [f for f in fields if f.face == BadgeFace.FRONT]
|
||||||
)
|
back_fields = [f for f in fields if f.face == BadgeFace.BACK]
|
||||||
|
# Un badge par exemplaire demandé sur la fiche (`Volunteer.badge_count`),
|
||||||
|
# à la suite les uns des autres.
|
||||||
|
badges = [
|
||||||
|
badge
|
||||||
for volunteer in volunteers
|
for volunteer in volunteers
|
||||||
|
for badge in [
|
||||||
|
BadgeSpec(
|
||||||
|
fields=tuple(spec(field, volunteer) for field in front_fields),
|
||||||
|
back_fields=tuple(spec(field, volunteer) for field in back_fields),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
* volunteer.badge_count
|
||||||
]
|
]
|
||||||
|
|
||||||
pdf_bytes = generate_badge_sheets_pdf(
|
pdf_bytes = generate_badge_sheets_pdf(
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
from app.models import (
|
from app.models import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
|
BadgeFace,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
|
BadgePhotoShape,
|
||||||
FeePaymentStatus,
|
FeePaymentStatus,
|
||||||
FormFieldType,
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
@@ -76,6 +78,9 @@ class VolunteerUpdate(ApiModel):
|
|||||||
companion_wish_note: str | None = None
|
companion_wish_note: str | None = None
|
||||||
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
|
# Texte libre du badge (champ `FREE_TEXT`), saisi par la coordination.
|
||||||
badge_text: str | None = None
|
badge_text: str | None = None
|
||||||
|
# Exemplaires du badge sur les planches (1 à 100 ; garde-fou contre une
|
||||||
|
# faute de frappe qui imprimerait des centaines de pages).
|
||||||
|
badge_count: int | None = Field(default=None, ge=1, le=100)
|
||||||
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
|
||||||
|
|
||||||
@@ -94,6 +99,7 @@ class VolunteerRead(ApiModel):
|
|||||||
has_photo: bool
|
has_photo: bool
|
||||||
companion_wish_note: str | None
|
companion_wish_note: str | None
|
||||||
badge_text: str | None
|
badge_text: str | None
|
||||||
|
badge_count: int
|
||||||
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`) —
|
||||||
@@ -346,6 +352,7 @@ class MatrixResponse(ApiModel):
|
|||||||
# Badges (planches PDF) #
|
# Badges (planches PDF) #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
class BadgeFieldIn(ApiModel):
|
class BadgeFieldIn(ApiModel):
|
||||||
|
face: BadgeFace = BadgeFace.FRONT
|
||||||
field_type: BadgeFieldType
|
field_type: BadgeFieldType
|
||||||
x_mm: float
|
x_mm: float
|
||||||
y_mm: float
|
y_mm: float
|
||||||
@@ -357,11 +364,17 @@ class BadgeFieldIn(ApiModel):
|
|||||||
color: str = "#000000"
|
color: str = "#000000"
|
||||||
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
|
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
|
||||||
text: str | None = None
|
text: str | None = None
|
||||||
|
# Fond de la zone (`#rrggbb`), `None` = transparent — voir `BadgeField`.
|
||||||
|
bg_color: str | None = None
|
||||||
|
auto_fit: bool = False
|
||||||
|
# `PHOTO` seulement, ignoré sinon.
|
||||||
|
photo_shape: BadgePhotoShape = BadgePhotoShape.RECT
|
||||||
|
|
||||||
|
|
||||||
class BadgeFieldsSet(ApiModel):
|
class BadgeFieldsSet(ApiModel):
|
||||||
"""Remplace l'intégralité des champs du recto — au plus un par
|
"""Remplace l'intégralité des champs du recto et du verso — au plus un
|
||||||
`field_type` (décocher un champ dans l'éditeur retire sa ligne)."""
|
par (`face`, `field_type`) (décocher un champ dans l'éditeur retire sa
|
||||||
|
ligne)."""
|
||||||
|
|
||||||
fields: list[BadgeFieldIn] = Field(default_factory=list)
|
fields: list[BadgeFieldIn] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
+233
-77
@@ -8,11 +8,12 @@ Format du badge : dérivé de l'image de fond du recto (pas de format fixe
|
|||||||
imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée,
|
imposé) — voir `image_size_mm`. Tant qu'aucune image n'est importée,
|
||||||
`DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la
|
`DEFAULT_BADGE_WIDTH_MM`/`DEFAULT_BADGE_HEIGHT_MM` (CR80, 85,6 × 54 mm — la
|
||||||
taille standard des badges/cartes d'événement) servent de repère à
|
taille standard des badges/cartes d'événement) servent de repère à
|
||||||
l'éditeur. Planche : grille sur page A4 portrait. Le verso est identique
|
l'éditeur. Planche : grille sur page A4 portrait. Chaque page de recto est
|
||||||
pour tous les bénévoles : la planche imprime toutes les pages de recto
|
suivie de sa page de verso (même image de fond à chaque emplacement rempli,
|
||||||
(personnalisées) puis, pour chacune, la page de verso correspondante (même
|
plus les champs du verso de chaque bénévole) — impression recto-verso
|
||||||
disposition, même image répétée à chaque emplacement rempli) — impression
|
classique, retournement sur le bord long : sur la page de verso, chaque
|
||||||
recto-verso classique, page de verso n directement après la page de recto n.
|
badge est placé en miroir horizontal (colonne `cols - 1 - col`, même rangée)
|
||||||
|
pour tomber exactement derrière son recto — voir `_cell_origin`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
@@ -36,6 +37,13 @@ _LINE_HEIGHT_FACTOR = 1.25
|
|||||||
_CELL_PAD_X_MM = 0.8
|
_CELL_PAD_X_MM = 0.8
|
||||||
_CELL_PAD_Y_MM = 0.5
|
_CELL_PAD_Y_MM = 0.5
|
||||||
_TABLE_LINE_WIDTH_PT = 0.4
|
_TABLE_LINE_WIDTH_PT = 0.4
|
||||||
|
# Réduction automatique de la taille du texte (`auto_fit`) : pas et plancher.
|
||||||
|
_AUTO_FIT_STEP_PT = 0.5
|
||||||
|
_AUTO_FIT_MIN_PT = 4.0
|
||||||
|
# Photo absente ou illisible : initiales sur ce fond, à cette taille (part de
|
||||||
|
# la largeur du cadre).
|
||||||
|
_INITIALS_BG = "#E7E0CE"
|
||||||
|
_INITIALS_SIZE_RATIO = 0.28
|
||||||
|
|
||||||
# Résolution supposée d'une image de fond importée, pour convertir ses pixels
|
# Résolution supposée d'une image de fond importée, pour convertir ses pixels
|
||||||
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
|
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
|
||||||
@@ -73,14 +81,21 @@ class BadgeTable:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
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. prénom puis nom), positionné depuis le coin haut-gauche
|
||||||
le coin haut-gauche du badge (comme dans l'éditeur visuel du front-end).
|
du badge (comme dans l'éditeur visuel du front-end).
|
||||||
|
|
||||||
`is_photo` : le champ est la photo du bénévole (`image`, ses octets
|
`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
|
bruts tels que stockés) plutôt qu'un texte, découpée selon
|
||||||
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné.
|
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), les
|
||||||
|
initiales du bénévole (`text`) sont imprimées à la place, sur un fond
|
||||||
|
neutre de la même forme.
|
||||||
|
|
||||||
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`."""
|
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.
|
||||||
|
|
||||||
|
`bg_color` : fond plein de la zone du champ (`None` = transparent ;
|
||||||
|
ignoré pour la photo). `auto_fit` : réduit la taille du texte jusqu'à ce
|
||||||
|
que tout tienne dans le cadre (sinon, les lignes en trop ne sont pas
|
||||||
|
imprimées). `valign` : `TOP` ou `MIDDLE` (centré verticalement)."""
|
||||||
|
|
||||||
text: str
|
text: str
|
||||||
x_mm: float
|
x_mm: float
|
||||||
@@ -94,13 +109,19 @@ class BadgeFieldSpec:
|
|||||||
is_photo: bool = False
|
is_photo: bool = False
|
||||||
image: bytes | None = None
|
image: bytes | None = None
|
||||||
table: BadgeTable | None = None
|
table: BadgeTable | None = None
|
||||||
|
bg_color: str | None = None
|
||||||
|
auto_fit: bool = False
|
||||||
|
valign: str = "TOP" # "TOP" | "MIDDLE"
|
||||||
|
photo_shape: str = "RECT" # "RECT" | "OVAL"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BadgeSpec:
|
class BadgeSpec:
|
||||||
"""Un badge = un bénévole, avec ses champs déjà résolus."""
|
"""Un badge = un bénévole, avec ses champs déjà résolus : `fields` pour
|
||||||
|
le recto, `back_fields` pour le verso."""
|
||||||
|
|
||||||
fields: tuple[BadgeFieldSpec, ...]
|
fields: tuple[BadgeFieldSpec, ...]
|
||||||
|
back_fields: tuple[BadgeFieldSpec, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
|
def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]:
|
||||||
@@ -112,11 +133,22 @@ def _grid_size(badge_width_mm: float, badge_height_mm: float) -> tuple[int, int]
|
|||||||
|
|
||||||
|
|
||||||
def _cell_origin(
|
def _cell_origin(
|
||||||
index: int, cols: int, badge_width_mm: float, badge_height_mm: float
|
index: int,
|
||||||
|
cols: int,
|
||||||
|
badge_width_mm: float,
|
||||||
|
badge_height_mm: float,
|
||||||
|
mirrored: bool = False,
|
||||||
) -> tuple[float, float]:
|
) -> tuple[float, float]:
|
||||||
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
|
"""Coin bas-gauche (repère PDF) de l'emplacement `index` sur la page,
|
||||||
en mm depuis le coin bas-gauche de la page."""
|
en mm depuis le coin bas-gauche de la page.
|
||||||
|
|
||||||
|
`mirrored` : page de verso. Une feuille retournée sur son bord long
|
||||||
|
inverse gauche et droite : le verso du badge de la colonne `col` doit
|
||||||
|
être imprimé en colonne `cols - 1 - col` (même rangée) pour tomber
|
||||||
|
derrière son recto — y compris sur une dernière planche incomplète."""
|
||||||
col, row = index % cols, index // cols
|
col, row = index % cols, index // cols
|
||||||
|
if mirrored:
|
||||||
|
col = cols - 1 - col
|
||||||
x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM)
|
x0 = _MARGIN_MM + col * (badge_width_mm + _GUTTER_MM)
|
||||||
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM
|
y0 = _PAGE_HEIGHT_MM - _MARGIN_MM - (row + 1) * badge_height_mm - row * _GUTTER_MM
|
||||||
return x0, y0
|
return x0, y0
|
||||||
@@ -169,23 +201,73 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
|
|||||||
return out.getvalue()
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_rect(
|
||||||
|
f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||||
|
) -> tuple[float, float, float, float]:
|
||||||
|
"""Cadre du champ en points, repère PDF : (gauche, bas, largeur,
|
||||||
|
hauteur). `f.y_mm` est le haut du cadre depuis le HAUT du badge ;
|
||||||
|
reportlab dessine depuis le BAS de la page."""
|
||||||
|
return (
|
||||||
|
(x0_mm + f.x_mm) * mm,
|
||||||
|
(y0_mm + badge_height_mm - f.y_mm - f.height_mm) * mm,
|
||||||
|
f.width_mm * mm,
|
||||||
|
f.height_mm * mm,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: float):
|
||||||
|
path = c.beginPath()
|
||||||
|
if shape == "OVAL":
|
||||||
|
path.ellipse(x, y, w, h)
|
||||||
|
else:
|
||||||
|
path.rect(x, y, w, h)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def initials(first_name: str, last_name: str) -> str:
|
||||||
|
"""Initiales imprimées à la place d'une photo absente : première lettre
|
||||||
|
du prénom et du nom, en majuscules."""
|
||||||
|
return "".join(part.strip()[:1] for part in (first_name, last_name)).upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_initials(c: canvas.Canvas, f: BadgeFieldSpec, x: float, y: float, w: float, h: float):
|
||||||
|
c.setFillColor(HexColor(_INITIALS_BG))
|
||||||
|
c.drawPath(_shape_path(c, f.photo_shape, x, y, w, h), stroke=0, fill=1)
|
||||||
|
if not f.text:
|
||||||
|
return
|
||||||
|
size = max(_AUTO_FIT_MIN_PT, w * _INITIALS_SIZE_RATIO)
|
||||||
|
c.setFont("Helvetica-Bold", size)
|
||||||
|
c.setFillColor(HexColor(f.color))
|
||||||
|
# Ligne de base décalée d'environ un tiers de la taille : centre
|
||||||
|
# visuel des majuscules (hauteur de capitale ≈ 0,7 × la taille).
|
||||||
|
c.drawCentredString(x + w / 2, y + h / 2 - size * 0.35, f.text)
|
||||||
|
|
||||||
|
|
||||||
def _draw_photo(
|
def _draw_photo(
|
||||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||||
) -> None:
|
) -> None:
|
||||||
if not f.image:
|
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
|
||||||
return
|
photo = _fit_photo(f.image, f.width_mm, f.height_mm) if f.image else None
|
||||||
photo = _fit_photo(f.image, f.width_mm, f.height_mm)
|
c.saveState()
|
||||||
if photo is None:
|
if photo is None:
|
||||||
|
_draw_initials(c, f, x, y, w, h)
|
||||||
|
else:
|
||||||
|
if f.photo_shape == "OVAL":
|
||||||
|
# Découpe en ellipse inscrite dans le cadre.
|
||||||
|
c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0)
|
||||||
|
c.drawImage(ImageReader(io.BytesIO(photo)), x, y, width=w, height=h)
|
||||||
|
c.restoreState()
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_zone_background(
|
||||||
|
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||||
|
) -> None:
|
||||||
|
if not f.bg_color:
|
||||||
return
|
return
|
||||||
c.drawImage(
|
c.saveState()
|
||||||
ImageReader(io.BytesIO(photo)),
|
c.setFillColor(HexColor(f.bg_color))
|
||||||
(x0_mm + f.x_mm) * mm,
|
c.rect(*_frame_rect(f, x0_mm, y0_mm, badge_height_mm), stroke=0, fill=1)
|
||||||
# `f.y_mm` est le haut du cadre depuis le HAUT du badge ; reportlab
|
c.restoreState()
|
||||||
# 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 wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
|
def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
|
||||||
@@ -242,6 +324,45 @@ def _column_widths(natural: list[float], available: float) -> list[float]:
|
|||||||
return widths
|
return widths
|
||||||
|
|
||||||
|
|
||||||
|
_TableLine = tuple[list[list[str]], str, bool, float]
|
||||||
|
|
||||||
|
|
||||||
|
def _table_layout(
|
||||||
|
table: BadgeTable, width_pt: float, font_size: float
|
||||||
|
) -> tuple[list[float], list[_TableLine]]:
|
||||||
|
"""Largeurs des colonnes et lignes du tableau à `font_size` : pour
|
||||||
|
chaque ligne, (texte replié de chaque cellule, police, en-tête de
|
||||||
|
section ?, hauteur en points)."""
|
||||||
|
regular, bold = "Helvetica", "Helvetica-Bold"
|
||||||
|
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||||||
|
line_height = font_size * _LINE_HEIGHT_FACTOR
|
||||||
|
body = [row for section in table.sections for row in section.rows]
|
||||||
|
columns = max(len(row) for row in body) if body else 1
|
||||||
|
natural = [
|
||||||
|
max(stringWidth(row[col], regular, font_size) for row in body) + 2 * pad_x
|
||||||
|
for col in range(columns)
|
||||||
|
] if body else [0.0]
|
||||||
|
widths = _column_widths(natural, width_pt)
|
||||||
|
total_width = sum(widths)
|
||||||
|
|
||||||
|
# Un en-tête n'a qu'une cellule, sur toute la largeur.
|
||||||
|
cells_to_draw = []
|
||||||
|
for section in table.sections:
|
||||||
|
cells_to_draw.append(((section.title,), bold, True))
|
||||||
|
cells_to_draw.extend((row, regular, False) for row in section.rows)
|
||||||
|
|
||||||
|
lines: list[_TableLine] = []
|
||||||
|
for cells, font, is_title in cells_to_draw:
|
||||||
|
cell_widths = [total_width] if is_title else widths
|
||||||
|
wrapped = [
|
||||||
|
wrap_text(text, font, font_size, max(1.0, cell_widths[col] - 2 * pad_x))
|
||||||
|
for col, text in enumerate(cells)
|
||||||
|
]
|
||||||
|
row_height = max(len(w) for w in wrapped) * line_height + 2 * pad_y
|
||||||
|
lines.append((wrapped, font, is_title, row_height))
|
||||||
|
return widths, lines
|
||||||
|
|
||||||
|
|
||||||
def _draw_table(
|
def _draw_table(
|
||||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -250,52 +371,43 @@ def _draw_table(
|
|||||||
filets à la couleur du champ, texte de chaque cellule replié dans sa
|
filets à la couleur du champ, texte de chaque cellule replié dans sa
|
||||||
colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du
|
colonne (`wrap_text`). Une ligne qui ne tient plus dans la hauteur du
|
||||||
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
|
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
|
||||||
texte."""
|
texte — sauf avec `auto_fit`, qui réduit la taille du texte jusqu'à ce
|
||||||
|
que toutes les lignes tiennent (plancher `_AUTO_FIT_MIN_PT`)."""
|
||||||
table = f.table
|
table = f.table
|
||||||
if table is None or not table.sections:
|
if table is None or not table.sections:
|
||||||
return
|
return
|
||||||
regular, bold = "Helvetica", "Helvetica-Bold"
|
|
||||||
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||||||
line_height = f.font_size * _LINE_HEIGHT_FACTOR
|
width_pt, height_pt = f.width_mm * mm, f.height_mm * mm
|
||||||
body = [row for section in table.sections for row in section.rows]
|
font_size = f.font_size
|
||||||
columns = max(len(row) for row in body) if body else 1
|
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
|
||||||
natural = [
|
if f.auto_fit:
|
||||||
max(stringWidth(row[col], regular, f.font_size) for row in body) + 2 * pad_x
|
while (
|
||||||
for col in range(columns)
|
sum(line[3] for line in lines_to_draw) > height_pt + 0.01
|
||||||
] if body else [0.0]
|
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
|
||||||
widths = _column_widths(natural, f.width_mm * mm)
|
):
|
||||||
|
font_size -= _AUTO_FIT_STEP_PT
|
||||||
|
widths, lines_to_draw = _table_layout(table, width_pt, font_size)
|
||||||
|
line_height = font_size * _LINE_HEIGHT_FACTOR
|
||||||
total_width = sum(widths)
|
total_width = sum(widths)
|
||||||
|
|
||||||
# (cellules, police, en-tête de section ?) — un en-tête n'a qu'une
|
|
||||||
# cellule, sur toute la largeur.
|
|
||||||
lines_to_draw = []
|
|
||||||
for section in table.sections:
|
|
||||||
lines_to_draw.append(((section.title,), bold, True))
|
|
||||||
lines_to_draw.extend((row, regular, False) for row in section.rows)
|
|
||||||
|
|
||||||
left = (x0_mm + f.x_mm) * mm
|
left = (x0_mm + f.x_mm) * mm
|
||||||
top = (y0_mm + badge_height_mm - f.y_mm) * mm
|
top = (y0_mm + badge_height_mm - f.y_mm) * mm
|
||||||
bottom_limit = top - f.height_mm * mm
|
bottom_limit = top - height_pt
|
||||||
color = HexColor(f.color)
|
color = HexColor(f.color)
|
||||||
c.setFillColor(color)
|
c.setFillColor(color)
|
||||||
c.setStrokeColor(color)
|
c.setStrokeColor(color)
|
||||||
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
|
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
|
||||||
|
|
||||||
y = top
|
y = top
|
||||||
for cells, font, is_title in lines_to_draw:
|
for wrapped, font, is_title, row_height in lines_to_draw:
|
||||||
cell_widths = [total_width] if is_title else widths
|
cell_widths = [total_width] if is_title else widths
|
||||||
wrapped = [
|
|
||||||
wrap_text(text, font, f.font_size, max(1.0, cell_widths[col] - 2 * pad_x))
|
|
||||||
for col, text in enumerate(cells)
|
|
||||||
]
|
|
||||||
row_height = max(len(lines) for lines in wrapped) * line_height + 2 * pad_y
|
|
||||||
if y - row_height < bottom_limit - 0.01:
|
if y - row_height < bottom_limit - 0.01:
|
||||||
break # la ligne déborderait du cadre du champ
|
break # la ligne déborderait du cadre du champ
|
||||||
c.setFont(font, f.font_size)
|
c.setFont(font, font_size)
|
||||||
x = left
|
x = left
|
||||||
for col, lines in enumerate(wrapped):
|
for col, lines in enumerate(wrapped):
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
baseline = y - pad_y - f.font_size * 0.85 - i * line_height
|
baseline = y - pad_y - font_size * 0.85 - i * line_height
|
||||||
c.drawString(x + pad_x, baseline, line)
|
c.drawString(x + pad_x, baseline, line)
|
||||||
x += cell_widths[col]
|
x += cell_widths[col]
|
||||||
if not is_title:
|
if not is_title:
|
||||||
@@ -312,6 +424,71 @@ def _draw_table(
|
|||||||
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
|
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _lines_that_fit(font_size: float, height_mm: float) -> int:
|
||||||
|
"""Nombre de lignes de texte qui tiennent dans `height_mm` (première
|
||||||
|
ligne de base à 0,85 × la taille sous le haut du cadre, puis un
|
||||||
|
interligne par ligne)."""
|
||||||
|
font_size_mm = font_size / mm
|
||||||
|
first = font_size_mm * 0.85
|
||||||
|
if first > height_mm + 1e-9:
|
||||||
|
return 0
|
||||||
|
return 1 + int((height_mm - first) / (font_size_mm * _LINE_HEIGHT_FACTOR) + 1e-9)
|
||||||
|
|
||||||
|
|
||||||
|
def fit_text(
|
||||||
|
text: str, font_name: str, font_size: float, width_mm: float, height_mm: float,
|
||||||
|
auto_fit: bool,
|
||||||
|
) -> tuple[float, list[str]]:
|
||||||
|
"""Taille retenue et lignes à imprimer d'un champ texte. Sans
|
||||||
|
`auto_fit` : la taille du champ, lignes en trop retirées. Avec : taille
|
||||||
|
réduite par pas de `_AUTO_FIT_STEP_PT` (plancher `_AUTO_FIT_MIN_PT`)
|
||||||
|
jusqu'à ce que toutes les lignes repliées tiennent dans le cadre."""
|
||||||
|
lines = wrap_text(text, font_name, font_size, width_mm * mm)
|
||||||
|
if auto_fit:
|
||||||
|
while (
|
||||||
|
len(lines) > _lines_that_fit(font_size, height_mm)
|
||||||
|
and font_size - _AUTO_FIT_STEP_PT >= _AUTO_FIT_MIN_PT
|
||||||
|
):
|
||||||
|
font_size -= _AUTO_FIT_STEP_PT
|
||||||
|
lines = wrap_text(text, font_name, font_size, width_mm * mm)
|
||||||
|
return font_size, lines[: _lines_that_fit(font_size, height_mm)]
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_text(
|
||||||
|
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||||
|
) -> None:
|
||||||
|
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
|
||||||
|
font_size, lines = fit_text(
|
||||||
|
f.text, font_name, f.font_size, f.width_mm, f.height_mm, f.auto_fit
|
||||||
|
)
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
c.setFont(font_name, font_size)
|
||||||
|
c.setFillColor(HexColor(f.color))
|
||||||
|
left_x = x0_mm + f.x_mm
|
||||||
|
# `font_size` est en points (unité reportlab) ; le reste du calcul
|
||||||
|
# travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant
|
||||||
|
# ici le facteur de conversion pt/mm de reportlab).
|
||||||
|
font_size_mm = font_size / mm
|
||||||
|
line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR
|
||||||
|
# `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
|
||||||
|
# dessine depuis le BAS de la page.
|
||||||
|
top_y = y0_mm + badge_height_mm - f.y_mm
|
||||||
|
if f.valign == "MIDDLE":
|
||||||
|
# Hauteur du bloc : interlignes entre les lignes + une hauteur de
|
||||||
|
# caractère pour la dernière.
|
||||||
|
block = (len(lines) - 1) * line_height_mm + font_size_mm
|
||||||
|
top_y -= max(0.0, (f.height_mm - block) / 2)
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
|
||||||
|
if f.align == "CENTER":
|
||||||
|
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
|
||||||
|
elif f.align == "RIGHT":
|
||||||
|
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line)
|
||||||
|
else:
|
||||||
|
c.drawString(left_x * mm, baseline_y * mm, line)
|
||||||
|
|
||||||
|
|
||||||
def _draw_fields(
|
def _draw_fields(
|
||||||
c: canvas.Canvas,
|
c: canvas.Canvas,
|
||||||
fields: tuple[BadgeFieldSpec, ...],
|
fields: tuple[BadgeFieldSpec, ...],
|
||||||
@@ -323,33 +500,11 @@ def _draw_fields(
|
|||||||
if f.is_photo:
|
if f.is_photo:
|
||||||
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm)
|
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||||
continue
|
continue
|
||||||
|
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||||
if f.table is not None:
|
if f.table is not None:
|
||||||
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
|
_draw_table(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||||
continue
|
|
||||||
font_name = "Helvetica-Bold" if f.bold else "Helvetica"
|
|
||||||
c.setFont(font_name, f.font_size)
|
|
||||||
c.setFillColor(HexColor(f.color))
|
|
||||||
left_x = x0_mm + f.x_mm
|
|
||||||
# `f.font_size` est en points (unité reportlab) ; le reste du calcul
|
|
||||||
# travaille en mm ⇒ on convertit d'abord (1 pt = 1/mm mm, `mm` étant
|
|
||||||
# ici le facteur de conversion pt/mm de reportlab).
|
|
||||||
font_size_mm = f.font_size / mm
|
|
||||||
line_height_mm = font_size_mm * _LINE_HEIGHT_FACTOR
|
|
||||||
# `f.y_mm` est depuis le HAUT du badge (repère écran) ; reportlab
|
|
||||||
# dessine depuis le BAS de la page.
|
|
||||||
top_y = y0_mm + badge_height_mm - f.y_mm
|
|
||||||
bottom_limit = top_y - f.height_mm
|
|
||||||
lines = wrap_text(f.text, font_name, f.font_size, f.width_mm * mm)
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
baseline_y = top_y - font_size_mm * 0.85 - i * line_height_mm
|
|
||||||
if baseline_y < bottom_limit:
|
|
||||||
break # la ligne déborderait de la boîte du champ
|
|
||||||
if f.align == "CENTER":
|
|
||||||
c.drawCentredString((left_x + f.width_mm / 2) * mm, baseline_y * mm, line)
|
|
||||||
elif f.align == "RIGHT":
|
|
||||||
c.drawRightString((left_x + f.width_mm) * mm, baseline_y * mm, line)
|
|
||||||
else:
|
else:
|
||||||
c.drawString(left_x * mm, baseline_y * mm, line)
|
_draw_text(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||||
|
|
||||||
|
|
||||||
def generate_badge_sheets_pdf(
|
def generate_badge_sheets_pdf(
|
||||||
@@ -374,9 +529,10 @@ def generate_badge_sheets_pdf(
|
|||||||
_draw_fields(c, badge.fields, x0, y0, badge_height_mm)
|
_draw_fields(c, badge.fields, x0, y0, badge_height_mm)
|
||||||
c.showPage()
|
c.showPage()
|
||||||
|
|
||||||
for i in range(len(page_badges)):
|
for i, badge in enumerate(page_badges):
|
||||||
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
|
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True)
|
||||||
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
|
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
|
||||||
|
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm)
|
||||||
c.showPage()
|
c.showPage()
|
||||||
c.save()
|
c.save()
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ _HEADER = [
|
|||||||
"Mécénat — pays",
|
"Mécénat — pays",
|
||||||
"Commentaire",
|
"Commentaire",
|
||||||
"Texte du badge",
|
"Texte du badge",
|
||||||
|
"Nombre de badges",
|
||||||
"Inscrit le",
|
"Inscrit le",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ def generate_volunteers_xlsx(
|
|||||||
_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 ""),
|
_safe_text(v.badge_text or ""),
|
||||||
|
v.badge_count,
|
||||||
_fmt_dt(v.created_at),
|
_fmt_dt(v.created_at),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -61,7 +61,8 @@
|
|||||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||||
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
|
"badge.duplicate_field_type": "Le champ « {field_type} » ne peut apparaître qu'une fois.",
|
||||||
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
||||||
"badge.no_fields": "Aucun champ n'est configuré sur le recto du badge.",
|
"badge.invalid_color": "La couleur « {color} » n'est pas valide (format attendu : #rrggbb).",
|
||||||
|
"badge.no_fields": "Aucun champ n'est configuré sur le badge.",
|
||||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||||
"badge.role.referent": "Référent",
|
"badge.role.referent": "Référent",
|
||||||
"badge.role.volunteer": "Bénévole",
|
"badge.role.volunteer": "Bénévole",
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Éditeur de badges v2 (verso, fond de zone, auto-ajustement, photo ovale)
|
||||||
|
|
||||||
|
Revision ID: 0026_badge_editor_v2
|
||||||
|
Revises: 0025_badge_field_text
|
||||||
|
Create Date: 2026-10-27
|
||||||
|
|
||||||
|
`badge_field.face` : face du badge (`FRONT`/`BACK`) — les champs existants
|
||||||
|
deviennent des champs du recto. La contrainte d'unicité passe de
|
||||||
|
(`edition_id`, `field_type`) à (`edition_id`, `face`, `field_type`) : un même
|
||||||
|
champ peut figurer sur les deux faces. `bg_color` (fond de la zone,
|
||||||
|
transparent si `NULL`), `auto_fit` (réduction automatique du texte) et
|
||||||
|
`photo_shape` (`RECT`/`OVAL`, champ `PHOTO`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0026_badge_editor_v2"
|
||||||
|
down_revision: str | None = "0025_badge_field_text"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
_FACE = sa.Enum("FRONT", "BACK", name="badgeface", native_enum=False)
|
||||||
|
_PHOTO_SHAPE = sa.Enum("RECT", "OVAL", name="badgephotoshape", native_enum=False)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"badge_field",
|
||||||
|
sa.Column("face", _FACE, server_default="FRONT", nullable=False),
|
||||||
|
)
|
||||||
|
op.add_column("badge_field", sa.Column("bg_color", sa.String(), nullable=True))
|
||||||
|
op.add_column(
|
||||||
|
"badge_field",
|
||||||
|
sa.Column("auto_fit", sa.Boolean(), server_default="false", nullable=False),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"badge_field",
|
||||||
|
sa.Column("photo_shape", _PHOTO_SHAPE, server_default="RECT", nullable=False),
|
||||||
|
)
|
||||||
|
op.drop_constraint("uq_badge_field_edition_type", "badge_field", type_="unique")
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_badge_field_edition_face_type",
|
||||||
|
"badge_field",
|
||||||
|
["edition_id", "face", "field_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Le verso n'existait pas : ses champs sont perdus (sinon l'ancienne
|
||||||
|
# contrainte d'unicité échouerait sur un champ présent sur les deux faces).
|
||||||
|
op.execute("DELETE FROM badge_field WHERE face = 'BACK'")
|
||||||
|
op.drop_constraint("uq_badge_field_edition_face_type", "badge_field", type_="unique")
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_badge_field_edition_type", "badge_field", ["edition_id", "field_type"]
|
||||||
|
)
|
||||||
|
op.drop_column("badge_field", "photo_shape")
|
||||||
|
op.drop_column("badge_field", "auto_fit")
|
||||||
|
op.drop_column("badge_field", "bg_color")
|
||||||
|
op.drop_column("badge_field", "face")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Nombre de badges par bénévole
|
||||||
|
|
||||||
|
Revision ID: 0027_volunteer_badge_count
|
||||||
|
Revises: 0026_badge_editor_v2
|
||||||
|
Create Date: 2026-10-27
|
||||||
|
|
||||||
|
`volunteer.badge_count` : nombre d'exemplaires de son badge imprimés sur les
|
||||||
|
planches (`POST .../badges/generate`), 1 par défaut — les bénévoles existants
|
||||||
|
gardent un seul badge.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0027_volunteer_badge_count"
|
||||||
|
down_revision: str | None = "0026_badge_editor_v2"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"volunteer",
|
||||||
|
sa.Column("badge_count", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_volunteer_badge_count_positive", "volunteer", "badge_count >= 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("ck_volunteer_badge_count_positive", "volunteer", type_="check")
|
||||||
|
op.drop_column("volunteer", "badge_count")
|
||||||
@@ -28,7 +28,9 @@ from app.models.domain import (
|
|||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
|
BadgeFace,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
|
BadgePhotoShape,
|
||||||
FeePaymentStatus,
|
FeePaymentStatus,
|
||||||
FormFieldType,
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
@@ -41,8 +43,10 @@ __all__ = [
|
|||||||
"Assignment",
|
"Assignment",
|
||||||
"AssignmentSource",
|
"AssignmentSource",
|
||||||
"BadgeAlign",
|
"BadgeAlign",
|
||||||
|
"BadgeFace",
|
||||||
"BadgeField",
|
"BadgeField",
|
||||||
"BadgeFieldType",
|
"BadgeFieldType",
|
||||||
|
"BadgePhotoShape",
|
||||||
"BadgeTemplate",
|
"BadgeTemplate",
|
||||||
"DayPart",
|
"DayPart",
|
||||||
"Edition",
|
"Edition",
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ from app.models.base import TimestampMixin
|
|||||||
from app.models.enums import (
|
from app.models.enums import (
|
||||||
AssignmentSource,
|
AssignmentSource,
|
||||||
BadgeAlign,
|
BadgeAlign,
|
||||||
|
BadgeFace,
|
||||||
BadgeFieldType,
|
BadgeFieldType,
|
||||||
|
BadgePhotoShape,
|
||||||
FeePaymentStatus,
|
FeePaymentStatus,
|
||||||
FormFieldType,
|
FormFieldType,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
@@ -160,6 +162,10 @@ class Artist(TimestampMixin, table=True):
|
|||||||
|
|
||||||
|
|
||||||
class Volunteer(TimestampMixin, table=True):
|
class Volunteer(TimestampMixin, table=True):
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("badge_count >= 1", name="ck_volunteer_badge_count_positive"),
|
||||||
|
)
|
||||||
|
|
||||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||||
edition_id: uuid.UUID = Field(
|
edition_id: uuid.UUID = Field(
|
||||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
@@ -187,6 +193,11 @@ class Volunteer(TimestampMixin, table=True):
|
|||||||
# saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
|
# saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
|
||||||
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
|
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
|
||||||
badge_text: str | None = None
|
badge_text: str | None = None
|
||||||
|
# Nombre d'exemplaires de son badge à imprimer sur les planches (badge
|
||||||
|
# perdu, plusieurs accréditations…) — 1 par défaut.
|
||||||
|
badge_count: int = Field(
|
||||||
|
default=1, sa_column=Column(Integer, nullable=False, server_default="1")
|
||||||
|
)
|
||||||
# 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(
|
||||||
@@ -429,16 +440,17 @@ class BadgeTemplate(TimestampMixin, table=True):
|
|||||||
|
|
||||||
|
|
||||||
class BadgeField(TimestampMixin, table=True):
|
class BadgeField(TimestampMixin, table=True):
|
||||||
"""Un champ positionné sur le recto du badge (nom, poste(s), catégorie,
|
"""Un champ positionné sur une face du badge (`face` : recto ou verso —
|
||||||
rôle — voir `BadgeFieldType`). Coordonnées en millimètres depuis le coin
|
nom, poste(s), catégorie, rôle… voir `BadgeFieldType`). Coordonnées en
|
||||||
haut-gauche du badge (format CR80, 85,6 × 54 mm — voir `app/badges/pdf.py`).
|
millimètres depuis le coin haut-gauche du badge (voir `app/badges/pdf.py`).
|
||||||
Au plus un champ par `field_type` et par édition : décocher un champ dans
|
Au plus un champ par `field_type` et par face pour une édition (le même
|
||||||
|
champ peut figurer au recto et au verso) : décocher un champ dans
|
||||||
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
|
l'éditeur retire sa ligne (remplacement complet, `PUT .../fields`)."""
|
||||||
|
|
||||||
__tablename__ = "badge_field"
|
__tablename__ = "badge_field"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint(
|
UniqueConstraint(
|
||||||
"edition_id", "field_type", name="uq_badge_field_edition_type"
|
"edition_id", "face", "field_type", name="uq_badge_field_edition_face_type"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -446,6 +458,14 @@ class BadgeField(TimestampMixin, table=True):
|
|||||||
edition_id: uuid.UUID = Field(
|
edition_id: uuid.UUID = Field(
|
||||||
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
foreign_key="edition.id", index=True, ondelete="CASCADE"
|
||||||
)
|
)
|
||||||
|
face: BadgeFace = Field(
|
||||||
|
default=BadgeFace.FRONT,
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(BadgeFace, native_enum=False),
|
||||||
|
nullable=False,
|
||||||
|
server_default=BadgeFace.FRONT.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
field_type: BadgeFieldType = Field(
|
field_type: BadgeFieldType = Field(
|
||||||
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
|
sa_column=Column(SAEnum(BadgeFieldType, native_enum=False), nullable=False)
|
||||||
)
|
)
|
||||||
@@ -471,6 +491,24 @@ class BadgeField(TimestampMixin, table=True):
|
|||||||
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
|
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
|
||||||
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
|
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
|
||||||
text: str | None = None
|
text: str | None = None
|
||||||
|
# Fond de la zone du champ (`#rrggbb`) ; `None` = transparent. Sans
|
||||||
|
# effet sur le champ `PHOTO`.
|
||||||
|
bg_color: str | None = None
|
||||||
|
# Réduit la taille du texte (par pas de 0,5 pt) jusqu'à ce que tout
|
||||||
|
# tienne dans le cadre, au lieu de tronquer les lignes en trop.
|
||||||
|
auto_fit: bool = Field(
|
||||||
|
default=False,
|
||||||
|
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
# Champ `PHOTO` seulement : rectangle ou ellipse inscrite dans le cadre.
|
||||||
|
photo_shape: BadgePhotoShape = Field(
|
||||||
|
default=BadgePhotoShape.RECT,
|
||||||
|
sa_column=Column(
|
||||||
|
SAEnum(BadgePhotoShape, native_enum=False),
|
||||||
|
nullable=False,
|
||||||
|
server_default=BadgePhotoShape.RECT.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class PositionCategory(enum.StrEnum):
|
|||||||
|
|
||||||
|
|
||||||
class BadgeFieldType(enum.StrEnum):
|
class BadgeFieldType(enum.StrEnum):
|
||||||
"""Champ affichable sur le recto d'un badge (voir `app/badges/`)."""
|
"""Champ affichable sur le recto ou le verso d'un badge (voir `app/badges/`)."""
|
||||||
|
|
||||||
FULL_NAME = "FULL_NAME"
|
FULL_NAME = "FULL_NAME"
|
||||||
POSITIONS = "POSITIONS"
|
POSITIONS = "POSITIONS"
|
||||||
@@ -45,6 +45,22 @@ class BadgeFieldType(enum.StrEnum):
|
|||||||
FREE_TEXT = "FREE_TEXT"
|
FREE_TEXT = "FREE_TEXT"
|
||||||
|
|
||||||
|
|
||||||
|
class BadgeFace(enum.StrEnum):
|
||||||
|
"""Face du badge sur laquelle un champ est imprimé : le verso est
|
||||||
|
personnalisable comme le recto (même champ possible sur les deux)."""
|
||||||
|
|
||||||
|
FRONT = "FRONT"
|
||||||
|
BACK = "BACK"
|
||||||
|
|
||||||
|
|
||||||
|
class BadgePhotoShape(enum.StrEnum):
|
||||||
|
"""Forme de découpe du champ `PHOTO` (cadre rectangulaire ou ellipse
|
||||||
|
inscrite dans le cadre)."""
|
||||||
|
|
||||||
|
RECT = "RECT"
|
||||||
|
OVAL = "OVAL"
|
||||||
|
|
||||||
|
|
||||||
class BadgeAlign(enum.StrEnum):
|
class BadgeAlign(enum.StrEnum):
|
||||||
LEFT = "LEFT"
|
LEFT = "LEFT"
|
||||||
CENTER = "CENTER"
|
CENTER = "CENTER"
|
||||||
|
|||||||
@@ -247,3 +247,154 @@ def test_generating_with_photo_and_free_text_fields(client, edition, session):
|
|||||||
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.content.startswith(b"%PDF")
|
assert resp.content.startswith(b"%PDF")
|
||||||
|
|
||||||
|
|
||||||
|
def _field(**kw):
|
||||||
|
return {"x_mm": 0, "y_mm": 0, "width_mm": 10, "height_mm": 10, **kw}
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_field_attributes_default_and_round_trip(client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
json={
|
||||||
|
"fields": [
|
||||||
|
_field(field_type="FULL_NAME"),
|
||||||
|
_field(
|
||||||
|
field_type="PHOTO", face="BACK", photo_shape="OVAL",
|
||||||
|
bg_color="#FFD23F", auto_fit=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
by_face = {f["face"]: f for f in resp.json()}
|
||||||
|
front, back = by_face["FRONT"], by_face["BACK"]
|
||||||
|
assert front["field_type"] == "FULL_NAME"
|
||||||
|
assert front["bg_color"] is None
|
||||||
|
assert front["auto_fit"] is False
|
||||||
|
assert front["photo_shape"] == "RECT"
|
||||||
|
assert back["field_type"] == "PHOTO"
|
||||||
|
assert back["photo_shape"] == "OVAL"
|
||||||
|
assert back["bg_color"] == "#FFD23F"
|
||||||
|
assert back["auto_fit"] is True
|
||||||
|
|
||||||
|
again = client.get(f"{_badges_url(edition)}/template").json()["fields"]
|
||||||
|
assert {(f["face"], f["field_type"]) for f in again} == {
|
||||||
|
("FRONT", "FULL_NAME"), ("BACK", "PHOTO"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_field_type_on_front_and_back_is_allowed(client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
json={
|
||||||
|
"fields": [
|
||||||
|
_field(field_type="FREE_TEXT", face="FRONT", text="Recto"),
|
||||||
|
_field(field_type="FREE_TEXT", face="BACK", text="Verso"),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert {(f["face"], f["text"]) for f in resp.json()} == {
|
||||||
|
("FRONT", "Recto"), ("BACK", "Verso"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_field_type_twice_on_the_back_is_rejected(client, edition):
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
json={
|
||||||
|
"fields": [
|
||||||
|
_field(field_type="ROLE", face="BACK"),
|
||||||
|
_field(field_type="ROLE", face="BACK"),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "badge.duplicate_field_type"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("color", ["red", "#FFF", "#GGGGGG", "FFD23F", "#FFD23F0"])
|
||||||
|
def test_invalid_background_color_is_rejected(client, edition, color):
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
json={"fields": [_field(field_type="ROLE", bg_color=color)]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert resp.json()["detail"]["code"] == "badge.invalid_color"
|
||||||
|
|
||||||
|
|
||||||
|
def test_generating_with_back_fields(client, edition, session, monkeypatch):
|
||||||
|
import app.api.badges as badges_api
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
original = badges_api.generate_badge_sheets_pdf
|
||||||
|
|
||||||
|
def spy(badges, *args, **kwargs):
|
||||||
|
captured["badges"] = badges
|
||||||
|
return original(badges, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
|
||||||
|
resp = client.put(
|
||||||
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
json={
|
||||||
|
"fields": [
|
||||||
|
_field(field_type="FULL_NAME", width_mm=60, font_size=12),
|
||||||
|
_field(field_type="ROLE", face="BACK", bg_color="#FFD23F", auto_fit=True),
|
||||||
|
_field(field_type="PHOTO", face="BACK", photo_shape="OVAL"),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||||
|
|
||||||
|
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.content.startswith(b"%PDF")
|
||||||
|
|
||||||
|
(badge,) = captured["badges"]
|
||||||
|
assert [f.text for f in badge.fields] == ["Alex\nMartin"]
|
||||||
|
back = {f.is_photo: f for f in badge.back_fields}
|
||||||
|
assert back[False].text == "Bénévole"
|
||||||
|
assert back[False].valign == "MIDDLE"
|
||||||
|
assert back[False].bg_color == "#FFD23F"
|
||||||
|
assert back[False].auto_fit is True
|
||||||
|
assert back[True].photo_shape == "OVAL"
|
||||||
|
assert back[True].text == "AM" # initiales, faute de photo
|
||||||
|
|
||||||
|
|
||||||
|
def test_generating_prints_as_many_badges_as_requested(client, edition, session, monkeypatch):
|
||||||
|
import app.api.badges as badges_api
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
original = badges_api.generate_badge_sheets_pdf
|
||||||
|
|
||||||
|
def spy(badges, *args, **kwargs):
|
||||||
|
captured["badges"] = badges
|
||||||
|
return original(badges, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(badges_api, "generate_badge_sheets_pdf", spy)
|
||||||
|
client.put(
|
||||||
|
f"{_badges_url(edition)}/template/fields",
|
||||||
|
json={"fields": [_field(field_type="FULL_NAME", width_mm=60, font_size=12)]},
|
||||||
|
)
|
||||||
|
many = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||||
|
make_volunteer(session, edition, first_name="Sam", last_name="Nuit")
|
||||||
|
resp = client.patch(f"/editions/{edition.id}/volunteers/{many.id}", json={"badge_count": 3})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["badge_count"] == 3
|
||||||
|
|
||||||
|
resp = client.post(f"{_badges_url(edition)}/generate", json={})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
names = [badge.fields[0].text for badge in captured["badges"]]
|
||||||
|
assert names == ["Alex\nMartin"] * 3 + ["Sam\nNuit"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("count", [0, -1, 101])
|
||||||
|
def test_badge_count_out_of_range_is_rejected(client, edition, session, count):
|
||||||
|
volunteer = make_volunteer(session, edition)
|
||||||
|
resp = client.patch(
|
||||||
|
f"/editions/{edition.id}/volunteers/{volunteer.id}", json={"badge_count": count}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|||||||
@@ -104,3 +104,21 @@ def test_field_text_free_text_falls_back_to_the_common_editor_text():
|
|||||||
|
|
||||||
assert _field_text(field, own, {}, set()) == "Accès backstage"
|
assert _field_text(field, own, {}, set()) == "Accès backstage"
|
||||||
assert _field_text(field, other, {}, set()) == "Staff 2026"
|
assert _field_text(field, other, {}, set()) == "Staff 2026"
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_text_full_name_is_first_name_then_last_name_on_two_lines():
|
||||||
|
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
||||||
|
field = BadgeField(
|
||||||
|
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FULL_NAME,
|
||||||
|
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||||
|
)
|
||||||
|
assert _field_text(field, volunteer, {}, set()) == "Alex\nMartin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_text_photo_is_the_initials_used_without_a_photo():
|
||||||
|
volunteer = _volunteer(first_name="alex", last_name="martin")
|
||||||
|
field = BadgeField(
|
||||||
|
edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO,
|
||||||
|
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||||
|
)
|
||||||
|
assert _field_text(field, volunteer, {}, set()) == "AM"
|
||||||
|
|||||||
@@ -236,3 +236,170 @@ def test_table_rows_beyond_the_frame_are_not_drawn():
|
|||||||
few = generate_badge_sheets_pdf([_table_badge(4, height_mm=12)], None, None)
|
few = generate_badge_sheets_pdf([_table_badge(4, height_mm=12)], None, None)
|
||||||
many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None)
|
many = generate_badge_sheets_pdf([_table_badge(50, height_mm=12)], None, None)
|
||||||
assert abs(len(many) - len(few)) < 50
|
assert abs(len(many) - len(few)) < 50
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Éditeur v2 : verso, fond de zone, réduction automatique, photo #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
from app.badges import pdf as pdf_module # noqa: E402
|
||||||
|
from app.badges.pdf import fit_text, initials # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _text_field(**kw) -> BadgeFieldSpec:
|
||||||
|
base = dict(
|
||||||
|
text="Alex", x_mm=5, y_mm=5, width_mm=60, height_mm=10,
|
||||||
|
font_size=12, align="LEFT", bold=False, color="#000000",
|
||||||
|
)
|
||||||
|
base.update(kw)
|
||||||
|
return BadgeFieldSpec(**base)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cell_origin_mirrors_the_column_on_the_back_page():
|
||||||
|
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||||
|
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
|
||||||
|
# Retournement bord long : colonne 0 au recto ⇒ dernière colonne au
|
||||||
|
# verso, même rangée.
|
||||||
|
assert _cell_origin(0, cols, w, h, mirrored=True) == _cell_origin(cols - 1, cols, w, h)
|
||||||
|
assert _cell_origin(cols, cols, w, h, mirrored=True) == _cell_origin(
|
||||||
|
2 * cols - 1, cols, w, h
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
original = pdf_module._draw_fields
|
||||||
|
|
||||||
|
def spy(c, fields, x0, y0, badge_height_mm):
|
||||||
|
calls.append((fields, x0, y0))
|
||||||
|
original(c, fields, x0, y0, badge_height_mm)
|
||||||
|
|
||||||
|
monkeypatch.setattr(pdf_module, "_draw_fields", spy)
|
||||||
|
back = (_text_field(text="Verso Alex"),)
|
||||||
|
# Un seul badge : planche incomplète, le verso doit quand même être en
|
||||||
|
# miroir (colonne de droite).
|
||||||
|
generate_badge_sheets_pdf(
|
||||||
|
[BadgeSpec(fields=(_text_field(),), back_fields=back)], None, None
|
||||||
|
)
|
||||||
|
|
||||||
|
cols, _ = _grid_size(DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM)
|
||||||
|
w, h = DEFAULT_BADGE_WIDTH_MM, DEFAULT_BADGE_HEIGHT_MM
|
||||||
|
(front_fields, fx, fy), (back_fields, bx, by) = calls
|
||||||
|
assert (fx, fy) == _cell_origin(0, cols, w, h)
|
||||||
|
assert back_fields == back
|
||||||
|
assert (bx, by) == _cell_origin(cols - 1, cols, w, h)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_text_without_auto_fit_drops_the_lines_that_overflow():
|
||||||
|
text = "\n".join(["ligne"] * 10)
|
||||||
|
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)
|
||||||
|
assert size == 12
|
||||||
|
assert 0 < len(lines) < 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_text_with_auto_fit_shrinks_until_every_line_fits():
|
||||||
|
text = "\n".join(["ligne"] * 4)
|
||||||
|
assert len(fit_text(text, "Helvetica", 12, 60, 10, auto_fit=False)[1]) < 4
|
||||||
|
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
|
||||||
|
assert 4 < size < 12
|
||||||
|
assert (size * 2) == int(size * 2) # pas de 0,5 pt
|
||||||
|
assert len(lines) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_text_auto_fit_stops_at_the_minimum_size():
|
||||||
|
text = "\n".join(["ligne"] * 200)
|
||||||
|
size, lines = fit_text(text, "Helvetica", 12, 60, 10, auto_fit=True)
|
||||||
|
assert size == 4.0
|
||||||
|
assert len(lines) < 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_text_auto_fit_keeps_the_size_when_the_text_already_fits():
|
||||||
|
assert fit_text("Alex", "Helvetica", 12, 60, 10, auto_fit=True) == (12, ["Alex"])
|
||||||
|
|
||||||
|
|
||||||
|
def _table_lines_drawn(monkeypatch, auto_fit: bool) -> int:
|
||||||
|
drawn = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pdf_module.canvas.Canvas, "drawString",
|
||||||
|
lambda self, x, y, text, *a, **k: drawn.append(text),
|
||||||
|
)
|
||||||
|
badge = _table_badge(4, height_mm=20)
|
||||||
|
field = badge.fields[0]
|
||||||
|
badge = BadgeSpec(fields=(BadgeFieldSpec(**{**field.__dict__, "auto_fit": auto_fit}),))
|
||||||
|
generate_badge_sheets_pdf([badge], None, None)
|
||||||
|
return sum(1 for text in drawn if text == "19:00–22:00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_table_auto_fit_draws_every_row(monkeypatch):
|
||||||
|
# En-tête + 4 lignes : la dernière ne tient pas à 7 pt dans 20 mm.
|
||||||
|
assert _table_lines_drawn(monkeypatch, auto_fit=False) < 4
|
||||||
|
assert _table_lines_drawn(monkeypatch, auto_fit=True) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def _filled_rects(monkeypatch) -> list:
|
||||||
|
fills = []
|
||||||
|
original = pdf_module.canvas.Canvas.rect
|
||||||
|
|
||||||
|
def spy(self, x, y, w, h, stroke=1, fill=0):
|
||||||
|
if fill:
|
||||||
|
fills.append((round(w / mm, 3), round(h / mm, 3)))
|
||||||
|
return original(self, x, y, w, h, stroke=stroke, fill=fill)
|
||||||
|
|
||||||
|
monkeypatch.setattr(pdf_module.canvas.Canvas, "rect", spy)
|
||||||
|
return fills
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_background_fills_the_field_frame(monkeypatch):
|
||||||
|
fills = _filled_rects(monkeypatch)
|
||||||
|
generate_badge_sheets_pdf(
|
||||||
|
[BadgeSpec(fields=(_text_field(bg_color="#FFD23F"),))], None, None
|
||||||
|
)
|
||||||
|
assert fills == [(60, 10)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_zone_background_by_default(monkeypatch):
|
||||||
|
fills = _filled_rects(monkeypatch)
|
||||||
|
generate_badge_sheets_pdf([BadgeSpec(fields=(_text_field(),))], None, None)
|
||||||
|
assert fills == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch):
|
||||||
|
baselines = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pdf_module.canvas.Canvas, "drawString",
|
||||||
|
lambda self, x, y, text, *a, **k: baselines.append(y),
|
||||||
|
)
|
||||||
|
for valign in ("TOP", "MIDDLE"):
|
||||||
|
generate_badge_sheets_pdf(
|
||||||
|
[BadgeSpec(fields=(_text_field(height_mm=30, valign=valign),))], None, None
|
||||||
|
)
|
||||||
|
top, middle = baselines
|
||||||
|
assert middle < top
|
||||||
|
|
||||||
|
|
||||||
|
def test_initials_are_the_uppercase_first_letters():
|
||||||
|
assert initials("alex", "martin") == "AM"
|
||||||
|
assert initials("Élodie", "de la Tour") == "ÉD"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_photo_prints_the_initials(monkeypatch):
|
||||||
|
drawn = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
pdf_module.canvas.Canvas, "drawCentredString",
|
||||||
|
lambda self, x, y, text, *a, **k: drawn.append(text),
|
||||||
|
)
|
||||||
|
for image in (None, b"pas une image"):
|
||||||
|
badge = BadgeSpec(
|
||||||
|
fields=(BadgeFieldSpec(**{**_photo_badge(image).fields[0].__dict__, "text": "AM"}),)
|
||||||
|
)
|
||||||
|
generate_badge_sheets_pdf([badge], None, None)
|
||||||
|
assert drawn == ["AM", "AM"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_oval_photo_and_oval_initials_generate_a_valid_pdf():
|
||||||
|
for image in (_jpeg(400, 500), None):
|
||||||
|
field = _photo_badge(image).fields[0]
|
||||||
|
badge = BadgeSpec(
|
||||||
|
fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL", "text": "AM"}),)
|
||||||
|
)
|
||||||
|
pdf = generate_badge_sheets_pdf([badge], None, None)
|
||||||
|
assert pdf.startswith(b"%PDF")
|
||||||
|
|||||||
@@ -461,6 +461,64 @@ function checkDayBoundary() {
|
|||||||
console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`);
|
console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Éditeur de badges (onglet « Personnaliser ») : un champ par face, rendu
|
||||||
|
// avec les vraies données (nom du premier bénévole dans l'ordre
|
||||||
|
// d'impression), contrôle avant génération présent ; le mock refuse un même
|
||||||
|
// type de champ deux fois sur une face, mais l'accepte sur les deux.
|
||||||
|
async function checkBadgeEditor() {
|
||||||
|
if (!coordinator) throw new Error('badges : pas de session coordination pour ce test');
|
||||||
|
const base = {
|
||||||
|
x_mm: 4, y_mm: 4, width_mm: 40, height_mm: 12, font_size: 9, align: 'LEFT' as const,
|
||||||
|
bold: true, color: '#14110E', bg_color: null, auto_fit: true, photo_shape: 'RECT' as const,
|
||||||
|
};
|
||||||
|
let duplicate = 'non';
|
||||||
|
try {
|
||||||
|
await api.badges.setFields(e, {
|
||||||
|
fields: [
|
||||||
|
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
|
||||||
|
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
duplicate = err instanceof ApiError ? err.code : String(err);
|
||||||
|
}
|
||||||
|
if (duplicate !== 'badge.duplicate_field_type') throw new Error(`doublon accepté : ${duplicate}`);
|
||||||
|
await api.badges.setFields(e, {
|
||||||
|
fields: [
|
||||||
|
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
|
||||||
|
{ ...base, field_type: 'POSITIONS', face: 'FRONT', y_mm: 18, height_mm: 30, bold: false },
|
||||||
|
{ ...base, field_type: 'FULL_NAME', face: 'BACK', bg_color: '#FFD23F' },
|
||||||
|
{ ...base, field_type: 'FREE_TEXT', face: 'BACK', y_mm: 30, text: 'STAFF' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const vols = [...(await api.volunteers.list(e))].sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.last_name.localeCompare(b.last_name, 'fr') || a.first_name.localeCompare(b.first_name, 'fr'),
|
||||||
|
);
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
await prefetchCore(client);
|
||||||
|
await Promise.all([
|
||||||
|
client.prefetchQuery({ queryKey: ['badgeTemplate', e], queryFn: () => api.badges.getTemplate(e) }),
|
||||||
|
client.prefetchQuery({ queryKey: ['dayParts', e], queryFn: () => api.dayParts.list(e) }),
|
||||||
|
client.prefetchQuery({ queryKey: ['accounts', e], queryFn: () => api.accounts.list(e) }),
|
||||||
|
]);
|
||||||
|
const html = renderToString(
|
||||||
|
createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client },
|
||||||
|
createElement(
|
||||||
|
AuthContext.Provider,
|
||||||
|
{ value: authValue(coordinator) },
|
||||||
|
createElement(MemoryRouter, null, createElement(BadgesPage)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const expected = [vols[0]?.last_name ?? '', 'STAFF', 'Contrôle des'];
|
||||||
|
const missing = expected.filter((text) => !html.includes(text));
|
||||||
|
if (missing.length) throw new Error(`badges : absent du rendu ${JSON.stringify(missing)}`);
|
||||||
|
console.log(`ok ${'/badges'.padEnd(26)} ${html.length} octets`);
|
||||||
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
try {
|
try {
|
||||||
checkDayBoundary();
|
checkDayBoundary();
|
||||||
@@ -484,7 +542,7 @@ if (coordinator) {
|
|||||||
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
||||||
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
||||||
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
||||||
await renderProtected('/badges', coordinator, createElement(BadgesPage));
|
await checkBadgeEditor().catch((err) => fail('checkBadgeEditor', err));
|
||||||
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
|
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
|
||||||
await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => {
|
await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => {
|
||||||
await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) });
|
await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) });
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ export function buildMockState(): MockState {
|
|||||||
has_photo: false,
|
has_photo: false,
|
||||||
companion_wish_note: null,
|
companion_wish_note: null,
|
||||||
badge_text: null,
|
badge_text: null,
|
||||||
|
badge_count: 1,
|
||||||
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,
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
|||||||
has_photo: pending.photo !== null,
|
has_photo: pending.photo !== null,
|
||||||
companion_wish_note: pending.companionWishNote,
|
companion_wish_note: pending.companionWishNote,
|
||||||
badge_text: null,
|
badge_text: null,
|
||||||
|
badge_count: 1,
|
||||||
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,
|
||||||
@@ -997,6 +998,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
has_photo: false,
|
has_photo: false,
|
||||||
companion_wish_note: null,
|
companion_wish_note: null,
|
||||||
badge_text: null,
|
badge_text: null,
|
||||||
|
badge_count: 1,
|
||||||
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,
|
||||||
@@ -1042,6 +1044,14 @@ export function createMockApi(): PlanningApi {
|
|||||||
? { 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 } : {}),
|
...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}),
|
||||||
|
...(body.badge_count != null
|
||||||
|
? (() => {
|
||||||
|
if (!Number.isInteger(body.badge_count) || body.badge_count < 1 || body.badge_count > 100) {
|
||||||
|
throw new ApiError(422, 'error.generic');
|
||||||
|
}
|
||||||
|
return { badge_count: body.badge_count };
|
||||||
|
})()
|
||||||
|
: {}),
|
||||||
...('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 }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -1321,6 +1331,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
has_photo: false,
|
has_photo: false,
|
||||||
companion_wish_note: null,
|
companion_wish_note: null,
|
||||||
badge_text: null,
|
badge_text: null,
|
||||||
|
badge_count: 1,
|
||||||
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,
|
||||||
@@ -1577,13 +1588,24 @@ export function createMockApi(): PlanningApi {
|
|||||||
return (await fetch(state.badgeTemplate.backImage)).blob();
|
return (await fetch(state.badgeTemplate.backImage)).blob();
|
||||||
},
|
},
|
||||||
setFields: (_edition, body: BadgeFieldsSet) => {
|
setFields: (_edition, body: BadgeFieldsSet) => {
|
||||||
|
// Un même type de champ peut exister une fois par face (recto/verso),
|
||||||
|
// comme côté back-end.
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
const hexColor = /^#[0-9a-fA-F]{6}$/;
|
||||||
for (const f of body.fields) {
|
for (const f of body.fields) {
|
||||||
if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type');
|
const key = `${f.face ?? 'FRONT'}:${f.field_type}`;
|
||||||
seen.add(f.field_type);
|
if (seen.has(key)) throw new ApiError(422, 'badge.duplicate_field_type');
|
||||||
|
seen.add(key);
|
||||||
|
if (!hexColor.test(f.color) || (f.bg_color != null && !hexColor.test(f.bg_color))) {
|
||||||
|
throw new ApiError(422, 'badge.invalid_color');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const fields: BadgeFieldRead[] = body.fields.map((f) => ({
|
const fields: BadgeFieldRead[] = body.fields.map((f) => ({
|
||||||
...f,
|
...f,
|
||||||
|
face: f.face ?? 'FRONT',
|
||||||
|
bg_color: f.bg_color ?? null,
|
||||||
|
auto_fit: f.auto_fit ?? true,
|
||||||
|
photo_shape: f.photo_shape ?? 'RECT',
|
||||||
id: `bf${state.seq++}`,
|
id: `bf${state.seq++}`,
|
||||||
edition_id: state.edition.id,
|
edition_id: state.edition.id,
|
||||||
}));
|
}));
|
||||||
@@ -1792,6 +1814,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
has_photo: photo !== null,
|
has_photo: photo !== null,
|
||||||
companion_wish_note: companionWishNote,
|
companion_wish_note: companionWishNote,
|
||||||
badge_text: null,
|
badge_text: null,
|
||||||
|
badge_count: 1,
|
||||||
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,
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ export interface VolunteerRead {
|
|||||||
/** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la
|
/** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la
|
||||||
* coordination. */
|
* coordination. */
|
||||||
badge_text: string | null;
|
badge_text: string | null;
|
||||||
|
/** Exemplaires de son badge imprimés sur les planches (1 par défaut). */
|
||||||
|
badge_count: number;
|
||||||
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) —
|
||||||
@@ -161,6 +163,8 @@ export interface VolunteerUpdate {
|
|||||||
wished_companion_ids?: string[] | null;
|
wished_companion_ids?: string[] | null;
|
||||||
companion_wish_note?: string | null;
|
companion_wish_note?: string | null;
|
||||||
badge_text?: string | null;
|
badge_text?: string | null;
|
||||||
|
/** 1 à 100. */
|
||||||
|
badge_count?: number | null;
|
||||||
self_declared_available?: boolean | null;
|
self_declared_available?: boolean | null;
|
||||||
willing_to_pay_fee?: boolean | null;
|
willing_to_pay_fee?: boolean | null;
|
||||||
}
|
}
|
||||||
@@ -433,9 +437,16 @@ export type BadgeFieldType =
|
|||||||
| 'PHOTO'
|
| 'PHOTO'
|
||||||
| 'FREE_TEXT';
|
| 'FREE_TEXT';
|
||||||
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
|
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
|
||||||
|
/** Face du badge où se trouve un champ : chaque type de champ peut exister
|
||||||
|
* une fois par face. */
|
||||||
|
export type BadgeFace = 'FRONT' | 'BACK';
|
||||||
|
/** Forme du cadre de la photo (`PHOTO`). */
|
||||||
|
export type BadgePhotoShape = 'RECT' | 'OVAL';
|
||||||
|
|
||||||
export interface BadgeFieldIn {
|
export interface BadgeFieldIn {
|
||||||
field_type: BadgeFieldType;
|
field_type: BadgeFieldType;
|
||||||
|
/** Défaut `FRONT`. */
|
||||||
|
face: BadgeFace;
|
||||||
x_mm: number;
|
x_mm: number;
|
||||||
y_mm: number;
|
y_mm: number;
|
||||||
width_mm: number;
|
width_mm: number;
|
||||||
@@ -444,6 +455,13 @@ export interface BadgeFieldIn {
|
|||||||
align: BadgeAlign;
|
align: BadgeAlign;
|
||||||
bold: boolean;
|
bold: boolean;
|
||||||
color: string;
|
color: string;
|
||||||
|
/** Fond de la zone du champ (`#rrggbb`), `null` = transparent. */
|
||||||
|
bg_color: string | null;
|
||||||
|
/** Réduit la taille du texte (par pas de 0,5 pt, jusqu'à 4 pt) quand il
|
||||||
|
* ne tient pas dans le cadre. */
|
||||||
|
auto_fit: boolean;
|
||||||
|
/** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */
|
||||||
|
photo_shape: BadgePhotoShape;
|
||||||
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
|
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
|
||||||
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
|
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
|
||||||
text?: string | null;
|
text?: string | null;
|
||||||
|
|||||||
@@ -231,6 +231,8 @@
|
|||||||
"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.badgeCount": "Nombre de badges",
|
||||||
|
"config.vol.field.badgeCount.help": "Nombre d'exemplaires de son badge imprimés sur les planches (1 à 100). 1 par défaut.",
|
||||||
"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.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.",
|
||||||
@@ -384,13 +386,121 @@
|
|||||||
"badges.saved": "Enregistré ✓",
|
"badges.saved": "Enregistré ✓",
|
||||||
"badges.generate.title": "Génération de la planche PDF",
|
"badges.generate.title": "Génération de la planche PDF",
|
||||||
"badges.generate.all": "Tous les bénévoles ({count})",
|
"badges.generate.all": "Tous les bénévoles ({count})",
|
||||||
|
"badges.generate.total": "{count} badge(s) à imprimer",
|
||||||
"badges.generate.pick": "Choisir ({count} sélectionné(s))",
|
"badges.generate.pick": "Choisir ({count} sélectionné(s))",
|
||||||
"badges.generate.action": "Générer le PDF",
|
"badges.generate.action": "Générer le PDF",
|
||||||
"badges.generating": "Génération…",
|
"badges.generating": "Génération…",
|
||||||
|
"badges.face.front": "Recto",
|
||||||
|
"badges.face.back": "Verso",
|
||||||
|
"badges.face.front.short": "recto",
|
||||||
|
"badges.face.back.short": "verso",
|
||||||
|
"badges.dims": "{width} × {height} mm",
|
||||||
|
"badges.drop.front": "Déposez ici le visuel du recto (PNG ou JPEG)",
|
||||||
|
"badges.drop.back": "Déposez ici le visuel du verso (PNG ou JPEG)",
|
||||||
|
"badges.format.title": "Format du badge",
|
||||||
|
"badges.format.current": "Format actuel",
|
||||||
|
"badges.preset.cr80Portrait": "Carte CR80 portrait",
|
||||||
|
"badges.preset.cr80Landscape": "Carte CR80 paysage",
|
||||||
|
"badges.preset.a7": "A7 tour de cou",
|
||||||
|
"badges.fieldsOf": "Champs du {face}",
|
||||||
|
"badges.fieldsShown": "{count} affiché(s)",
|
||||||
|
"badges.layer.toggle": "Afficher / masquer",
|
||||||
|
"badges.layer.hidden": "masqué",
|
||||||
|
"badges.layer.meta": "{size} pt · {weight}",
|
||||||
|
"badges.layer.bold": "gras",
|
||||||
|
"badges.layer.regular": "normal",
|
||||||
|
"badges.layer.rect": "rectangle",
|
||||||
|
"badges.layer.oval": "ovale",
|
||||||
|
"badges.layer.issueCount": "▲ {count}",
|
||||||
|
"badges.layer.issueTruncated.one": "{count} badge avec texte coupé",
|
||||||
|
"badges.layer.issueTruncated.other": "{count} badges avec texte coupé",
|
||||||
|
"badges.layer.issueReduced.one": "{count} badge réduit automatiquement",
|
||||||
|
"badges.layer.issueReduced.other": "{count} badges réduits automatiquement",
|
||||||
|
"badges.keys.hint": "Clic sur un champ pour le régler. Flèches : déplacer de 0,5 mm. Maj + flèches : 2 mm. Échap : désélectionner. Ctrl/Cmd + Z : annuler.",
|
||||||
|
"badges.undo": "↶ Annuler",
|
||||||
|
"badges.undo.title": "Ctrl/Cmd + Z",
|
||||||
|
"badges.dirty": "Modifications non enregistrées",
|
||||||
|
"badges.clean": "✓ Enregistré",
|
||||||
|
"badges.savedAt": "✓ Enregistré à {time}",
|
||||||
|
"badges.sample.firstName": "Prénom",
|
||||||
|
"badges.sample.lastName": "Nom",
|
||||||
|
"badges.preview.with": "Aperçu avec",
|
||||||
|
"badges.preview.prev": "Bénévole précédent",
|
||||||
|
"badges.preview.next": "Bénévole suivant",
|
||||||
|
"badges.preview.counter": "Badge {index} / {total} · {role}",
|
||||||
|
"badges.preview.noVolunteer": "Aucun bénévole : exemple fictif",
|
||||||
|
"badges.preview.longest": "Tester le nom le plus long",
|
||||||
|
"badges.preview.guides": "Repères",
|
||||||
|
"badges.preview.guides.help": "Affiche la marge de découpe (1,5 mm) et le cadre de chaque champ",
|
||||||
|
"badges.preview.zoomIn": "Agrandir l'aperçu",
|
||||||
|
"badges.preview.zoomOut": "Réduire l'aperçu",
|
||||||
|
"badges.preview.zoom": "{percent} %",
|
||||||
|
"badges.preview.truncated": "▲ TEXTE COUPÉ",
|
||||||
|
"badges.preview.dims": "{width} × {height} mm",
|
||||||
|
"badges.preview.reducedTo": " · réduit à {size} pt",
|
||||||
|
"badges.pt": "{size} pt",
|
||||||
|
"badges.color.white": "Blanc",
|
||||||
|
"badges.color.black": "Noir",
|
||||||
|
"badges.color.yellow": "Jaune",
|
||||||
|
"badges.color.coral": "Corail",
|
||||||
|
"badges.color.none": "Aucun",
|
||||||
|
"badges.color.custom": "Autre",
|
||||||
|
"badges.align.left": "Gauche",
|
||||||
|
"badges.align.center": "Centre",
|
||||||
|
"badges.align.right": "Droite",
|
||||||
|
"badges.inspector.kicker": "{face} · champ sélectionné",
|
||||||
|
"badges.inspector.content": "Contenu",
|
||||||
|
"badges.inspector.contentHelp": "Identique sur tous les badges — sauf pour un bénévole dont la fiche porte un « Texte du badge », qui le remplace.",
|
||||||
|
"badges.inspector.fontSize": "Taille du texte",
|
||||||
|
"badges.inspector.reducedHere": "Ici réduit à {size} pt",
|
||||||
|
"badges.inspector.capHeight": "Hauteur ≈ {mm} mm",
|
||||||
|
"badges.inspector.smaller": "Diminuer la taille",
|
||||||
|
"badges.inspector.larger": "Augmenter la taille",
|
||||||
|
"badges.inspector.autoFit": "Réduire automatiquement si le texte est trop long",
|
||||||
|
"badges.inspector.align": "Alignement",
|
||||||
|
"badges.inspector.weight": "Graisse",
|
||||||
|
"badges.inspector.regular": "Normal",
|
||||||
|
"badges.inspector.bold": "Gras",
|
||||||
|
"badges.inspector.textColor": "Couleur du texte",
|
||||||
|
"badges.inspector.background": "Fond de la zone",
|
||||||
|
"badges.inspector.lowContrast": "⚠ Contraste faible : le texte risque d'être illisible à l'impression.",
|
||||||
|
"badges.inspector.photoShape": "Forme de la photo",
|
||||||
|
"badges.inspector.photoRect": "▢ Rectangle",
|
||||||
|
"badges.inspector.photoOval": "◯ Ovale",
|
||||||
|
"badges.inspector.photoStat": "{count} photo(s) reçue(s) sur {total}. Sans photo : initiales sur fond beige.",
|
||||||
|
"badges.inspector.geometry": "Position et taille (mm)",
|
||||||
|
"badges.inspector.x": "X",
|
||||||
|
"badges.inspector.y": "Y",
|
||||||
|
"badges.inspector.w": "L",
|
||||||
|
"badges.inspector.h": "H",
|
||||||
|
"badges.inspector.center": "↔ Centrer",
|
||||||
|
"badges.inspector.fullWidth": "⇤⇥ Pleine largeur",
|
||||||
|
"badges.inspector.hide": "Masquer ce champ",
|
||||||
|
"badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.",
|
||||||
|
"badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.",
|
||||||
|
"badges.inspector.empty.drop": "Pour changer le visuel de fond, déposez une image directement sur le recto ou le verso.",
|
||||||
|
"badges.check.title": "Contrôle des {count} badges",
|
||||||
|
"badges.check.subtitle": "Avant génération des planches",
|
||||||
|
"badges.check.critical": "⛔ À corriger",
|
||||||
|
"badges.check.warning": "⚠ À vérifier",
|
||||||
|
"badges.check.fixAutoFit": "Activer la réduction auto",
|
||||||
|
"badges.check.showExample": "Voir un exemple",
|
||||||
|
"badges.check.ok": "Les {count} badges s'affichent sans texte coupé ni champ hors zone de découpe.",
|
||||||
|
"badges.check.estimate": "Estimation faite dans le navigateur : le PDF généré reste la référence.",
|
||||||
|
"badges.issue.where": "{field} ({face})",
|
||||||
|
"badges.issue.truncated.one": "{where} : texte coupé sur {count} badge.",
|
||||||
|
"badges.issue.truncated.other": "{where} : texte coupé sur {count} badges.",
|
||||||
|
"badges.issue.reduced.one": "{where} : réduit automatiquement sur {count} badge, jusqu'à {size} pt.",
|
||||||
|
"badges.issue.reduced.other": "{where} : réduit automatiquement sur {count} badges, jusqu'à {size} pt.",
|
||||||
|
"badges.issue.margin": "{where} : touche la marge de découpe (1,5 mm), risque d'être rogné.",
|
||||||
|
"badges.issue.contrast": "{where} : texte et fond de zone trop proches, illisible.",
|
||||||
|
"badges.issue.noPhoto.one": "{count} bénévole n'a pas de photo : ses initiales seront imprimées à la place.",
|
||||||
|
"badges.issue.noPhoto.other": "{count} bénévoles n'ont pas de photo : leurs initiales seront imprimées à la place.",
|
||||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||||
"badge.empty_image": "L'image est vide.",
|
"badge.empty_image": "L'image est vide.",
|
||||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||||
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
|
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois par face.",
|
||||||
|
"badge.invalid_color": "Couleur invalide (format attendu : #rrggbb).",
|
||||||
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
||||||
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
|
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
|
||||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||||
|
|||||||
@@ -1,158 +1,21 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import { useGenerateBadges, useVolunteers } from '@/api/hooks';
|
||||||
useBadgeImageUrl,
|
|
||||||
useBadgeTemplate,
|
|
||||||
useGenerateBadges,
|
|
||||||
useSetBadgeFields,
|
|
||||||
useSetBadgeSize,
|
|
||||||
useUploadBadgeBackImage,
|
|
||||||
useUploadBadgeFrontImage,
|
|
||||||
useVolunteers,
|
|
||||||
} from '@/api/hooks';
|
|
||||||
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
|
|
||||||
import { ApiError } from '@/api';
|
import { ApiError } from '@/api';
|
||||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||||
import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
import { Btn, Chip, Panel, PanelHeader } from '@/components/ui';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
|
||||||
|
|
||||||
// Le format du badge est dérivé de l'image de fond du recto (voir
|
import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab';
|
||||||
// `backend/app/badges/pdf.py:image_size_mm`) — CR80 (85,6 × 54 mm) tant
|
|
||||||
// qu'aucune image n'est importée. `SCALE` fixe la taille d'édition à
|
|
||||||
// l'écran ; les coordonnées enregistrées restent en mm.
|
|
||||||
const DEFAULT_BADGE_W_MM = 85.6;
|
|
||||||
const DEFAULT_BADGE_H_MM = 54;
|
|
||||||
const SCALE = 4.6; // px par mm à l'écran (au plus)
|
|
||||||
// Marges horizontales autour de l'aperçu (page + panneau + bordures), pour
|
|
||||||
// le faire tenir dans un écran étroit.
|
|
||||||
const PREVIEW_GUTTER_PX = 90;
|
|
||||||
|
|
||||||
/** Échelle d'édition : `SCALE`, réduite sur un écran trop étroit pour un
|
// L'onglet « Personnaliser » (éditeur recto/verso, aperçu avec les vraies
|
||||||
* badge à pleine taille (téléphone, tablette en portrait). Les coordonnées
|
// données, contrôle avant génération) vit dans `./badges/`.
|
||||||
* enregistrées restent en mm, seule la conversion px ↔ mm suit l'échelle. */
|
|
||||||
function useEditorScale(badgeWidthMm: number): number {
|
|
||||||
const [viewport, setViewport] = useState(() =>
|
|
||||||
typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth,
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
const onResize = () => setViewport(window.innerWidth);
|
|
||||||
window.addEventListener('resize', onResize);
|
|
||||||
return () => window.removeEventListener('resize', onResize);
|
|
||||||
}, []);
|
|
||||||
return Math.min(SCALE, Math.max(1, (viewport - PREVIEW_GUTTER_PX) / badgeWidthMm));
|
|
||||||
}
|
|
||||||
|
|
||||||
type FieldState = BadgeFieldIn & { enabled: boolean };
|
|
||||||
|
|
||||||
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> = {
|
|
||||||
FULL_NAME: 'badges.field.fullName.placeholder',
|
|
||||||
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
|
|
||||||
// verticalement pour ne pas se superposer (repères pour un badge CR80 ;
|
|
||||||
// l'utilisateur ajuste ensuite à la souris pour son propre format). Postes
|
|
||||||
// affectés sur plusieurs lignes (un par créneau, avec horaire) : plus haut
|
|
||||||
// que les autres champs.
|
|
||||||
const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = {
|
|
||||||
FULL_NAME: {
|
|
||||||
x_mm: 5, y_mm: 4, width_mm: 70, height_mm: 10,
|
|
||||||
font_size: 13, align: 'LEFT', bold: true, color: '#000000',
|
|
||||||
},
|
|
||||||
POSITIONS: {
|
|
||||||
x_mm: 5, y_mm: 15, width_mm: 70, height_mm: 22,
|
|
||||||
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
|
|
||||||
},
|
|
||||||
CATEGORY: {
|
|
||||||
x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 7,
|
|
||||||
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
|
|
||||||
},
|
|
||||||
ROLE: {
|
|
||||||
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 {
|
|
||||||
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> {
|
|
||||||
const byType = new Map(saved.map((f) => [f.field_type, f]));
|
|
||||||
const out = {} as Record<BadgeFieldType, FieldState>;
|
|
||||||
for (const type of FIELD_TYPES) {
|
|
||||||
const existing = byType.get(type);
|
|
||||||
out[type] = existing
|
|
||||||
? { ...existing, enabled: true }
|
|
||||||
: { field_type: type, enabled: false, ...FIELD_DEFAULTS[type] };
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BadgesPage() {
|
export function BadgesPage() {
|
||||||
const [tab, setTab] = useState<'customize' | 'generate'>('customize');
|
const [tab, setTab] = useState<'customize' | 'generate'>('customize');
|
||||||
|
|
||||||
return (
|
const tabBar = (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b-2 border-ink bg-sand px-4 py-[9px]">
|
|
||||||
<span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span>
|
<span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span>
|
||||||
<Chip active={tab === 'customize'} onClick={() => setTab('customize')}>
|
<Chip active={tab === 'customize'} onClick={() => setTab('customize')}>
|
||||||
{t('badges.tab.customize')}
|
{t('badges.tab.customize')}
|
||||||
@@ -160,518 +23,21 @@ export function BadgesPage() {
|
|||||||
<Chip active={tab === 'generate'} onClick={() => setTab('generate')}>
|
<Chip active={tab === 'generate'} onClick={() => setTab('generate')}>
|
||||||
{t('badges.tab.generate')}
|
{t('badges.tab.generate')}
|
||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</>
|
||||||
|
|
||||||
{tab === 'customize' ? <BadgeCustomizeTab /> : <BadgeGenerateTab />}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
|
||||||
// Personnalisation //
|
|
||||||
// --------------------------------------------------------------------------- //
|
|
||||||
function BadgeCustomizeTab() {
|
|
||||||
const template = useBadgeTemplate();
|
|
||||||
const frontUrl = useBadgeImageUrl(
|
|
||||||
'front', template.data?.has_front_image ?? false, template.dataUpdatedAt,
|
|
||||||
);
|
|
||||||
const backUrl = useBadgeImageUrl(
|
|
||||||
'back', template.data?.has_back_image ?? false, template.dataUpdatedAt,
|
|
||||||
);
|
|
||||||
const uploadFront = useUploadBadgeFrontImage();
|
|
||||||
const uploadBack = useUploadBadgeBackImage();
|
|
||||||
const setFields = useSetBadgeFields();
|
|
||||||
const setSize = useSetBadgeSize();
|
|
||||||
|
|
||||||
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
|
|
||||||
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
|
|
||||||
|
|
||||||
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 <Loading />;
|
|
||||||
if (template.error) return <ErrorNote error={template.error} />;
|
|
||||||
|
|
||||||
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
|
|
||||||
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 payload: BadgeFieldIn[] = FIELD_TYPES.filter((type) => fields[type].enabled).map(
|
|
||||||
(type) => {
|
|
||||||
const f = fields[type];
|
|
||||||
return {
|
|
||||||
field_type: f.field_type,
|
|
||||||
x_mm: f.x_mm,
|
|
||||||
y_mm: f.y_mm,
|
|
||||||
width_mm: f.width_mm,
|
|
||||||
height_mm: f.height_mm,
|
|
||||||
font_size: f.font_size,
|
|
||||||
align: f.align,
|
|
||||||
bold: f.bold,
|
|
||||||
color: f.color,
|
|
||||||
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
setFields.mutate({ fields: payload });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4 lg:flex-row lg:items-start">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex flex-none flex-col gap-4">
|
{tab === 'customize' ? (
|
||||||
<Panel>
|
<BadgeCustomizeTab tabBar={tabBar} />
|
||||||
<PanelHeader>
|
|
||||||
{t('badges.front')}
|
|
||||||
<HelpTip text={t('badges.front.help')} className="ml-[6px]" />
|
|
||||||
</PanelHeader>
|
|
||||||
<div className="p-3">
|
|
||||||
<ImageUploadRow
|
|
||||||
url={frontUrl}
|
|
||||||
pending={uploadFront.isPending}
|
|
||||||
error={uploadFront.error}
|
|
||||||
onPick={(file) => uploadFront.mutate(file)}
|
|
||||||
/>
|
|
||||||
<BadgeSizeControl
|
|
||||||
badgeW={badgeW}
|
|
||||||
badgeH={badgeH}
|
|
||||||
pending={setSize.isPending}
|
|
||||||
onApply={(width_mm, height_mm) =>
|
|
||||||
setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
|
|
||||||
style={{
|
|
||||||
width: badgeW * scale,
|
|
||||||
height: badgeH * scale,
|
|
||||||
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
|
|
||||||
}}
|
|
||||||
onPointerDown={() => setSelected(null)}
|
|
||||||
>
|
|
||||||
{FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => (
|
|
||||||
<FieldBox
|
|
||||||
key={type}
|
|
||||||
field={fields[type]}
|
|
||||||
badgeW={badgeW}
|
|
||||||
badgeH={badgeH}
|
|
||||||
scale={scale}
|
|
||||||
selected={selected === type}
|
|
||||||
onSelect={() => setSelected(type)}
|
|
||||||
onChange={(patch) => updateField(type, patch)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60">
|
|
||||||
{t('badges.editor.hint', {
|
|
||||||
width: Math.round(badgeW * 10) / 10,
|
|
||||||
height: Math.round(badgeH * 10) / 10,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel>
|
|
||||||
<PanelHeader>
|
|
||||||
{t('badges.back')}
|
|
||||||
<HelpTip text={t('badges.back.help')} className="ml-[6px]" />
|
|
||||||
</PanelHeader>
|
|
||||||
<div className="p-3">
|
|
||||||
<ImageUploadRow
|
|
||||||
url={backUrl}
|
|
||||||
pending={uploadBack.isPending}
|
|
||||||
error={uploadBack.error}
|
|
||||||
onPick={(file) => uploadBack.mutate(file)}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
|
|
||||||
style={{ width: badgeW * scale, height: badgeH * scale, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Panel className="min-w-0 flex-1 lg:min-w-[320px]">
|
|
||||||
<PanelHeader>{t('badges.fields')}</PanelHeader>
|
|
||||||
<div className="flex flex-col divide-y-2 divide-ink/10">
|
|
||||||
{FIELD_TYPES.map((type) => (
|
|
||||||
<FieldEditorRow
|
|
||||||
key={type}
|
|
||||||
type={type}
|
|
||||||
field={fields[type]}
|
|
||||||
active={selected === type}
|
|
||||||
onFocus={() => setSelected(type)}
|
|
||||||
onChange={(patch) => updateField(type, patch)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
|
|
||||||
<Btn onClick={save} disabled={setFields.isPending}>
|
|
||||||
{setFields.isPending ? t('badges.saving') : t('badges.save')}
|
|
||||||
</Btn>
|
|
||||||
{setFields.isSuccess ? (
|
|
||||||
<span className="font-mono text-[9.5px] font-bold text-green">{t('badges.saved')}</span>
|
|
||||||
) : null}
|
|
||||||
{setFields.isError ? (
|
|
||||||
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
|
|
||||||
{errorMessage(setFields.error)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ImageUploadRow({
|
|
||||||
url,
|
|
||||||
pending,
|
|
||||||
error,
|
|
||||||
onPick,
|
|
||||||
}: {
|
|
||||||
url: string | null;
|
|
||||||
pending: boolean;
|
|
||||||
error: unknown;
|
|
||||||
onPick: (file: File) => void;
|
|
||||||
}) {
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-[10px]">
|
|
||||||
<Btn variant="ghost" onClick={() => inputRef.current?.click()} disabled={pending}>
|
|
||||||
{pending ? t('badges.uploading') : url ? t('badges.image.replace') : t('badges.image.choose')}
|
|
||||||
</Btn>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/png,image/jpeg"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) onPick(file);
|
|
||||||
e.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{error ? (
|
|
||||||
<span className="font-mono text-[9.5px] font-bold text-orange-dark">{errorMessage(error)}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de
|
|
||||||
* l'image importée). Sert à agrandir un badge devenu minuscule parce que
|
|
||||||
* l'image de fond n'était pas à une résolution d'impression (300 dpi
|
|
||||||
* supposés à l'import, voir `useBadgeTemplate`). */
|
|
||||||
function BadgeSizeControl({
|
|
||||||
badgeW,
|
|
||||||
badgeH,
|
|
||||||
pending,
|
|
||||||
onApply,
|
|
||||||
}: {
|
|
||||||
badgeW: number;
|
|
||||||
badgeH: number;
|
|
||||||
pending: boolean;
|
|
||||||
onApply: (width_mm: number, height_mm: number) => void;
|
|
||||||
}) {
|
|
||||||
const rounded = Math.round(badgeW * 10) / 10;
|
|
||||||
const [width, setWidth] = useState(String(rounded));
|
|
||||||
|
|
||||||
useEffect(() => setWidth(String(rounded)), [rounded]);
|
|
||||||
|
|
||||||
const apply = () => {
|
|
||||||
const w = Number(width);
|
|
||||||
if (!Number.isFinite(w) || w <= 0) {
|
|
||||||
setWidth(String(rounded));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const ratio = badgeH / badgeW;
|
|
||||||
onApply(w, Math.round(w * ratio * 10) / 10);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 flex items-center gap-[8px]">
|
|
||||||
<FieldLabel>{t('badges.size.width')}</FieldLabel>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={5}
|
|
||||||
step={1}
|
|
||||||
value={width}
|
|
||||||
disabled={pending}
|
|
||||||
onChange={(e) => setWidth(e.target.value)}
|
|
||||||
onBlur={apply}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') apply();
|
|
||||||
}}
|
|
||||||
className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
|
|
||||||
/>
|
|
||||||
<span className="font-mono text-[9.5px] opacity-60">
|
|
||||||
{t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldBox({
|
|
||||||
field,
|
|
||||||
badgeW,
|
|
||||||
badgeH,
|
|
||||||
scale,
|
|
||||||
selected,
|
|
||||||
onSelect,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
field: FieldState;
|
|
||||||
badgeW: number;
|
|
||||||
badgeH: number;
|
|
||||||
scale: number;
|
|
||||||
selected: boolean;
|
|
||||||
onSelect: () => void;
|
|
||||||
onChange: (patch: Partial<FieldState>) => void;
|
|
||||||
}) {
|
|
||||||
const dragRef = useRef<{ startXMm: number; startYMm: number; pointerXPx: number; pointerYPx: number } | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const resizeRef = useRef<{ startWMm: number; startHMm: number; pointerXPx: number; pointerYPx: number } | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const startDrag = (e: React.PointerEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelect();
|
|
||||||
(e.target as Element).setPointerCapture(e.pointerId);
|
|
||||||
dragRef.current = { startXMm: field.x_mm, startYMm: field.y_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragMove = (e: React.PointerEvent) => {
|
|
||||||
if (!dragRef.current) return;
|
|
||||||
const d = dragRef.current;
|
|
||||||
const dxMm = (e.clientX - d.pointerXPx) / scale;
|
|
||||||
const dyMm = (e.clientY - d.pointerYPx) / scale;
|
|
||||||
onChange({
|
|
||||||
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)),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const startResize = (e: React.PointerEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelect();
|
|
||||||
(e.target as Element).setPointerCapture(e.pointerId);
|
|
||||||
resizeRef.current = { startWMm: field.width_mm, startHMm: field.height_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
|
|
||||||
};
|
|
||||||
|
|
||||||
const onResizeMove = (e: React.PointerEvent) => {
|
|
||||||
if (!resizeRef.current) return;
|
|
||||||
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, Math.min(10, maxW), maxW),
|
|
||||||
height_mm: clamp(d.startHMm + dhMm, Math.min(5, maxH), maxH),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onPointerDown={startDrag}
|
|
||||||
onPointerMove={onDragMove}
|
|
||||||
onPointerUp={() => (dragRef.current = null)}
|
|
||||||
className={cx(
|
|
||||||
'absolute flex cursor-move touch-none items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
|
|
||||||
selected ? 'border-blue-strong' : 'border-ink/40',
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
left: field.x_mm * scale,
|
|
||||||
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 === 'POSITIONS' ? (
|
|
||||||
<PositionsTablePreview field={field} scale={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"
|
<div className="flex flex-wrap items-center gap-2 border-b-[3px] border-ink bg-sand px-3 py-2 sm:px-4">
|
||||||
style={{
|
{tabBar}
|
||||||
fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge
|
</div>
|
||||||
fontWeight: field.bold ? 800 : 400,
|
<BadgeGenerateTab />
|
||||||
color: field.color,
|
</>
|
||||||
width: '100%',
|
|
||||||
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{field.field_type === 'FREE_TEXT' && field.text?.trim()
|
|
||||||
? field.text
|
|
||||||
: t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<div
|
|
||||||
onPointerDown={startResize}
|
|
||||||
onPointerMove={onResizeMove}
|
|
||||||
onPointerUp={() => (resizeRef.current = null)}
|
|
||||||
className="absolute bottom-0 right-0 h-[14px] w-[14px] cursor-nwse-resize touch-none lg:h-[10px] lg:w-[10px] border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Aperçu du champ « postes » : le jour en en-tête, puis une ligne
|
|
||||||
* horaire / poste par créneau, comme dans le PDF
|
|
||||||
* (`backend/app/badges/pdf.py:_draw_table`), avec des lignes d'exemple. */
|
|
||||||
function PositionsTablePreview({ field, scale }: { field: FieldState; scale: number }) {
|
|
||||||
const fontSize = field.font_size * (scale / 2.83); // pt -> px approx. à l'échelle du badge
|
|
||||||
const sections = [1, 2].map((n) => ({
|
|
||||||
title: t(`badges.field.positions.sample${n}.day`),
|
|
||||||
rows: t(`badges.field.positions.sample${n}.rows`)
|
|
||||||
.split('\n')
|
|
||||||
.map((row) => row.split('|')),
|
|
||||||
}));
|
|
||||||
const cell = 'border px-[0.8mm] py-[0.5mm] text-left align-top';
|
|
||||||
const border = { borderColor: field.color };
|
|
||||||
return (
|
|
||||||
<table
|
|
||||||
className="pointer-events-none w-full border-collapse font-sans leading-tight"
|
|
||||||
style={{ fontSize, color: field.color }}
|
|
||||||
>
|
|
||||||
{sections.map((section) => (
|
|
||||||
<tbody key={section.title}>
|
|
||||||
<tr>
|
|
||||||
<th colSpan={2} className={cell} style={border}>
|
|
||||||
{section.title}
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
{section.rows.map(([time, position]) => (
|
|
||||||
<tr key={time}>
|
|
||||||
<td className={cx(cell, 'w-px whitespace-nowrap')} style={border}>
|
|
||||||
{time}
|
|
||||||
</td>
|
|
||||||
<td className={cell} style={border}>
|
|
||||||
{position}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
))}
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldEditorRow({
|
|
||||||
type,
|
|
||||||
field,
|
|
||||||
active,
|
|
||||||
onFocus,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
type: BadgeFieldType;
|
|
||||||
field: FieldState;
|
|
||||||
active: boolean;
|
|
||||||
onFocus: () => void;
|
|
||||||
onChange: (patch: Partial<FieldState>) => void;
|
|
||||||
}) {
|
|
||||||
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"
|
|
||||||
checked={field.enabled}
|
|
||||||
onChange={(e) => onChange({ enabled: e.target.checked })}
|
|
||||||
className="h-[16px] w-[16px] accent-ink"
|
|
||||||
/>
|
|
||||||
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
|
|
||||||
{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 && 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
|
|
||||||
(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>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={6}
|
|
||||||
max={36}
|
|
||||||
value={field.font_size}
|
|
||||||
onChange={(e) => onChange({ font_size: Number(e.target.value) })}
|
|
||||||
className="w-[52px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-[4px]">
|
|
||||||
{(['LEFT', 'CENTER', 'RIGHT'] as BadgeAlign[]).map((align) => (
|
|
||||||
<button
|
|
||||||
key={align}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange({ align })}
|
|
||||||
className={cx(
|
|
||||||
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-mono text-[10px] font-bold',
|
|
||||||
field.align === align ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{align === 'LEFT' ? '⟵' : align === 'CENTER' ? '↔' : '⟶'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange({ bold: !field.bold })}
|
|
||||||
className={cx(
|
|
||||||
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-display text-[11px] font-black',
|
|
||||||
field.bold ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
G
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={field.color}
|
|
||||||
onChange={(e) => onChange({ color: e.target.value })}
|
|
||||||
className="h-[24px] w-[30px] cursor-pointer border-2 border-ink p-0"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -734,6 +100,18 @@ function BadgeGenerateTab() {
|
|||||||
</Chip>
|
</Chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Un bénévole peut demander plusieurs exemplaires (fiche,
|
||||||
|
« Nombre de badges ») : le total imprimé peut dépasser le
|
||||||
|
nombre de bénévoles. */}
|
||||||
|
<span className="font-mono text-[10px] opacity-70">
|
||||||
|
{t('badges.generate.total', {
|
||||||
|
count: (mode === 'pick' ? sorted.filter((v) => picked.has(v.id)) : sorted).reduce(
|
||||||
|
(sum, v) => sum + v.badge_count,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
|
||||||
{mode === 'pick' ? (
|
{mode === 'pick' ? (
|
||||||
<div className="max-h-[320px] overflow-auto border-2 border-ink">
|
<div className="max-h-[320px] overflow-auto border-2 border-ink">
|
||||||
{sorted.map((v) => (
|
{sorted.map((v) => (
|
||||||
@@ -748,6 +126,9 @@ function BadgeGenerateTab() {
|
|||||||
className="h-[14px] w-[14px] accent-ink"
|
className="h-[14px] w-[14px] accent-ink"
|
||||||
/>
|
/>
|
||||||
{v.first_name} {v.last_name}
|
{v.first_name} {v.last_name}
|
||||||
|
{v.badge_count > 1 ? (
|
||||||
|
<span className="ml-auto opacity-60">× {v.badge_count}</span>
|
||||||
|
) : null}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import type { BadgeFace, VolunteerRead } from '@/api/schema';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CELL_PAD_X_MM,
|
||||||
|
CELL_PAD_Y_MM,
|
||||||
|
FIRST_BASELINE,
|
||||||
|
LINE_HEIGHT,
|
||||||
|
MM_PER_PT,
|
||||||
|
type TableLayout,
|
||||||
|
type TextLayout,
|
||||||
|
} from './layout';
|
||||||
|
import {
|
||||||
|
FACE_LABEL_KEY,
|
||||||
|
fmt,
|
||||||
|
initials,
|
||||||
|
isVerticallyCentered,
|
||||||
|
renderField,
|
||||||
|
SAFE_MARGIN_MM,
|
||||||
|
type BadgeData,
|
||||||
|
type FieldState,
|
||||||
|
} from './model';
|
||||||
|
|
||||||
|
/** Fond d'une photo absente (initiales à la place), comme le PDF. */
|
||||||
|
export const PHOTO_PLACEHOLDER_BG = '#E7E0CE';
|
||||||
|
/** Pas d'accroche du glisser-déposer (mm). */
|
||||||
|
const DRAG_SNAP_MM = 0.5;
|
||||||
|
/** Plus petit cadre possible au redimensionnement (mm). */
|
||||||
|
const MIN_W_MM = 3;
|
||||||
|
const MIN_H_MM = 2;
|
||||||
|
/** Décalage entre la ligne de base d'une ligne CSS (interligne 1,25, police
|
||||||
|
* Helvetica/Arial) et celle du PDF (0,85 × taille sous le haut du cadre). */
|
||||||
|
const CSS_BASELINE = 0.97;
|
||||||
|
const FONT_STACK = 'Helvetica, Arial, sans-serif';
|
||||||
|
|
||||||
|
function snap(value: number): number {
|
||||||
|
return Math.round(value / DRAG_SNAP_MM) * DRAG_SNAP_MM;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BadgeCanvasProps {
|
||||||
|
face: BadgeFace;
|
||||||
|
fields: FieldState[];
|
||||||
|
data: BadgeData;
|
||||||
|
volunteer: VolunteerRead | null;
|
||||||
|
photoUrl: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
badgeW: number;
|
||||||
|
badgeH: number;
|
||||||
|
scale: number;
|
||||||
|
showGuides: boolean;
|
||||||
|
selectedType: string | null;
|
||||||
|
uploading: boolean;
|
||||||
|
onSelect: (field: FieldState) => void;
|
||||||
|
/** `record` : enregistrer l'état actuel dans l'historique d'annulation
|
||||||
|
* avant d'appliquer `patch` (une seule fois par glisser-déposer). */
|
||||||
|
onChange: (field: FieldState, patch: Partial<FieldState>, record: boolean) => void;
|
||||||
|
onDropImage: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aperçu d'une face du badge, éditable (glisser, redimensionner) et zone de
|
||||||
|
* dépôt de son image de fond. */
|
||||||
|
export function BadgeCanvas({
|
||||||
|
face,
|
||||||
|
fields,
|
||||||
|
data,
|
||||||
|
volunteer,
|
||||||
|
photoUrl,
|
||||||
|
imageUrl,
|
||||||
|
badgeW,
|
||||||
|
badgeH,
|
||||||
|
scale,
|
||||||
|
showGuides,
|
||||||
|
selectedType,
|
||||||
|
uploading,
|
||||||
|
onSelect,
|
||||||
|
onChange,
|
||||||
|
onDropImage,
|
||||||
|
}: BadgeCanvasProps) {
|
||||||
|
const [dropping, setDropping] = useState(false);
|
||||||
|
const shown = fields.filter((f) => f.enabled);
|
||||||
|
const isFaceSelected = shown.some((f) => f.field_type === selectedType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-[9px]">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'border-2 border-ink px-2 py-1 font-display text-[11px] font-black uppercase tracking-wider',
|
||||||
|
isFaceSelected ? 'bg-ink text-yellow' : 'bg-paper text-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t(FACE_LABEL_KEY[face])}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] opacity-70">
|
||||||
|
{t('badges.dims', { width: fmt(badgeW), height: fmt(badgeH) })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'relative flex-none select-none border-[3px] border-ink bg-sand-dark bg-contain bg-center bg-no-repeat shadow-brutal-lg',
|
||||||
|
dropping && 'outline outline-[3px] outline-offset-2 outline-blue-strong',
|
||||||
|
)}
|
||||||
|
// + 6 : bordure de 3 px de chaque côté (les champs se placent dans
|
||||||
|
// la boîte intérieure, à l'échelle exacte du badge).
|
||||||
|
style={{
|
||||||
|
width: badgeW * scale + 6,
|
||||||
|
height: badgeH * scale + 6,
|
||||||
|
backgroundImage: imageUrl ? `url(${imageUrl})` : undefined,
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
if (!e.dataTransfer.types.includes('Files')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
setDropping(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDropping(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDropping(false);
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) onDropImage(file);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Invitation à déposer une image : seulement sur une face encore
|
||||||
|
vide (elle passerait sous les champs sinon), ou par-dessus tout
|
||||||
|
pendant un dépôt / un envoi. */}
|
||||||
|
{uploading || dropping || (!imageUrl && shown.length === 0) ? (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'pointer-events-none absolute inset-0 flex items-center justify-center p-3 text-center font-mono text-[10px] font-medium leading-relaxed',
|
||||||
|
uploading || dropping ? 'z-20 bg-blue/80 text-ink' : 'text-ink/60',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{uploading
|
||||||
|
? t('badges.uploading')
|
||||||
|
: t(face === 'FRONT' ? 'badges.drop.front' : 'badges.drop.back')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showGuides ? (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute border-[1.5px] border-dashed border-yellow opacity-90"
|
||||||
|
style={{ inset: SAFE_MARGIN_MM * scale }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{shown.map((field) => (
|
||||||
|
<FieldBox
|
||||||
|
key={field.field_type}
|
||||||
|
field={field}
|
||||||
|
data={data}
|
||||||
|
volunteer={volunteer}
|
||||||
|
photoUrl={photoUrl}
|
||||||
|
badgeW={badgeW}
|
||||||
|
badgeH={badgeH}
|
||||||
|
scale={scale}
|
||||||
|
showGuides={showGuides}
|
||||||
|
selected={selectedType === field.field_type}
|
||||||
|
onSelect={() => onSelect(field)}
|
||||||
|
onChange={(patch, record) => onChange(field, patch, record)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldBox({
|
||||||
|
field,
|
||||||
|
data,
|
||||||
|
volunteer,
|
||||||
|
photoUrl,
|
||||||
|
badgeW,
|
||||||
|
badgeH,
|
||||||
|
scale,
|
||||||
|
showGuides,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
field: FieldState;
|
||||||
|
data: BadgeData;
|
||||||
|
volunteer: VolunteerRead | null;
|
||||||
|
photoUrl: string | null;
|
||||||
|
badgeW: number;
|
||||||
|
badgeH: number;
|
||||||
|
scale: number;
|
||||||
|
showGuides: boolean;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onChange: (patch: Partial<FieldState>, record: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const dragRef = useRef<{
|
||||||
|
mode: 'move' | 'resize';
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
px: number;
|
||||||
|
py: number;
|
||||||
|
moved: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const start = (mode: 'move' | 'resize') => (e: React.PointerEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onSelect();
|
||||||
|
(e.currentTarget as Element).setPointerCapture(e.pointerId);
|
||||||
|
dragRef.current = {
|
||||||
|
mode,
|
||||||
|
x: field.x_mm,
|
||||||
|
y: field.y_mm,
|
||||||
|
w: field.width_mm,
|
||||||
|
h: field.height_mm,
|
||||||
|
px: e.clientX,
|
||||||
|
py: e.clientY,
|
||||||
|
moved: false,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = (e: React.PointerEvent) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
const dx = (e.clientX - d.px) / scale;
|
||||||
|
const dy = (e.clientY - d.py) / scale;
|
||||||
|
if (!d.moved && Math.abs(dx) < 0.2 && Math.abs(dy) < 0.2) return;
|
||||||
|
const record = !d.moved;
|
||||||
|
d.moved = true;
|
||||||
|
if (d.mode === 'move') {
|
||||||
|
// Bornes jamais négatives : un champ plus grand que le badge reste
|
||||||
|
// saisissable (voir `fitToBadge`).
|
||||||
|
onChange(
|
||||||
|
{
|
||||||
|
x_mm: clamp(snap(d.x + dx), 0, Math.max(0, badgeW - d.w)),
|
||||||
|
y_mm: clamp(snap(d.y + dy), 0, Math.max(0, badgeH - d.h)),
|
||||||
|
},
|
||||||
|
record,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const maxW = Math.max(0, badgeW - d.x);
|
||||||
|
const maxH = Math.max(0, badgeH - d.y);
|
||||||
|
onChange(
|
||||||
|
{
|
||||||
|
width_mm: clamp(snap(d.w + dx), Math.min(MIN_W_MM, maxW), maxW),
|
||||||
|
height_mm: clamp(snap(d.h + dy), Math.min(MIN_H_MM, maxH), maxH),
|
||||||
|
},
|
||||||
|
record,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const end = (e: React.PointerEvent) => {
|
||||||
|
if (dragRef.current) e.stopPropagation();
|
||||||
|
dragRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendered = volunteer ? renderField(field, volunteer, data) : null;
|
||||||
|
const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null;
|
||||||
|
const truncated = layout?.truncated ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onPointerDown={start('move')}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={end}
|
||||||
|
onPointerCancel={end}
|
||||||
|
className="absolute cursor-move touch-none"
|
||||||
|
style={{
|
||||||
|
left: field.x_mm * scale,
|
||||||
|
top: field.y_mm * scale,
|
||||||
|
width: field.width_mm * scale,
|
||||||
|
height: field.height_mm * scale,
|
||||||
|
outline: selected
|
||||||
|
? '2px solid #2A6BFF'
|
||||||
|
: truncated
|
||||||
|
? '2px dashed #FF5C39'
|
||||||
|
: showGuides
|
||||||
|
? '1px dashed rgba(20,17,14,.45)'
|
||||||
|
: 'none',
|
||||||
|
// 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
|
||||||
|
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
|
||||||
|
) : rendered?.kind === 'table' ? (
|
||||||
|
<TableContent field={field} layout={rendered.layout} scale={scale} />
|
||||||
|
) : rendered?.kind === 'text' ? (
|
||||||
|
<TextContent field={field} layout={rendered.layout} scale={scale} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{truncated ? (
|
||||||
|
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none">
|
||||||
|
{t('badges.preview.truncated')}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{selected ? (
|
||||||
|
<>
|
||||||
|
<span className="pointer-events-none absolute -bottom-[19px] left-0 z-10 whitespace-nowrap bg-blue-strong px-[5px] py-[2px] font-mono text-[9px] font-semibold leading-tight text-white">
|
||||||
|
{t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })}
|
||||||
|
{layout?.reduced
|
||||||
|
? t('badges.preview.reducedTo', { size: fmt(layout.fontSize) })
|
||||||
|
: null}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
onPointerDown={start('resize')}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={end}
|
||||||
|
onPointerCancel={end}
|
||||||
|
className="absolute -bottom-[7px] -right-[7px] h-[15px] w-[15px] cursor-nwse-resize touch-none border-2 border-ink bg-yellow lg:h-[13px] lg:w-[13px]"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextContent({ field, layout, scale }: { field: FieldState; layout: TextLayout; scale: number }) {
|
||||||
|
const fontPx = layout.fontSize * MM_PER_PT * scale;
|
||||||
|
const lineHeightPx = fontPx * LINE_HEIGHT;
|
||||||
|
// Bandeaux (texte libre, rôle) centrés verticalement, les autres en haut —
|
||||||
|
// même calcul que le PDF : bloc = interlignes + une hauteur de caractère.
|
||||||
|
const blockPx = Math.max(0, layout.visible.length - 1) * lineHeightPx + fontPx;
|
||||||
|
const boxPx = field.height_mm * scale;
|
||||||
|
const offset = isVerticallyCentered(field.field_type) ? Math.max(0, (boxPx - blockPx) / 2) : 0;
|
||||||
|
const top = offset + (FIRST_BASELINE - CSS_BASELINE) * fontPx;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none relative h-full w-full overflow-hidden"
|
||||||
|
style={{ background: field.bg_color ?? 'transparent' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0"
|
||||||
|
style={{
|
||||||
|
top,
|
||||||
|
color: field.color,
|
||||||
|
fontFamily: FONT_STACK,
|
||||||
|
fontSize: fontPx,
|
||||||
|
lineHeight: `${lineHeightPx}px`,
|
||||||
|
fontWeight: field.bold ? 700 : 400,
|
||||||
|
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layout.visible.map((line, i) => (
|
||||||
|
<div key={i} className="whitespace-pre">
|
||||||
|
{line || ' '}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tableau des créneaux, mêmes colonnes, hauteurs de ligne et lignes
|
||||||
|
* retenues que le PDF (`_draw_table`). */
|
||||||
|
function TableContent({ field, layout, scale }: { field: FieldState; layout: TableLayout; scale: number }) {
|
||||||
|
const fontPx = layout.fontSize * MM_PER_PT * scale;
|
||||||
|
const lineHeightPx = fontPx * LINE_HEIGHT;
|
||||||
|
const rule = `${Math.max(0.5, 0.4 * MM_PER_PT * scale)}px solid ${field.color}`;
|
||||||
|
const padX = CELL_PAD_X_MM * scale;
|
||||||
|
const padY = CELL_PAD_Y_MM * scale;
|
||||||
|
const fullWidth = layout.widthsMm.reduce((a, b) => a + b, 0) * scale;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none h-full w-full overflow-hidden"
|
||||||
|
style={{ background: field.bg_color ?? 'transparent' }}
|
||||||
|
>
|
||||||
|
{layout.rows.length ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: fullWidth,
|
||||||
|
border: rule,
|
||||||
|
color: field.color,
|
||||||
|
fontFamily: FONT_STACK,
|
||||||
|
fontSize: fontPx,
|
||||||
|
lineHeight: `${lineHeightPx}px`,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layout.rows.map((row, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex"
|
||||||
|
style={{
|
||||||
|
height: row.heightMm * scale,
|
||||||
|
borderTop: i > 0 ? rule : undefined,
|
||||||
|
fontWeight: row.isTitle ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.cells.map((lines, col) => (
|
||||||
|
<div
|
||||||
|
key={col}
|
||||||
|
className="flex-none overflow-hidden"
|
||||||
|
style={{
|
||||||
|
width: row.isTitle ? '100%' : layout.widthsMm[col] * scale,
|
||||||
|
padding: `${padY}px ${padX}px`,
|
||||||
|
borderLeft: col > 0 ? rule : undefined,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lines.map((line, j) => (
|
||||||
|
<div key={j} className="whitespace-pre">
|
||||||
|
{line || ' '}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhotoContent({
|
||||||
|
field,
|
||||||
|
volunteer,
|
||||||
|
photoUrl,
|
||||||
|
scale,
|
||||||
|
}: {
|
||||||
|
field: FieldState;
|
||||||
|
volunteer: VolunteerRead | null;
|
||||||
|
photoUrl: string | null;
|
||||||
|
scale: number;
|
||||||
|
}) {
|
||||||
|
const radius = field.photo_shape === 'OVAL' ? '50%' : 0;
|
||||||
|
if (volunteer?.has_photo && photoUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={photoUrl}
|
||||||
|
alt=""
|
||||||
|
draggable={false}
|
||||||
|
className="pointer-events-none h-full w-full object-cover"
|
||||||
|
style={{ borderRadius: radius }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none flex h-full w-full items-center justify-center font-bold"
|
||||||
|
style={{
|
||||||
|
borderRadius: radius,
|
||||||
|
background: PHOTO_PLACEHOLDER_BG,
|
||||||
|
color: field.color,
|
||||||
|
fontFamily: FONT_STACK,
|
||||||
|
// Comme le PDF : 0,28 × la largeur du cadre, 4 pt au moins.
|
||||||
|
fontSize: Math.max(4 * MM_PER_PT, field.width_mm * 0.28) * scale,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{volunteer ? initials(volunteer) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
|||||||
|
import { useEffect, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type { BadgeAlign } from '@/api/schema';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
|
import { contrastRatio, FONT_STEP_PT, MAX_FONT_PT, MIN_CONTRAST, MIN_FONT_PT, MM_PER_PT } from './layout';
|
||||||
|
import { FACE_LABEL_KEY, FIELD_LABEL_KEY, fitToBadge, fmt, round1, type FieldState } from './model';
|
||||||
|
|
||||||
|
const TEXT_COLORS: Array<{ hex: string; key: string }> = [
|
||||||
|
{ hex: '#FFFFFF', key: 'badges.color.white' },
|
||||||
|
{ hex: '#14110E', key: 'badges.color.black' },
|
||||||
|
{ hex: '#FFD23F', key: 'badges.color.yellow' },
|
||||||
|
{ hex: '#FF5C39', key: 'badges.color.coral' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const BG_COLORS: Array<{ hex: string | null; key: string }> = [
|
||||||
|
{ hex: null, key: 'badges.color.none' },
|
||||||
|
{ hex: '#FFFFFF', key: 'badges.color.white' },
|
||||||
|
{ hex: '#14110E', key: 'badges.color.black' },
|
||||||
|
{ hex: '#FFD23F', key: 'badges.color.yellow' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALIGNS: Array<{ value: BadgeAlign; key: string; items: string }> = [
|
||||||
|
{ value: 'LEFT', key: 'badges.align.left', items: 'items-start' },
|
||||||
|
{ value: 'CENTER', key: 'badges.align.center', items: 'items-center' },
|
||||||
|
{ value: 'RIGHT', key: 'badges.align.right', items: 'items-end' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function sameColor(a: string | null, b: string | null): boolean {
|
||||||
|
return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, aside, children }: { title: string; aside?: ReactNode; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-[6px] flex items-baseline">
|
||||||
|
<span className="font-display text-[9.5px] font-extrabold uppercase tracking-widest">{title}</span>
|
||||||
|
{aside ? <span className="ml-auto font-mono text-[9px] uppercase opacity-60">{aside}</span> : null}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segment({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={cx(
|
||||||
|
'cursor-pointer border-0 transition-colors',
|
||||||
|
active ? 'bg-ink text-yellow' : 'bg-cream text-ink hover:bg-yellow-soft',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Swatch({
|
||||||
|
hex,
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
hex: string | null;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-pressed={active}
|
||||||
|
className="flex cursor-pointer flex-col items-center gap-1 border-0 bg-transparent p-0"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'flex h-[30px] w-full items-center justify-center border-2 border-ink font-display text-[12px] font-black',
|
||||||
|
hex === null && 'bg-hatch-sand',
|
||||||
|
hex === '#14110E' ? 'text-yellow' : 'text-ink',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
background: hex ?? undefined,
|
||||||
|
boxShadow: active ? '0 0 0 3px #2A6BFF' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{active ? '✓' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[9.5px] font-semibold">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saisie d'une coordonnée en mm, appliquée à la validation (sortie du
|
||||||
|
* champ ou Entrée) pour ne pas bousculer la saisie en cours. */
|
||||||
|
function MmInput({ label, value, onCommit }: { label: string; value: number; onCommit: (v: number) => void }) {
|
||||||
|
const [text, setText] = useState(fmt(value));
|
||||||
|
useEffect(() => setText(fmt(value)), [value]);
|
||||||
|
const commit = () => {
|
||||||
|
const n = Number(text.replace(',', '.'));
|
||||||
|
if (Number.isFinite(n) && round1(n) !== round1(value)) onCommit(Math.max(0, n));
|
||||||
|
else setText(fmt(value));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<label className="flex items-stretch border-2 border-ink bg-cream">
|
||||||
|
<span className="flex w-[30px] flex-none items-center justify-center border-r-2 border-ink bg-sand font-display text-[11px] font-extrabold">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
inputMode="decimal"
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') commit();
|
||||||
|
}}
|
||||||
|
className="min-w-0 flex-1 border-0 bg-transparent px-[7px] py-2 font-mono text-[13px] font-semibold"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BadgeInspector({
|
||||||
|
field,
|
||||||
|
badgeW,
|
||||||
|
badgeH,
|
||||||
|
effectiveFontSize,
|
||||||
|
photoCount,
|
||||||
|
volunteerCount,
|
||||||
|
onChange,
|
||||||
|
onHide,
|
||||||
|
}: {
|
||||||
|
field: FieldState;
|
||||||
|
badgeW: number;
|
||||||
|
badgeH: number;
|
||||||
|
/** Taille réellement utilisée sur le badge affiché (réduction auto). */
|
||||||
|
effectiveFontSize: number | null;
|
||||||
|
photoCount: number;
|
||||||
|
volunteerCount: number;
|
||||||
|
onChange: (patch: Partial<FieldState>) => void;
|
||||||
|
onHide: () => void;
|
||||||
|
}) {
|
||||||
|
const isPhoto = field.field_type === 'PHOTO';
|
||||||
|
const lowContrast = Boolean(field.bg_color) && contrastRatio(field.color, field.bg_color as string) < MIN_CONTRAST;
|
||||||
|
const reduced = effectiveFontSize !== null && effectiveFontSize < field.font_size;
|
||||||
|
const setGeometry = (patch: Partial<FieldState>) =>
|
||||||
|
onChange(fitToBadge({ ...field, ...patch }, badgeW, badgeH));
|
||||||
|
const fontSize = (delta: number) =>
|
||||||
|
onChange({
|
||||||
|
font_size: Math.min(MAX_FONT_PT, Math.max(MIN_FONT_PT, round1(field.font_size + delta))),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bg-ink px-3 py-[10px] text-cream">
|
||||||
|
<div className="font-mono text-[9.5px] uppercase text-yellow">
|
||||||
|
{t('badges.inspector.kicker', { face: t(FACE_LABEL_KEY[field.face]) })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[5px] font-display text-[17px] font-black leading-tight tracking-tight">
|
||||||
|
{t(FIELD_LABEL_KEY[field.field_type])}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-[15px] p-3">
|
||||||
|
{field.field_type === 'FREE_TEXT' ? (
|
||||||
|
<label className="block">
|
||||||
|
<span className="mb-[6px] block font-display text-[9.5px] font-extrabold uppercase tracking-widest">
|
||||||
|
{t('badges.inspector.content')}
|
||||||
|
</span>
|
||||||
|
<textarea
|
||||||
|
value={field.text ?? ''}
|
||||||
|
onChange={(e) => onChange({ text: e.target.value })}
|
||||||
|
rows={2}
|
||||||
|
placeholder={t('badges.field.freeText.input')}
|
||||||
|
className="w-full resize-y border-2 border-ink bg-cream p-2 font-display text-[13px] font-semibold leading-snug"
|
||||||
|
/>
|
||||||
|
<span className="mt-1 block font-mono text-[9px] leading-normal opacity-60">
|
||||||
|
{t('badges.inspector.contentHelp')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isPhoto ? (
|
||||||
|
<>
|
||||||
|
<Section
|
||||||
|
title={t('badges.inspector.fontSize')}
|
||||||
|
aside={
|
||||||
|
reduced
|
||||||
|
? t('badges.inspector.reducedHere', { size: fmt(effectiveFontSize as number) })
|
||||||
|
: t('badges.inspector.capHeight', { mm: fmt(field.font_size * MM_PER_PT) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-stretch border-2 border-ink">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fontSize(-FONT_STEP_PT)}
|
||||||
|
aria-label={t('badges.inspector.smaller')}
|
||||||
|
className="h-10 w-11 cursor-pointer border-0 border-r-2 border-ink bg-cream font-display text-[16px] font-black hover:bg-yellow"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<span className="flex flex-1 items-center justify-center font-mono text-[17px] font-bold">
|
||||||
|
{t('badges.pt', { size: fmt(field.font_size) })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fontSize(FONT_STEP_PT)}
|
||||||
|
aria-label={t('badges.inspector.larger')}
|
||||||
|
className="h-10 w-11 cursor-pointer border-0 border-l-2 border-ink bg-cream font-display text-[16px] font-black hover:bg-yellow"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={field.auto_fit}
|
||||||
|
onClick={() => onChange({ auto_fit: !field.auto_fit })}
|
||||||
|
className="mt-[7px] flex w-full cursor-pointer items-center gap-[9px] border-0 bg-transparent px-0 py-[3px] text-left"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'relative h-5 w-[34px] flex-none border-2 border-ink',
|
||||||
|
field.auto_fit ? 'bg-yellow' : 'bg-sand-dark',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-[1px] h-[14px] w-[14px] bg-ink"
|
||||||
|
style={{ left: field.auto_fit ? 15 : 1 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[11.5px] font-bold leading-snug">
|
||||||
|
{t('badges.inspector.autoFit')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('badges.inspector.align')}>
|
||||||
|
<div className="grid grid-cols-3 border-2 border-ink">
|
||||||
|
{ALIGNS.map((a, i) => (
|
||||||
|
<Segment
|
||||||
|
key={a.value}
|
||||||
|
active={field.align === a.value}
|
||||||
|
onClick={() => onChange({ align: a.value })}
|
||||||
|
className={cx(
|
||||||
|
'flex h-[46px] flex-col items-center justify-center gap-1',
|
||||||
|
i < 2 && 'border-r-2 border-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cx('flex w-[18px] flex-col gap-[2px]', a.items)}>
|
||||||
|
<span className="h-[2px] w-[18px] bg-current" />
|
||||||
|
<span className="h-[2px] w-[11px] bg-current" />
|
||||||
|
<span className="h-[2px] w-[15px] bg-current" />
|
||||||
|
</span>
|
||||||
|
<span className="font-display text-[9.5px] font-bold">{t(a.key)}</span>
|
||||||
|
</Segment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('badges.inspector.weight')}>
|
||||||
|
<div className="grid grid-cols-2 border-2 border-ink">
|
||||||
|
<Segment
|
||||||
|
active={!field.bold}
|
||||||
|
onClick={() => onChange({ bold: false })}
|
||||||
|
className="h-[38px] border-r-2 border-ink font-display text-[13px] font-medium"
|
||||||
|
>
|
||||||
|
{t('badges.inspector.regular')}
|
||||||
|
</Segment>
|
||||||
|
<Segment
|
||||||
|
active={field.bold}
|
||||||
|
onClick={() => onChange({ bold: true })}
|
||||||
|
className="h-[38px] font-display text-[13px] font-black"
|
||||||
|
>
|
||||||
|
{t('badges.inspector.bold')}
|
||||||
|
</Segment>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('badges.inspector.textColor')}>
|
||||||
|
<div className="grid grid-cols-5 gap-[6px]">
|
||||||
|
{TEXT_COLORS.map((c) => (
|
||||||
|
<Swatch
|
||||||
|
key={c.hex}
|
||||||
|
hex={c.hex}
|
||||||
|
label={t(c.key)}
|
||||||
|
active={sameColor(field.color, c.hex)}
|
||||||
|
onClick={() => onChange({ color: c.hex })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<label className="flex cursor-pointer flex-col items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={field.color}
|
||||||
|
onChange={(e) => onChange({ color: e.target.value })}
|
||||||
|
className={cx(
|
||||||
|
'h-[30px] w-full cursor-pointer border-2 border-ink bg-cream p-0',
|
||||||
|
!TEXT_COLORS.some((c) => sameColor(c.hex, field.color)) &&
|
||||||
|
'shadow-[0_0_0_3px_#2A6BFF]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-display text-[9.5px] font-semibold">
|
||||||
|
{t('badges.color.custom')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t('badges.inspector.background')}>
|
||||||
|
<div className="grid grid-cols-4 gap-[6px]">
|
||||||
|
{BG_COLORS.map((c) => (
|
||||||
|
<Swatch
|
||||||
|
key={c.key}
|
||||||
|
hex={c.hex}
|
||||||
|
label={t(c.key)}
|
||||||
|
active={sameColor(field.bg_color, c.hex)}
|
||||||
|
onClick={() => onChange({ bg_color: c.hex })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{lowContrast ? (
|
||||||
|
<div className="mt-[7px] border-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
|
||||||
|
{t('badges.inspector.lowContrast')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Section title={t('badges.inspector.photoShape')}>
|
||||||
|
<div className="grid grid-cols-2 border-2 border-ink">
|
||||||
|
<Segment
|
||||||
|
active={field.photo_shape === 'RECT'}
|
||||||
|
onClick={() => onChange({ photo_shape: 'RECT' })}
|
||||||
|
className="h-10 border-r-2 border-ink font-display text-[12px] font-extrabold"
|
||||||
|
>
|
||||||
|
{t('badges.inspector.photoRect')}
|
||||||
|
</Segment>
|
||||||
|
<Segment
|
||||||
|
active={field.photo_shape === 'OVAL'}
|
||||||
|
onClick={() => onChange({ photo_shape: 'OVAL' })}
|
||||||
|
className="h-10 font-display text-[12px] font-extrabold"
|
||||||
|
>
|
||||||
|
{t('badges.inspector.photoOval')}
|
||||||
|
</Segment>
|
||||||
|
</div>
|
||||||
|
<div className="mt-[7px] font-mono text-[9.5px] leading-relaxed opacity-65">
|
||||||
|
{t('badges.inspector.photoStat', { count: photoCount, total: volunteerCount })}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t-2 border-ink pt-3">
|
||||||
|
<div className="mb-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-widest">
|
||||||
|
{t('badges.inspector.geometry')}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-[7px]">
|
||||||
|
<MmInput label={t('badges.inspector.x')} value={field.x_mm} onCommit={(v) => setGeometry({ x_mm: v })} />
|
||||||
|
<MmInput label={t('badges.inspector.y')} value={field.y_mm} onCommit={(v) => setGeometry({ y_mm: v })} />
|
||||||
|
<MmInput
|
||||||
|
label={t('badges.inspector.w')}
|
||||||
|
value={field.width_mm}
|
||||||
|
onCommit={(v) => setGeometry({ width_mm: Math.max(1, v) })}
|
||||||
|
/>
|
||||||
|
<MmInput
|
||||||
|
label={t('badges.inspector.h')}
|
||||||
|
value={field.height_mm}
|
||||||
|
onCommit={(v) => setGeometry({ height_mm: Math.max(1, v) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-[7px] grid grid-cols-2 gap-[7px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGeometry({ x_mm: (badgeW - field.width_mm) / 2 })}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft"
|
||||||
|
>
|
||||||
|
{t('badges.inspector.center')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setGeometry({ x_mm: 0, width_mm: badgeW })}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft"
|
||||||
|
>
|
||||||
|
{t('badges.inspector.fullWidth')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onHide}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-paper p-[10px] font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-orange"
|
||||||
|
>
|
||||||
|
{t('badges.inspector.hide')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Métriques de la police Helvetica (et Helvetica-Bold) des 14 polices
|
||||||
|
// standard PDF, telles que ReportLab les utilise pour `stringWidth`
|
||||||
|
// (`backend/app/badges/pdf.py:wrap_text`) : largeur de chaque caractère du
|
||||||
|
// codage WinAnsi, en millièmes de la taille de police. Recopiées des fichiers
|
||||||
|
// `reportlab/pdfbase/_fontdata_widths_helvetica*.py` — mesurer avec ces mêmes
|
||||||
|
// chiffres (plutôt qu'avec la police que le navigateur trouve sous le nom
|
||||||
|
// « Helvetica ») fait tomber les retours à la ligne de l'aperçu au même
|
||||||
|
// endroit que dans le PDF, et marche aussi hors navigateur (smoke).
|
||||||
|
|
||||||
|
const CHARS = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ ¡¢£¤¥¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ";
|
||||||
|
const REGULAR = [278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556,1015,667,667,722,722,667,611,778,722,278,500,667,556,833,722,778,667,778,722,667,611,722,667,944,667,667,611,278,278,278,469,556,333,556,556,500,556,556,278,556,556,222,222,500,222,833,556,556,556,556,333,500,278,556,500,722,500,500,500,334,260,334,584,350,556,222,556,333,1000,556,556,333,1000,667,333,1000,611,222,222,333,333,350,556,1000,333,1000,500,333,944,500,667,278,333,556,556,556,556,260,556,333,737,370,556,584,333,737,333,400,584,333,333,333,556,537,278,333,333,365,556,834,834,834,611,667,667,667,667,667,667,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,500,556,556,556,556,278,278,278,278,556,556,556,556,556,556,556,584,611,556,556,556,556,500,556,500];
|
||||||
|
const BOLD = [278,333,474,556,556,889,722,238,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,333,333,584,584,584,611,975,722,722,722,722,667,611,778,722,278,556,722,611,833,722,778,667,778,722,667,611,722,667,944,667,667,611,333,278,333,584,556,333,556,611,556,611,556,333,611,611,278,278,556,278,889,611,611,611,611,389,556,333,611,556,778,556,556,500,389,280,389,584,350,556,278,556,500,1000,556,556,333,1000,667,333,1000,611,278,278,500,500,350,556,1000,333,1000,556,333,944,500,667,278,333,556,556,556,556,280,556,333,737,370,556,584,333,737,333,400,584,333,333,333,611,556,278,333,333,365,556,834,834,834,611,722,722,722,722,722,722,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,556,556,556,556,556,278,278,278,278,611,611,611,611,611,611,611,584,611,611,611,611,611,556,611,556];
|
||||||
|
|
||||||
|
/** Largeur (millièmes d'em) d'un caractère hors WinAnsi : ReportLab le
|
||||||
|
* remplacerait par une police de substitution, on prend une largeur moyenne. */
|
||||||
|
const FALLBACK_WIDTH = 556;
|
||||||
|
|
||||||
|
const regularWidths = new Map<string, number>();
|
||||||
|
const boldWidths = new Map<string, number>();
|
||||||
|
for (let i = 0; i < CHARS.length; i++) {
|
||||||
|
regularWidths.set(CHARS[i], REGULAR[i]);
|
||||||
|
boldWidths.set(CHARS[i], BOLD[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largeur de `text` en points, à la taille `size` (pt) — équivalent de
|
||||||
|
* `reportlab.pdfbase.pdfmetrics.stringWidth(text, "Helvetica[-Bold]", size)`. */
|
||||||
|
export function stringWidthPt(text: string, bold: boolean, size: number): number {
|
||||||
|
const widths = bold ? boldWidths : regularWidths;
|
||||||
|
let total = 0;
|
||||||
|
for (const ch of text) total += widths.get(ch) ?? FALLBACK_WIDTH;
|
||||||
|
return (total * size) / 1000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Contrôle avant génération : passe chaque champ affiché sur TOUS les
|
||||||
|
// bénévoles (estimation de mise en page, voir `layout.ts`) et relève ce qui
|
||||||
|
// risque de mal s'imprimer.
|
||||||
|
|
||||||
|
import type { BadgeFace, BadgeFieldType } from '@/api/schema';
|
||||||
|
|
||||||
|
import { contrastRatio, MIN_CONTRAST } from './layout';
|
||||||
|
import { renderField, SAFE_MARGIN_MM, type BadgeData, type FieldRef, type FieldState } from './model';
|
||||||
|
|
||||||
|
export interface FieldStats {
|
||||||
|
/** Bénévoles dont le texte a été réduit automatiquement (sans être coupé). */
|
||||||
|
reduced: number;
|
||||||
|
/** Bénévoles dont le texte reste coupé. */
|
||||||
|
truncated: number;
|
||||||
|
/** Plus petite taille atteinte par la réduction automatique. */
|
||||||
|
minPt: number;
|
||||||
|
/** Index (dans `BadgeData.volunteers`) d'un exemple de chaque cas. */
|
||||||
|
reducedExample: number | null;
|
||||||
|
truncatedExample: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IssueKind = 'truncated' | 'reduced' | 'margin' | 'contrast' | 'noPhoto';
|
||||||
|
|
||||||
|
export interface BadgeIssue {
|
||||||
|
kind: IssueKind;
|
||||||
|
critical: boolean;
|
||||||
|
ref: FieldRef;
|
||||||
|
count: number;
|
||||||
|
minPt: number;
|
||||||
|
/** Bénévole à afficher pour « Voir un exemple » (null : pas d'exemple). */
|
||||||
|
exampleIndex: number | null;
|
||||||
|
/** « Activer la réduction auto » proposé (texte coupé, réduction coupée). */
|
||||||
|
canEnableAutoFit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statsKey(face: BadgeFace, type: BadgeFieldType): string {
|
||||||
|
return `${face}:${type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeIssues(
|
||||||
|
fields: FieldState[],
|
||||||
|
data: BadgeData,
|
||||||
|
badgeW: number,
|
||||||
|
badgeH: number,
|
||||||
|
): { issues: BadgeIssue[]; stats: Map<string, FieldStats> } {
|
||||||
|
const stats = new Map<string, FieldStats>();
|
||||||
|
const issues: BadgeIssue[] = [];
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
if (!field.enabled) continue;
|
||||||
|
const ref = { face: field.face, type: field.field_type };
|
||||||
|
const base = { ref, count: 0, minPt: field.font_size, exampleIndex: null, canEnableAutoFit: false };
|
||||||
|
|
||||||
|
if (field.field_type !== 'PHOTO') {
|
||||||
|
const s: FieldStats = {
|
||||||
|
reduced: 0,
|
||||||
|
truncated: 0,
|
||||||
|
minPt: field.font_size,
|
||||||
|
reducedExample: null,
|
||||||
|
truncatedExample: null,
|
||||||
|
};
|
||||||
|
data.volunteers.forEach((volunteer, index) => {
|
||||||
|
const rendered = renderField(field, volunteer, data);
|
||||||
|
if (rendered.kind === 'photo') return;
|
||||||
|
const { truncated, reduced, fontSize } = rendered.layout;
|
||||||
|
if (truncated) {
|
||||||
|
s.truncated += 1;
|
||||||
|
if (s.truncatedExample === null) s.truncatedExample = index;
|
||||||
|
} else if (reduced) {
|
||||||
|
s.reduced += 1;
|
||||||
|
if (fontSize < s.minPt) {
|
||||||
|
s.minPt = fontSize;
|
||||||
|
s.reducedExample = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stats.set(statsKey(field.face, field.field_type), s);
|
||||||
|
if (s.truncated) {
|
||||||
|
issues.push({
|
||||||
|
...base,
|
||||||
|
kind: 'truncated',
|
||||||
|
critical: true,
|
||||||
|
count: s.truncated,
|
||||||
|
exampleIndex: s.truncatedExample,
|
||||||
|
canEnableAutoFit: !field.auto_fit,
|
||||||
|
});
|
||||||
|
} else if (s.reduced) {
|
||||||
|
issues.push({
|
||||||
|
...base,
|
||||||
|
kind: 'reduced',
|
||||||
|
critical: false,
|
||||||
|
count: s.reduced,
|
||||||
|
minPt: s.minPt,
|
||||||
|
exampleIndex: s.reducedExample,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (field.bg_color && contrastRatio(field.color, field.bg_color) < MIN_CONTRAST) {
|
||||||
|
issues.push({ ...base, kind: 'contrast', critical: true });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const missing = data.volunteers.findIndex((v) => !v.has_photo);
|
||||||
|
if (missing >= 0) {
|
||||||
|
issues.push({
|
||||||
|
...base,
|
||||||
|
kind: 'noPhoto',
|
||||||
|
critical: false,
|
||||||
|
count: data.volunteers.filter((v) => !v.has_photo).length,
|
||||||
|
exampleIndex: missing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un bandeau coloré (fond de zone) peut volontairement aller jusqu'au
|
||||||
|
// bord : seul un champ sans fond qui touche la marge est signalé.
|
||||||
|
const bleed = field.bg_color !== null && field.field_type !== 'PHOTO';
|
||||||
|
const m = SAFE_MARGIN_MM - 1e-6;
|
||||||
|
const touchesEdge =
|
||||||
|
field.x_mm < m ||
|
||||||
|
field.y_mm < m ||
|
||||||
|
field.x_mm + field.width_mm > badgeW - m ||
|
||||||
|
field.y_mm + field.height_mm > badgeH - m;
|
||||||
|
if (touchesEdge && !bleed) issues.push({ ...base, kind: 'margin', critical: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
issues.sort((a, b) => Number(b.critical) - Number(a.critical));
|
||||||
|
return { issues, stats };
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// Estimation de la mise en page d'un champ du badge, côté navigateur — même
|
||||||
|
// algorithme que la génération PDF (`backend/app/badges/pdf.py` : `wrap_text`,
|
||||||
|
// `_draw_table`, mêmes métriques Helvetica via `stringWidthPt`). Sert à
|
||||||
|
// l'aperçu (retours à la ligne, réduction automatique, lignes coupées) et au
|
||||||
|
// contrôle avant génération. Ce n'est qu'une estimation : le PDF reste la
|
||||||
|
// référence.
|
||||||
|
|
||||||
|
import { stringWidthPt } from './helveticaMetrics';
|
||||||
|
|
||||||
|
/** 1 pt = 25,4 / 72 mm. */
|
||||||
|
export const MM_PER_PT = 25.4 / 72;
|
||||||
|
/** Interligne, en multiple de la taille de police (`_LINE_HEIGHT_FACTOR`). */
|
||||||
|
export const LINE_HEIGHT = 1.25;
|
||||||
|
/** Position de la première ligne de base sous le haut du cadre, en multiple
|
||||||
|
* de la taille de police (`font_size * 0.85` dans le PDF). */
|
||||||
|
export const FIRST_BASELINE = 0.85;
|
||||||
|
export const MIN_FONT_PT = 4;
|
||||||
|
export const MAX_FONT_PT = 30;
|
||||||
|
export const FONT_STEP_PT = 0.5;
|
||||||
|
/** Marges intérieures des cellules du tableau des créneaux (mm). */
|
||||||
|
export const CELL_PAD_X_MM = 0.8;
|
||||||
|
export const CELL_PAD_Y_MM = 0.5;
|
||||||
|
|
||||||
|
/** Découpe `text` en lignes de largeur au plus `maxWidthPt` — portage de
|
||||||
|
* `wrap_text` : les `\n` restent des retours à la ligne, puis passage à la
|
||||||
|
* ligne entre deux mots, ou au milieu d'un mot qui seul ne tient pas. */
|
||||||
|
export function wrapText(text: string, bold: boolean, size: number, maxWidthPt: number): string[] {
|
||||||
|
const fits = (candidate: string) => stringWidthPt(candidate, bold, size) <= maxWidthPt;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const paragraph of text.split('\n')) {
|
||||||
|
let current = '';
|
||||||
|
for (const word of paragraph.split(' ')) {
|
||||||
|
const candidate = current ? `${current} ${word}` : word;
|
||||||
|
if (fits(candidate)) {
|
||||||
|
current = candidate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current) lines.push(current.trimEnd());
|
||||||
|
current = word;
|
||||||
|
while (current && !fits(current)) {
|
||||||
|
let cut = current.length - 1;
|
||||||
|
while (cut > 1 && !fits(current.slice(0, cut))) cut -= 1;
|
||||||
|
lines.push(current.slice(0, cut));
|
||||||
|
current = current.slice(cut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(current.trimEnd());
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre de lignes qui tiennent dans `heightMm` : une ligne est dessinée
|
||||||
|
* tant que sa ligne de base reste dans le cadre (règle du PDF). */
|
||||||
|
export function linesThatFit(size: number, heightMm: number): number {
|
||||||
|
const sizeMm = size * MM_PER_PT;
|
||||||
|
if (FIRST_BASELINE * sizeMm > heightMm) return 0;
|
||||||
|
return Math.floor((heightMm - FIRST_BASELINE * sizeMm) / (LINE_HEIGHT * sizeMm) + 1e-9) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TextLayout {
|
||||||
|
/** Taille réellement utilisée (réduite si `reduced`). */
|
||||||
|
fontSize: number;
|
||||||
|
/** Toutes les lignes après retour à la ligne. */
|
||||||
|
lines: string[];
|
||||||
|
/** Lignes imprimées (les suivantes débordent du cadre). */
|
||||||
|
visible: string[];
|
||||||
|
reduced: boolean;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FitOptions {
|
||||||
|
fontSize: number;
|
||||||
|
autoFit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Réduction automatique commune au texte et au tableau : à la taille
|
||||||
|
* demandée si tout tient ; sinon, si `autoFit`, par pas de 0,5 pt jusqu'à
|
||||||
|
* 4 pt ; le texte reste coupé s'il ne tient toujours pas. */
|
||||||
|
function withAutoFit<L>(
|
||||||
|
{ fontSize, autoFit }: FitOptions,
|
||||||
|
layoutAt: (size: number) => L,
|
||||||
|
isComplete: (layout: L) => boolean,
|
||||||
|
): { layout: L; fontSize: number; reduced: boolean; truncated: boolean } {
|
||||||
|
let size = fontSize;
|
||||||
|
let layout = layoutAt(size);
|
||||||
|
if (isComplete(layout)) return { layout, fontSize: size, reduced: false, truncated: false };
|
||||||
|
if (!autoFit) return { layout, fontSize: size, reduced: false, truncated: true };
|
||||||
|
while (size - FONT_STEP_PT >= MIN_FONT_PT - 1e-9) {
|
||||||
|
size = Math.round((size - FONT_STEP_PT) * 10) / 10;
|
||||||
|
layout = layoutAt(size);
|
||||||
|
if (isComplete(layout)) return { layout, fontSize: size, reduced: true, truncated: false };
|
||||||
|
}
|
||||||
|
return { layout, fontSize: size, reduced: size < fontSize, truncated: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutText(
|
||||||
|
text: string,
|
||||||
|
box: { width_mm: number; height_mm: number; bold: boolean; font_size: number; auto_fit: boolean },
|
||||||
|
): TextLayout {
|
||||||
|
const result = withAutoFit(
|
||||||
|
{ fontSize: box.font_size, autoFit: box.auto_fit },
|
||||||
|
(size) => {
|
||||||
|
const lines = text ? wrapText(text, box.bold, size, box.width_mm / MM_PER_PT) : [];
|
||||||
|
return { lines, capacity: linesThatFit(size, box.height_mm) };
|
||||||
|
},
|
||||||
|
({ lines, capacity }) => lines.length <= capacity,
|
||||||
|
);
|
||||||
|
const { lines, capacity } = result.layout;
|
||||||
|
return {
|
||||||
|
fontSize: result.fontSize,
|
||||||
|
lines,
|
||||||
|
visible: lines.slice(0, capacity),
|
||||||
|
reduced: result.reduced,
|
||||||
|
truncated: result.truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Tableau des créneaux (`POSITIONS`) //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
export interface TableSection {
|
||||||
|
title: string;
|
||||||
|
rows: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableRowLayout {
|
||||||
|
/** Lignes de texte de chaque cellule (une seule cellule pour un en-tête). */
|
||||||
|
cells: string[][];
|
||||||
|
isTitle: boolean;
|
||||||
|
heightMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableLayout {
|
||||||
|
fontSize: number;
|
||||||
|
/** Largeur de chaque colonne (mm). */
|
||||||
|
widthsMm: number[];
|
||||||
|
/** Lignes du tableau qui tiennent dans le cadre. */
|
||||||
|
rows: TableRowLayout[];
|
||||||
|
reduced: boolean;
|
||||||
|
truncated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Portage de `_column_widths` : une colonne courte garde sa largeur
|
||||||
|
* naturelle, les plus longues se partagent le reste. */
|
||||||
|
function columnWidths(natural: number[], available: number): number[] {
|
||||||
|
const widths = natural.map(() => 0);
|
||||||
|
const pending = natural.map((_, i) => i).sort((a, b) => natural[a] - natural[b]);
|
||||||
|
let remaining = available;
|
||||||
|
while (pending.length) {
|
||||||
|
const share = remaining / pending.length;
|
||||||
|
if (natural[pending[0]] > share) {
|
||||||
|
for (const i of pending) widths[i] = share;
|
||||||
|
return widths;
|
||||||
|
}
|
||||||
|
const i = pending.shift() as number;
|
||||||
|
widths[i] = natural[i];
|
||||||
|
remaining -= natural[i];
|
||||||
|
}
|
||||||
|
widths[widths.length - 1] += remaining;
|
||||||
|
return widths;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutTable(
|
||||||
|
sections: TableSection[],
|
||||||
|
box: { width_mm: number; height_mm: number; font_size: number; auto_fit: boolean },
|
||||||
|
): TableLayout {
|
||||||
|
const total = sections.reduce((n, s) => n + 1 + s.rows.length, 0);
|
||||||
|
const result = withAutoFit(
|
||||||
|
{ fontSize: box.font_size, autoFit: box.auto_fit },
|
||||||
|
(size) => {
|
||||||
|
const body = sections.flatMap((s) => s.rows);
|
||||||
|
const columns = body.length ? Math.max(...body.map((r) => r.length)) : 1;
|
||||||
|
const natural = body.length
|
||||||
|
? Array.from({ length: columns }, (_, col) =>
|
||||||
|
Math.max(...body.map((r) => stringWidthPt(r[col] ?? '', false, size) * MM_PER_PT)) +
|
||||||
|
2 * CELL_PAD_X_MM,
|
||||||
|
)
|
||||||
|
: [0];
|
||||||
|
const widthsMm = columnWidths(natural, box.width_mm);
|
||||||
|
const fullWidth = widthsMm.reduce((a, b) => a + b, 0);
|
||||||
|
const lineMm = size * MM_PER_PT * LINE_HEIGHT;
|
||||||
|
const rows: TableRowLayout[] = [];
|
||||||
|
let used = 0;
|
||||||
|
outer: for (const section of sections) {
|
||||||
|
const entries: Array<{ cells: string[]; isTitle: boolean }> = [
|
||||||
|
{ cells: [section.title], isTitle: true },
|
||||||
|
...section.rows.map((cells) => ({ cells, isTitle: false })),
|
||||||
|
];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const widths = entry.isTitle ? [fullWidth] : widthsMm;
|
||||||
|
const cells = entry.cells.map((text, col) =>
|
||||||
|
wrapText(
|
||||||
|
text,
|
||||||
|
entry.isTitle,
|
||||||
|
size,
|
||||||
|
Math.max(1, (widths[col] - 2 * CELL_PAD_X_MM) / MM_PER_PT),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const heightMm = Math.max(...cells.map((c) => c.length)) * lineMm + 2 * CELL_PAD_Y_MM;
|
||||||
|
if (used + heightMm > box.height_mm + 0.01 * MM_PER_PT) break outer;
|
||||||
|
rows.push({ cells, isTitle: entry.isTitle, heightMm });
|
||||||
|
used += heightMm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { widthsMm, rows };
|
||||||
|
},
|
||||||
|
(layout) => layout.rows.length >= total,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
fontSize: result.fontSize,
|
||||||
|
widthsMm: result.layout.widthsMm,
|
||||||
|
rows: result.layout.rows,
|
||||||
|
reduced: result.reduced,
|
||||||
|
truncated: result.truncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Contraste //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
function luminance(hex: string): number {
|
||||||
|
const n = parseInt(hex.slice(1, 7), 16);
|
||||||
|
const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => {
|
||||||
|
const x = c / 255;
|
||||||
|
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
||||||
|
});
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rapport de contraste WCAG entre deux couleurs `#rrggbb`. */
|
||||||
|
export function contrastRatio(a: string, b: string): number {
|
||||||
|
const la = luminance(a);
|
||||||
|
const lb = luminance(b);
|
||||||
|
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** En dessous, le texte risque d'être illisible à l'impression. */
|
||||||
|
export const MIN_CONTRAST = 3;
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
// État de l'éditeur de badges et contenu réel de chaque champ pour un
|
||||||
|
// bénévole donné — mêmes règles que la génération PDF
|
||||||
|
// (`backend/app/api/badges.py:_field_text`, `_positions_table`).
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BadgeFace,
|
||||||
|
BadgeFieldIn,
|
||||||
|
BadgeFieldRead,
|
||||||
|
BadgeFieldType,
|
||||||
|
PositionRead,
|
||||||
|
ShiftRead,
|
||||||
|
VolunteerRead,
|
||||||
|
} from '@/api/schema';
|
||||||
|
import { t } from '@/i18n';
|
||||||
|
import { categoryLabel, festivalDay } from '@/lib/planning';
|
||||||
|
import { longDayLabel, timePart } from '@/lib/time';
|
||||||
|
|
||||||
|
import { layoutTable, layoutText, type TableLayout, type TableSection, type TextLayout } from './layout';
|
||||||
|
|
||||||
|
export const DEFAULT_BADGE_W_MM = 85.6;
|
||||||
|
export const DEFAULT_BADGE_H_MM = 54;
|
||||||
|
/** Marge de découpe (mm) : un champ qui la touche risque d'être rogné. */
|
||||||
|
export const SAFE_MARGIN_MM = 1.5;
|
||||||
|
|
||||||
|
export const FACES: BadgeFace[] = ['FRONT', 'BACK'];
|
||||||
|
|
||||||
|
export const FIELD_TYPES: BadgeFieldType[] = [
|
||||||
|
'PHOTO',
|
||||||
|
'FULL_NAME',
|
||||||
|
'POSITIONS',
|
||||||
|
'CATEGORY',
|
||||||
|
'ROLE',
|
||||||
|
'FREE_TEXT',
|
||||||
|
];
|
||||||
|
|
||||||
|
export 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FACE_LABEL_KEY: Record<BadgeFace, string> = {
|
||||||
|
FRONT: 'badges.face.front',
|
||||||
|
BACK: 'badges.face.back',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Formats prédéfinis (mm). */
|
||||||
|
export const BADGE_PRESETS: Array<{ key: string; width: number; height: number }> = [
|
||||||
|
{ key: 'badges.preset.cr80Portrait', width: 54, height: 85.6 },
|
||||||
|
{ key: 'badges.preset.cr80Landscape', width: 85.6, height: 54 },
|
||||||
|
{ key: 'badges.preset.a7', width: 74, height: 105 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type FieldState = BadgeFieldIn & { enabled: boolean };
|
||||||
|
|
||||||
|
export interface FieldRef {
|
||||||
|
face: BadgeFace;
|
||||||
|
type: BadgeFieldType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameRef(a: FieldRef | null, b: FieldRef | null): boolean {
|
||||||
|
return Boolean(a && b && a.face === b.face && a.type === b.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldDefaults = Omit<BadgeFieldIn, 'field_type' | 'face'>;
|
||||||
|
|
||||||
|
const COMMON = { bg_color: null, auto_fit: true, photo_shape: 'RECT' } as const;
|
||||||
|
|
||||||
|
// Position/taille par défaut à l'activation d'un champ — empilés
|
||||||
|
// verticalement pour ne pas se superposer (repères pour un badge CR80
|
||||||
|
// paysage, ramenés dans le badge par `fitToBadge` pour un autre format).
|
||||||
|
const FIELD_DEFAULTS: Record<BadgeFieldType, FieldDefaults> = {
|
||||||
|
FULL_NAME: {
|
||||||
|
...COMMON, x_mm: 5, y_mm: 4, width_mm: 50, height_mm: 11,
|
||||||
|
font_size: 11, align: 'LEFT', bold: true, color: '#14110E',
|
||||||
|
},
|
||||||
|
POSITIONS: {
|
||||||
|
...COMMON, x_mm: 5, y_mm: 16, width_mm: 52, height_mm: 22,
|
||||||
|
font_size: 6, align: 'LEFT', bold: false, color: '#14110E',
|
||||||
|
},
|
||||||
|
CATEGORY: {
|
||||||
|
...COMMON, x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 5,
|
||||||
|
font_size: 7, align: 'LEFT', bold: true, color: '#14110E',
|
||||||
|
},
|
||||||
|
ROLE: {
|
||||||
|
...COMMON, x_mm: 5, y_mm: 45, width_mm: 30, height_mm: 5,
|
||||||
|
font_size: 7, align: 'CENTER', bold: true, color: '#14110E', bg_color: '#FFD23F',
|
||||||
|
},
|
||||||
|
// Format portrait d'identité, en haut à droite. Taille de police/couleur
|
||||||
|
// sans effet : la photo est une image.
|
||||||
|
PHOTO: {
|
||||||
|
...COMMON, x_mm: 60, y_mm: 4, width_mm: 21, height_mm: 27,
|
||||||
|
font_size: 8, align: 'CENTER', bold: false, color: '#14110E',
|
||||||
|
},
|
||||||
|
FREE_TEXT: {
|
||||||
|
...COMMON, x_mm: 40, y_mm: 45, width_mm: 40, height_mm: 5,
|
||||||
|
font_size: 7, align: 'CENTER', bold: true, color: '#FFFFFF', bg_color: '#14110E',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
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). Sur un badge plus étroit que le CR80 paysage des
|
||||||
|
* positions par défaut, 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). */
|
||||||
|
export 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Les 12 champs possibles (6 types × 2 faces), activés ou non, dans
|
||||||
|
* l'ordre d'affichage. */
|
||||||
|
export function fieldsFromServer(
|
||||||
|
saved: BadgeFieldRead[],
|
||||||
|
badgeW: number,
|
||||||
|
badgeH: number,
|
||||||
|
): FieldState[] {
|
||||||
|
const out: FieldState[] = [];
|
||||||
|
for (const face of FACES) {
|
||||||
|
for (const type of FIELD_TYPES) {
|
||||||
|
const existing = saved.find((f) => (f.face ?? 'FRONT') === face && f.field_type === type);
|
||||||
|
const field: FieldState = existing
|
||||||
|
? {
|
||||||
|
field_type: type,
|
||||||
|
face,
|
||||||
|
x_mm: existing.x_mm,
|
||||||
|
y_mm: existing.y_mm,
|
||||||
|
width_mm: existing.width_mm,
|
||||||
|
height_mm: existing.height_mm,
|
||||||
|
font_size: existing.font_size,
|
||||||
|
align: existing.align,
|
||||||
|
bold: existing.bold,
|
||||||
|
color: existing.color,
|
||||||
|
bg_color: existing.bg_color ?? null,
|
||||||
|
auto_fit: existing.auto_fit ?? true,
|
||||||
|
photo_shape: existing.photo_shape ?? 'RECT',
|
||||||
|
text: existing.text ?? null,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
: { field_type: type, face, ...FIELD_DEFAULTS[type], text: null, enabled: false };
|
||||||
|
out.push(fitToBadge(field, badgeW, badgeH));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corps du `PUT .../badges/template/fields` : les champs activés des deux
|
||||||
|
* faces. */
|
||||||
|
export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
|
||||||
|
return fields
|
||||||
|
.filter((f) => f.enabled)
|
||||||
|
.map((f) => ({
|
||||||
|
field_type: f.field_type,
|
||||||
|
face: f.face,
|
||||||
|
x_mm: round1(f.x_mm),
|
||||||
|
y_mm: round1(f.y_mm),
|
||||||
|
width_mm: round1(f.width_mm),
|
||||||
|
height_mm: round1(f.height_mm),
|
||||||
|
font_size: f.font_size,
|
||||||
|
align: f.align,
|
||||||
|
bold: f.bold,
|
||||||
|
color: f.color,
|
||||||
|
bg_color: f.bg_color,
|
||||||
|
auto_fit: f.auto_fit,
|
||||||
|
photo_shape: f.photo_shape,
|
||||||
|
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function round1(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nombre français à une décimale au plus (« 8,5 »). */
|
||||||
|
export function fmt(value: number): string {
|
||||||
|
return String(round1(value)).replace('.', ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Contenu réel d'un champ pour un bénévole //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
export interface BadgeData {
|
||||||
|
/** Dans l'ordre d'impression : nom puis prénom (comme le PDF). */
|
||||||
|
volunteers: VolunteerRead[];
|
||||||
|
/** Créneaux affectés de chaque bénévole, dans l'ordre chronologique. */
|
||||||
|
assignments: Map<string, Array<{ position: PositionRead; shift: ShiftRead }>>;
|
||||||
|
/** Bénévoles dont le compte est référent (`LEAD`). */
|
||||||
|
leadVolunteerIds: Set<string>;
|
||||||
|
/** Début de « journée de festival » (`HH:MM`). */
|
||||||
|
dayBoundary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initials(volunteer: VolunteerRead): string {
|
||||||
|
return `${volunteer.first_name.trim().charAt(0)}${volunteer.last_name.trim().charAt(0)}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fullNameText(volunteer: VolunteerRead): string {
|
||||||
|
return `${volunteer.first_name}\n${volunteer.last_name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Texte d'un champ (hors `POSITIONS`, un tableau, et `PHOTO`). */
|
||||||
|
export function fieldText(field: FieldState, volunteer: VolunteerRead, data: BadgeData): string {
|
||||||
|
switch (field.field_type) {
|
||||||
|
case 'FULL_NAME':
|
||||||
|
return fullNameText(volunteer);
|
||||||
|
case 'FREE_TEXT':
|
||||||
|
// 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 || field.text || '';
|
||||||
|
case 'CATEGORY': {
|
||||||
|
const rows = data.assignments.get(volunteer.id) ?? [];
|
||||||
|
return [...new Set(rows.map((r) => categoryLabel(r.position.category)))].sort().join(', ');
|
||||||
|
}
|
||||||
|
case 'ROLE':
|
||||||
|
return data.leadVolunteerIds.has(volunteer.id) ? t('role.lead') : t('role.volunteer');
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Créneaux regroupés par jour de festival : le jour en en-tête, puis une
|
||||||
|
* ligne horaire / poste par créneau. */
|
||||||
|
export function positionsTable(volunteer: VolunteerRead, data: BadgeData): TableSection[] {
|
||||||
|
const byDay = new Map<string, string[][]>();
|
||||||
|
for (const { position, shift } of data.assignments.get(volunteer.id) ?? []) {
|
||||||
|
const day = festivalDay(shift.starts_at, data.dayBoundary);
|
||||||
|
const rows = byDay.get(day) ?? [];
|
||||||
|
rows.push([`${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`, position.name]);
|
||||||
|
byDay.set(day, rows);
|
||||||
|
}
|
||||||
|
return [...byDay.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([day, rows]) => ({ title: longDayLabel(day), rows }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FieldRender =
|
||||||
|
| { kind: 'text'; layout: TextLayout }
|
||||||
|
| { kind: 'table'; layout: TableLayout }
|
||||||
|
| { kind: 'photo' };
|
||||||
|
|
||||||
|
/** Mise en page estimée d'un champ pour un bénévole. */
|
||||||
|
export function renderField(field: FieldState, volunteer: VolunteerRead, data: BadgeData): FieldRender {
|
||||||
|
if (field.field_type === 'PHOTO') return { kind: 'photo' };
|
||||||
|
if (field.field_type === 'POSITIONS') {
|
||||||
|
return { kind: 'table', layout: layoutTable(positionsTable(volunteer, data), field) };
|
||||||
|
}
|
||||||
|
return { kind: 'text', layout: layoutText(fieldText(field, volunteer, data), field) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Champs dont le texte est centré verticalement dans le cadre (bandeaux) ;
|
||||||
|
* les autres partent du haut. */
|
||||||
|
export function isVerticallyCentered(type: BadgeFieldType): boolean {
|
||||||
|
return type === 'FREE_TEXT' || type === 'ROLE';
|
||||||
|
}
|
||||||
@@ -169,6 +169,43 @@ const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status
|
|||||||
EXPIRED: 'config.vol.feeStatus.expired',
|
EXPIRED: 'config.vol.feeStatus.expired',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nombre d'exemplaires du badge imprimés sur les planches (1 à 100). Saisie
|
||||||
|
* libre gardée en local : n'enregistre qu'un entier valide, et revient à la
|
||||||
|
* dernière valeur enregistrée si le champ est quitté vide ou hors bornes.
|
||||||
|
*/
|
||||||
|
function BadgeCountInput({ value, onChange }: { value: number; onChange: (count: number) => void }) {
|
||||||
|
const [draft, setDraft] = useState(String(value));
|
||||||
|
const parse = (raw: string) => {
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isInteger(n) && n >= 1 && n <= 100 ? n : null;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<label className="block">
|
||||||
|
<FieldLabel>
|
||||||
|
{t('config.vol.field.badgeCount')}
|
||||||
|
<HelpTip text={t('config.vol.field.badgeCount.help')} className="ml-[6px]" />
|
||||||
|
</FieldLabel>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDraft(e.target.value);
|
||||||
|
const n = parse(e.target.value);
|
||||||
|
if (n !== null && n !== value) onChange(n);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (parse(draft) === null) setDraft(String(value));
|
||||||
|
}}
|
||||||
|
className="w-[90px] border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[13px] font-bold"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Photo du bénévole (badge, écrans bénévole/référent, feuilles de poste) :
|
* Photo du bénévole (badge, écrans bénévole/référent, feuilles de poste) :
|
||||||
* celle envoyée au formulaire public, ou ajoutée ici par la coordination
|
* celle envoyée au formulaire public, ou ajoutée ici par la coordination
|
||||||
@@ -960,6 +997,11 @@ export function ConfigVolunteers() {
|
|||||||
onChange={(v) => patch({ badge_text: v || null })}
|
onChange={(v) => patch({ badge_text: v || null })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<BadgeCountInput
|
||||||
|
key={`${selected.id}-${selected.badge_count}`}
|
||||||
|
value={selected.badge_count}
|
||||||
|
onChange={(badge_count) => patch({ badge_count })}
|
||||||
|
/>
|
||||||
<VolunteerPhotoSection key={selected.id} volunteer={selected} />
|
<VolunteerPhotoSection key={selected.id} volunteer={selected} />
|
||||||
|
|
||||||
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||||
|
|||||||
Reference in New Issue
Block a user