Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a23881e81 | ||
|
|
cfed76c239 |
@@ -86,9 +86,10 @@ les tables sauf les tables d'association.
|
||||
effet sur les affectations) et `willing_to_pay_fee` (« je participe à la
|
||||
cotisation » — déclenche, lui, un vrai paiement : voir `fee_payment_label`/
|
||||
`fee_amount`/`fee_checkout_id`/`fee_payment_status`/`fee_paid_at` et le
|
||||
paiement SumUp plus bas). `badge_text` : texte libre imprimé sur le badge
|
||||
paiement SumUp plus bas). `badge_text` : texte du badge propre à ce bénévole
|
||||
(champ `FREE_TEXT`, voir `BadgeField`), saisi par la coordination sur la
|
||||
fiche (« Texte du badge ») — jamais par le formulaire public.
|
||||
fiche (« Texte du badge ») — jamais par le formulaire public — et qui
|
||||
remplace pour lui le texte commun de l'éditeur.
|
||||
- **`Artist`** : programmation de l'édition (`name`, `performance_date`,
|
||||
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
|
||||
lendemain). Unique (`edition_id`, `name`, `performance_date`). La
|
||||
@@ -170,19 +171,35 @@ les tables sauf les tables d'association.
|
||||
depuis l'écran « Badges » du front-end (`frontend/src/pages/BadgesPage.tsx`),
|
||||
qui envoie ces mêmes millimètres et redimensionne son aperçu au format réel
|
||||
du badge (`GET .../badges/template` renvoie `badge_width_mm`/`height_mm`).
|
||||
`POSITIONS` résout **une ligne par créneau affecté**, triée
|
||||
chronologiquement : nom du poste + horaire (`HH:MM–HH:MM`), suivi du
|
||||
référent du poste (`Position.lead_account_id` → son bénévole) s'il y en a
|
||||
un et que ce n'est pas le bénévole badgé lui-même (texte multi-lignes,
|
||||
`\n`) ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
|
||||
`POSITIONS` est un **tableau** (`app/api/badges.py:_positions_table`,
|
||||
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
|
||||
reste sur le jour où elle a commencé) : le jour en en-tête sur toute la
|
||||
largeur (« Samedi 11 »), puis une ligne par créneau, colonne horaire
|
||||
(`HH:MM–HH:MM`, à sa largeur naturelle) et colonne poste (le reste,
|
||||
repliée si besoin), dans l'ordre chronologique. Pas de référent sur le
|
||||
badge (choix de la coordination). Une ligne qui ne tient plus dans la
|
||||
hauteur du cadre n'est pas imprimée ; `CATEGORY` liste les catégories distinctes de ces mêmes postes ;
|
||||
`ROLE` vient de `Account.role` du bénévole (`LEAD` ⇒ « Référent ») ;
|
||||
`FREE_TEXT` est `Volunteer.badge_text` (vide ⇒ rien) ; `PHOTO` imprime
|
||||
`FREE_TEXT` imprime `Volunteer.badge_text` s'il est renseigné, sinon le
|
||||
texte commun saisi dans l'éditeur (`BadgeField.text`, ignoré pour les
|
||||
autres types), sinon rien ; `PHOTO` imprime
|
||||
`Volunteer.photo` dans le cadre du champ (style de texte ignoré), recadrée
|
||||
au ratio du cadre sans déformation, orientation EXIF appliquée et réduite
|
||||
à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de
|
||||
plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou
|
||||
photo illisible, le cadre reste vide plutôt que de faire échouer la
|
||||
planche.
|
||||
planche. 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
|
||||
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
|
||||
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
|
||||
(`BadgesPage.tsx:fitToBadge`) et met le champ sélectionné au premier
|
||||
plan : les positions par défaut visent un CR80 paysage, et sur un badge
|
||||
plus étroit un champ pouvait tomber hors du badge, avec des bornes de
|
||||
glisser-déposer négatives qui le bloquaient — bug corrigé une fois (photo
|
||||
impossible à déplacer ni redimensionner).
|
||||
`POST /editions/{id}/badges/generate` (COORDINATION, `{volunteer_ids?}` —
|
||||
absent = tous) génère la planche PDF (`app/badges/pdf.py`, OR-Tools n'y est
|
||||
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
|
||||
@@ -933,6 +950,9 @@ Aucune dépendance locale requise hors Docker.
|
||||
| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
|
||||
| 46 | Badges : champ « Photo du bénévole » (`PHOTO`, photo du formulaire recadrée au cadre, 300 dpi) et champ « Texte libre » (`FREE_TEXT`) alimenté par un nouveau « Texte du badge » sur la fiche bénévole (`Volunteer.badge_text`, migration `0024`, repris dans l'export Excel) | à valider |
|
||||
| 47 | Photo ajoutée par la coordination : section « Photo » sur la fiche bénévole (ajouter / remplacer / retirer), `PUT`/`DELETE /editions/{id}/volunteers/{vid}/photo` (COORDINATION), validation de l'image partagée avec le formulaire public (`read_photo`) | à valider |
|
||||
| 48 | Badges : texte libre saisi dans l'éditeur (`BadgeField.text`, migration `0025`), commun à tous les badges, le « Texte du badge » de la fiche le remplaçant pour un bénévole ; champs ramenés dans le badge et champ sélectionné au premier plan (la photo restait bloquée hors d'un badge plus étroit que le CR80) | à valider |
|
||||
| 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 |
|
||||
|
||||
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||
|
||||
+46
-25
@@ -8,6 +8,7 @@ pour la composition du PDF et `app/models/domain.py` (`BadgeTemplate`,
|
||||
`BadgeField`) pour le modèle.
|
||||
"""
|
||||
|
||||
import datetime as dt
|
||||
import io
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
@@ -15,7 +16,6 @@ from collections import defaultdict
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.deps import get_edition
|
||||
@@ -32,6 +32,8 @@ from app.badges.pdf import (
|
||||
DEFAULT_BADGE_WIDTH_MM,
|
||||
BadgeFieldSpec,
|
||||
BadgeSpec,
|
||||
BadgeTable,
|
||||
BadgeTableSection,
|
||||
generate_badge_sheets_pdf,
|
||||
image_size_mm,
|
||||
)
|
||||
@@ -43,12 +45,14 @@ from app.models import (
|
||||
BadgeField,
|
||||
BadgeFieldType,
|
||||
BadgeTemplate,
|
||||
DayPart,
|
||||
Edition,
|
||||
Position,
|
||||
Role,
|
||||
Shift,
|
||||
Volunteer,
|
||||
)
|
||||
from app.validation.engine import day_boundary, festival_day
|
||||
|
||||
router = APIRouter(prefix="/editions/{edition_id}/badges", tags=["badges"])
|
||||
|
||||
@@ -246,7 +250,7 @@ def set_size(
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Génération du PDF #
|
||||
# --------------------------------------------------------------------------- #
|
||||
AssignmentRow = tuple[Position, Shift, Volunteer | None] # (poste, créneau, référent)
|
||||
AssignmentRow = tuple[Position, Shift]
|
||||
|
||||
|
||||
def _role_label(volunteer_id: uuid.UUID, lead_volunteer_ids: set[uuid.UUID]) -> str:
|
||||
@@ -259,16 +263,29 @@ def _category_label(category) -> str:
|
||||
return t(f"position.category.{category.value.lower()}")
|
||||
|
||||
|
||||
def _position_line(
|
||||
position: Position, shift: Shift, referent: Volunteer | None, volunteer_id: uuid.UUID
|
||||
) -> str:
|
||||
"""Une ligne par créneau affecté : poste + horaire, et le référent du
|
||||
poste s'il y en a un — sauf si le bénévole badgé est lui-même ce
|
||||
référent (pas besoin de se l'indiquer à soi-même)."""
|
||||
line = f"{position.name} {shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}"
|
||||
if referent is not None and referent.id != volunteer_id:
|
||||
line += f" ({t('badge.role.referent')} : {referent.first_name} {referent.last_name})"
|
||||
return line
|
||||
def _day_label(day: dt.date) -> str:
|
||||
"""En-tête d'un jour dans le tableau des créneaux : « Samedi 11 »."""
|
||||
return t("badge.table.day_label", weekday=t(f"badge.weekday.{day.weekday()}"), day=day.day)
|
||||
|
||||
|
||||
def _positions_table(assignments: list[AssignmentRow], boundary: dt.time) -> BadgeTable | None:
|
||||
"""Créneaux affectés regroupés par jour de festival (une soirée qui
|
||||
passe minuit reste sur le jour où elle a commencé — `festival_day`) :
|
||||
le jour en en-tête, puis une ligne horaire / poste par créneau, dans
|
||||
l'ordre chronologique. `None` sans aucun créneau (rien à dessiner)."""
|
||||
by_day: dict[dt.date, list[tuple[str, str]]] = defaultdict(list)
|
||||
for position, shift in assignments:
|
||||
by_day[festival_day(shift.starts_at, boundary)].append(
|
||||
(f"{shift.starts_at:%H:%M}–{shift.ends_at:%H:%M}", position.name)
|
||||
)
|
||||
if not by_day:
|
||||
return None
|
||||
return BadgeTable(
|
||||
sections=tuple(
|
||||
BadgeTableSection(title=_day_label(day), rows=tuple(rows))
|
||||
for day, rows in sorted(by_day.items())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _field_text(
|
||||
@@ -277,19 +294,18 @@ def _field_text(
|
||||
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]],
|
||||
lead_volunteer_ids: set[uuid.UUID],
|
||||
) -> str:
|
||||
"""Texte d'un champ — `POSITIONS` n'en a pas : c'est un tableau
|
||||
(`_positions_table`)."""
|
||||
if field.field_type == BadgeFieldType.FULL_NAME:
|
||||
return f"{volunteer.first_name} {volunteer.last_name}"
|
||||
if field.field_type == BadgeFieldType.FREE_TEXT:
|
||||
return volunteer.badge_text or ""
|
||||
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
|
||||
# le « Texte du badge » de sa fiche s'il en a un.
|
||||
return volunteer.badge_text or field.text or ""
|
||||
assignments = assignments_by_volunteer.get(volunteer.id, [])
|
||||
if field.field_type == BadgeFieldType.POSITIONS:
|
||||
return "\n".join(
|
||||
_position_line(position, shift, referent, volunteer.id)
|
||||
for position, shift, referent in assignments
|
||||
)
|
||||
if field.field_type == BadgeFieldType.CATEGORY:
|
||||
categories = sorted(
|
||||
{_category_label(position.category) for position, _shift, _r in assignments}
|
||||
{_category_label(position.category) for position, _shift in assignments}
|
||||
)
|
||||
return ", ".join(categories)
|
||||
if field.field_type == BadgeFieldType.ROLE:
|
||||
@@ -317,20 +333,20 @@ def generate_badges(
|
||||
raise HTTPException(status_code=422, detail={"code": "badge.no_volunteers"})
|
||||
|
||||
volunteer_ids = [v.id for v in volunteers]
|
||||
Referent = aliased(Volunteer)
|
||||
rows = session.exec(
|
||||
select(Assignment.volunteer_id, Position, Shift, Referent)
|
||||
select(Assignment.volunteer_id, Position, Shift)
|
||||
.join(Shift, Shift.id == Assignment.shift_id)
|
||||
.join(Position, Position.id == Shift.position_id)
|
||||
.join(Account, Account.id == Position.lead_account_id, isouter=True)
|
||||
.join(Referent, Referent.id == Account.volunteer_id, isouter=True)
|
||||
.where(Assignment.volunteer_id.in_(volunteer_ids))
|
||||
.order_by(Shift.starts_at)
|
||||
).all()
|
||||
assignments_by_volunteer: dict[uuid.UUID, list[AssignmentRow]] = defaultdict(list)
|
||||
for volunteer_id, position, shift, referent in rows:
|
||||
assignments_by_volunteer[volunteer_id].append((position, shift, referent))
|
||||
for volunteer_id, position, shift in rows:
|
||||
assignments_by_volunteer[volunteer_id].append((position, shift))
|
||||
|
||||
boundary = day_boundary(
|
||||
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
|
||||
)
|
||||
lead_volunteer_ids = {
|
||||
account.volunteer_id
|
||||
for account in session.exec(
|
||||
@@ -362,6 +378,11 @@ def generate_badges(
|
||||
image=volunteer.photo
|
||||
if field.field_type == BadgeFieldType.PHOTO
|
||||
else None,
|
||||
table=_positions_table(
|
||||
assignments_by_volunteer.get(volunteer.id, []), boundary
|
||||
)
|
||||
if field.field_type == BadgeFieldType.POSITIONS
|
||||
else None,
|
||||
)
|
||||
for field in fields
|
||||
)
|
||||
|
||||
@@ -355,6 +355,8 @@ class BadgeFieldIn(ApiModel):
|
||||
align: BadgeAlign = BadgeAlign.LEFT
|
||||
bold: bool = False
|
||||
color: str = "#000000"
|
||||
# `FREE_TEXT` seulement (voir `BadgeField.text`), ignoré sinon.
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class BadgeFieldsSet(ApiModel):
|
||||
|
||||
+158
-3
@@ -23,6 +23,7 @@ from reportlab.lib.colors import HexColor
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
DEFAULT_BADGE_WIDTH_MM = 85.6
|
||||
@@ -30,6 +31,11 @@ DEFAULT_BADGE_HEIGHT_MM = 54.0
|
||||
_MARGIN_MM = 10.0
|
||||
_GUTTER_MM = 4.0
|
||||
_LINE_HEIGHT_FACTOR = 1.25
|
||||
# Tableau (champ « postes ») : marges intérieures des cellules et épaisseur
|
||||
# des filets.
|
||||
_CELL_PAD_X_MM = 0.8
|
||||
_CELL_PAD_Y_MM = 0.5
|
||||
_TABLE_LINE_WIDTH_PT = 0.4
|
||||
|
||||
# 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
|
||||
@@ -47,6 +53,23 @@ def image_size_mm(width_px: int, height_px: int, dpi: float = ASSUMED_DPI) -> tu
|
||||
return (width_px / dpi * 25.4, height_px / dpi * 25.4)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeTableSection:
|
||||
"""Un bloc du tableau : une ligne d'en-tête sur toute la largeur (le
|
||||
jour), puis ses lignes (horaire, poste)."""
|
||||
|
||||
title: str
|
||||
rows: tuple[tuple[str, ...], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeTable:
|
||||
"""Contenu d'un champ affiché en tableau plutôt qu'en texte libre : les
|
||||
créneaux du bénévole regroupés par jour."""
|
||||
|
||||
sections: tuple[BadgeTableSection, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BadgeFieldSpec:
|
||||
"""Un champ déjà résolu en texte (plusieurs lignes séparées par `\\n`
|
||||
@@ -55,7 +78,9 @@ class BadgeFieldSpec:
|
||||
|
||||
`is_photo` : le champ est la photo du bénévole (`image`, ses octets
|
||||
bruts tels que stockés) plutôt qu'un texte — `text` et le style sont
|
||||
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné."""
|
||||
alors ignorés. Sans photo (`image` à `None`), rien n'est dessiné.
|
||||
|
||||
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`."""
|
||||
|
||||
text: str
|
||||
x_mm: float
|
||||
@@ -68,6 +93,7 @@ class BadgeFieldSpec:
|
||||
color: str # "#rrggbb"
|
||||
is_photo: bool = False
|
||||
image: bytes | None = None
|
||||
table: BadgeTable | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -162,6 +188,130 @@ def _draw_photo(
|
||||
)
|
||||
|
||||
|
||||
def wrap_text(text: str, font_name: str, font_size: float, max_width_pt: float) -> list[str]:
|
||||
"""Découpe `text` en lignes qui tiennent dans `max_width_pt` : les `\\n`
|
||||
du texte restent des retours à la ligne, puis chaque ligne trop longue
|
||||
passe à la ligne entre deux mots — ou au milieu d'un mot qui, seul, ne
|
||||
tient pas (nom très long dans un cadre étroit). Sans cela, un prénom +
|
||||
nom ou une ligne de poste avec horaire débordait du cadre du champ."""
|
||||
|
||||
def fits(candidate: str) -> bool:
|
||||
return stringWidth(candidate, font_name, font_size) <= max_width_pt
|
||||
|
||||
lines: list[str] = []
|
||||
for paragraph in text.split("\n"):
|
||||
current = ""
|
||||
# `split(" ")` (et non `split()`) garde les doubles espaces voulus,
|
||||
# ex. entre le nom du poste et son horaire.
|
||||
for word in paragraph.split(" "):
|
||||
candidate = f"{current} {word}" if current else word
|
||||
if fits(candidate):
|
||||
current = candidate
|
||||
continue
|
||||
if current:
|
||||
lines.append(current.rstrip())
|
||||
current = word
|
||||
while current and not fits(current):
|
||||
cut = len(current) - 1
|
||||
while cut > 1 and not fits(current[:cut]):
|
||||
cut -= 1
|
||||
lines.append(current[:cut])
|
||||
current = current[cut:]
|
||||
lines.append(current.rstrip())
|
||||
return lines
|
||||
|
||||
|
||||
def _column_widths(natural: list[float], available: float) -> list[float]:
|
||||
"""Largeurs des colonnes d'un tableau dans `available` : une colonne
|
||||
courte (jour, horaire) garde sa largeur naturelle, les plus longues
|
||||
(poste, référent) se partagent le reste à parts égales et passent à la
|
||||
ligne. S'il reste de la place, la dernière colonne la prend."""
|
||||
widths = [0.0] * len(natural)
|
||||
pending = sorted(range(len(natural)), key=lambda i: natural[i])
|
||||
remaining = available
|
||||
while pending:
|
||||
share = remaining / len(pending)
|
||||
if natural[pending[0]] > share:
|
||||
for i in pending:
|
||||
widths[i] = share
|
||||
return widths
|
||||
i = pending.pop(0)
|
||||
widths[i] = natural[i]
|
||||
remaining -= natural[i]
|
||||
widths[-1] += remaining
|
||||
return widths
|
||||
|
||||
|
||||
def _draw_table(
|
||||
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float
|
||||
) -> None:
|
||||
"""Tableau dans le cadre du champ : pour chaque section, une ligne
|
||||
d'en-tête en gras sur toute la largeur (le jour), puis ses lignes ;
|
||||
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
|
||||
cadre n'est pas dessinée, ni les suivantes — comme les lignes d'un champ
|
||||
texte."""
|
||||
table = f.table
|
||||
if table is None or not table.sections:
|
||||
return
|
||||
regular, bold = "Helvetica", "Helvetica-Bold"
|
||||
pad_x, pad_y = _CELL_PAD_X_MM * mm, _CELL_PAD_Y_MM * mm
|
||||
line_height = f.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, f.font_size) for row in body) + 2 * pad_x
|
||||
for col in range(columns)
|
||||
] if body else [0.0]
|
||||
widths = _column_widths(natural, f.width_mm * mm)
|
||||
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
|
||||
top = (y0_mm + badge_height_mm - f.y_mm) * mm
|
||||
bottom_limit = top - f.height_mm * mm
|
||||
color = HexColor(f.color)
|
||||
c.setFillColor(color)
|
||||
c.setStrokeColor(color)
|
||||
c.setLineWidth(_TABLE_LINE_WIDTH_PT)
|
||||
|
||||
y = top
|
||||
for cells, font, is_title in lines_to_draw:
|
||||
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:
|
||||
break # la ligne déborderait du cadre du champ
|
||||
c.setFont(font, f.font_size)
|
||||
x = left
|
||||
for col, lines in enumerate(wrapped):
|
||||
for i, line in enumerate(lines):
|
||||
baseline = y - pad_y - f.font_size * 0.85 - i * line_height
|
||||
c.drawString(x + pad_x, baseline, line)
|
||||
x += cell_widths[col]
|
||||
if not is_title:
|
||||
# Séparations de colonnes, sur la hauteur de cette ligne seulement.
|
||||
x = left
|
||||
for width in widths[:-1]:
|
||||
x += width
|
||||
c.line(x, y, x, y - row_height)
|
||||
y -= row_height
|
||||
c.line(left, y, left + total_width, y)
|
||||
|
||||
if y == top:
|
||||
return # pas même le premier en-tête ne tient
|
||||
c.rect(left, y, total_width, top - y, stroke=1, fill=0)
|
||||
|
||||
|
||||
def _draw_fields(
|
||||
c: canvas.Canvas,
|
||||
fields: tuple[BadgeFieldSpec, ...],
|
||||
@@ -173,7 +323,11 @@ def _draw_fields(
|
||||
if f.is_photo:
|
||||
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm)
|
||||
continue
|
||||
c.setFont("Helvetica-Bold" if f.bold else "Helvetica", f.font_size)
|
||||
if f.table is not None:
|
||||
_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
|
||||
@@ -185,7 +339,8 @@ def _draw_fields(
|
||||
# dessine depuis le BAS de la page.
|
||||
top_y = y0_mm + badge_height_mm - f.y_mm
|
||||
bottom_limit = top_y - f.height_mm
|
||||
for i, line in enumerate(f.text.split("\n")):
|
||||
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
|
||||
|
||||
@@ -65,6 +65,14 @@
|
||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||
"badge.role.referent": "Référent",
|
||||
"badge.role.volunteer": "Bénévole",
|
||||
"badge.table.day_label": "{weekday} {day}",
|
||||
"badge.weekday.0": "Lundi",
|
||||
"badge.weekday.1": "Mardi",
|
||||
"badge.weekday.2": "Mercredi",
|
||||
"badge.weekday.3": "Jeudi",
|
||||
"badge.weekday.4": "Vendredi",
|
||||
"badge.weekday.5": "Samedi",
|
||||
"badge.weekday.6": "Dimanche",
|
||||
"position.category.bar": "Bar",
|
||||
"position.category.ticketing": "Billetterie",
|
||||
"position.category.welcome": "Accueil",
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Texte commun du champ libre des badges
|
||||
|
||||
Revision ID: 0025_badge_field_text
|
||||
Revises: 0024_volunteer_badge_text
|
||||
Create Date: 2026-10-27
|
||||
|
||||
`badge_field.text` : texte du champ `FREE_TEXT` saisi dans l'éditeur de
|
||||
badges, imprimé sur tous les badges — sauf ceux dont la fiche porte un
|
||||
`volunteer.badge_text`, qui le remplace pour ce bénévole.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0025_badge_field_text"
|
||||
down_revision: str | None = "0024_volunteer_badge_text"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("badge_field", sa.Column("text", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("badge_field", "text")
|
||||
@@ -183,8 +183,9 @@ class Volunteer(TimestampMixin, table=True):
|
||||
# cette note sur la fiche et la transforme, si besoin, en un vrai
|
||||
# `VolunteerCompanionWish` une fois les deux bénévoles inscrits.
|
||||
companion_wish_note: str | None = None
|
||||
# Texte libre imprimé sur le badge (champ `BadgeFieldType.FREE_TEXT`),
|
||||
# saisi par la coordination sur la fiche — ex. « Accès backstage ».
|
||||
# Texte du badge propre à ce bénévole (champ `BadgeFieldType.FREE_TEXT`),
|
||||
# saisi par la coordination sur la fiche — ex. « Accès backstage ». Vide :
|
||||
# le texte commun de l'éditeur (`BadgeField.text`) s'applique.
|
||||
badge_text: str | None = None
|
||||
# Cases à cocher du formulaire public — purement informatives : aucune
|
||||
# logique n'y est attachée (pas de workflow de paiement).
|
||||
@@ -466,6 +467,10 @@ class BadgeField(TimestampMixin, table=True):
|
||||
sa_column=Column(Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
color: str = Field(default="#000000")
|
||||
# Champ `FREE_TEXT` seulement : texte saisi dans l'éditeur, imprimé sur
|
||||
# tous les badges — sauf ceux dont la fiche porte un « Texte du badge »
|
||||
# (`Volunteer.badge_text`), qui le remplace pour ce bénévole.
|
||||
text: str | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -39,8 +39,9 @@ class BadgeFieldType(enum.StrEnum):
|
||||
ROLE = "ROLE"
|
||||
# Photo du bénévole (`Volunteer.photo`), ajustée au cadre du champ.
|
||||
PHOTO = "PHOTO"
|
||||
# Texte libre propre à chaque bénévole, saisi par la coordination sur sa
|
||||
# fiche (`Volunteer.badge_text`).
|
||||
# Texte libre saisi par la coordination dans l'éditeur (`BadgeField.text`,
|
||||
# commun à tous les badges), remplacé pour un bénévole par le « Texte du
|
||||
# badge » de sa fiche (`Volunteer.badge_text`) s'il en a un.
|
||||
FREE_TEXT = "FREE_TEXT"
|
||||
|
||||
|
||||
|
||||
@@ -221,12 +221,15 @@ def test_generating_with_photo_and_free_text_fields(client, edition, session):
|
||||
{
|
||||
"field_type": "FREE_TEXT", "x_mm": 5, "y_mm": 40,
|
||||
"width_mm": 50, "height_mm": 8, "font_size": 9,
|
||||
"text": "Staff 2026",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert {f["field_type"] for f in resp.json()} == {"PHOTO", "FREE_TEXT"}
|
||||
by_type = {f["field_type"]: f for f in resp.json()}
|
||||
assert set(by_type) == {"PHOTO", "FREE_TEXT"}
|
||||
assert by_type["FREE_TEXT"]["text"] == "Staff 2026"
|
||||
|
||||
with_photo = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
|
||||
with_photo.photo = _PNG
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import datetime as dt
|
||||
import uuid
|
||||
|
||||
from app.api.badges import _field_text, _position_line
|
||||
from app.api.badges import _field_text, _positions_table
|
||||
from app.models import (
|
||||
BadgeField,
|
||||
BadgeFieldType,
|
||||
@@ -29,65 +29,45 @@ def _shift(start: dt.datetime, end: dt.datetime) -> Shift:
|
||||
)
|
||||
|
||||
|
||||
def test_position_line_shows_the_schedule():
|
||||
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
||||
position = _position(name="Bar central")
|
||||
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
|
||||
|
||||
line = _position_line(position, shift, referent=None, volunteer_id=volunteer.id)
|
||||
|
||||
assert line == "Bar central 19:00–22:00"
|
||||
_BOUNDARY = dt.time(2, 0) # journée de festival de 02:00 à 02:00
|
||||
|
||||
|
||||
def test_position_line_shows_the_referent_when_there_is_one():
|
||||
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
||||
position = _position(name="Bar central")
|
||||
shift = _shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0))
|
||||
referent = _volunteer(first_name="Sam", last_name="Nuit")
|
||||
|
||||
line = _position_line(position, shift, referent=referent, volunteer_id=volunteer.id)
|
||||
|
||||
assert line == "Bar central 19:00–22:00 (Référent : Sam Nuit)"
|
||||
|
||||
|
||||
def test_position_line_hides_the_referent_when_it_is_the_badged_volunteer():
|
||||
# Pas besoin de se dire à soi-même qu'on est referent de son propre poste.
|
||||
volunteer = _volunteer(first_name="Sam", last_name="Nuit")
|
||||
|
||||
line = _position_line(
|
||||
_position(name="Bar central"),
|
||||
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
|
||||
referent=volunteer,
|
||||
volunteer_id=volunteer.id,
|
||||
)
|
||||
|
||||
assert "Référent" not in line
|
||||
|
||||
|
||||
def test_field_text_positions_lists_one_line_per_shift_in_chronological_order():
|
||||
volunteer = _volunteer(first_name="Alex", last_name="Martin")
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.POSITIONS,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9,
|
||||
)
|
||||
morning = (
|
||||
def test_positions_table_groups_shifts_under_their_day():
|
||||
accueil = (
|
||||
_position(name="Accueil"),
|
||||
_shift(dt.datetime(2026, 7, 9, 9, 0), dt.datetime(2026, 7, 9, 13, 0)),
|
||||
None,
|
||||
_shift(dt.datetime(2026, 7, 11, 9, 0), dt.datetime(2026, 7, 11, 13, 0)),
|
||||
)
|
||||
evening = (
|
||||
bar = (
|
||||
_position(name="Bar central"),
|
||||
_shift(dt.datetime(2026, 7, 9, 19, 0), dt.datetime(2026, 7, 9, 22, 0)),
|
||||
None,
|
||||
_shift(dt.datetime(2026, 7, 11, 19, 0), dt.datetime(2026, 7, 11, 22, 0)),
|
||||
)
|
||||
demontage = (
|
||||
_position(name="Démontage"),
|
||||
_shift(dt.datetime(2026, 7, 12, 14, 0), dt.datetime(2026, 7, 12, 18, 0)),
|
||||
)
|
||||
|
||||
text = _field_text(
|
||||
field, volunteer,
|
||||
assignments_by_volunteer={volunteer.id: [morning, evening]},
|
||||
lead_volunteer_ids=set(),
|
||||
table = _positions_table([accueil, bar, demontage], _BOUNDARY)
|
||||
|
||||
assert [(s.title, s.rows) for s in table.sections] == [
|
||||
("Samedi 11", (("09:00–13:00", "Accueil"), ("19:00–22:00", "Bar central"))),
|
||||
("Dimanche 12", (("14:00–18:00", "Démontage"),)),
|
||||
]
|
||||
|
||||
|
||||
def test_positions_table_keeps_a_night_shift_on_the_day_it_started():
|
||||
night = (
|
||||
_position(name="Bar central"),
|
||||
_shift(dt.datetime(2026, 7, 12, 0, 0), dt.datetime(2026, 7, 12, 1, 0)),
|
||||
)
|
||||
|
||||
assert text == "Accueil 09:00–13:00\nBar central 19:00–22:00"
|
||||
table = _positions_table([night], _BOUNDARY)
|
||||
|
||||
assert table.sections[0].title == "Samedi 11"
|
||||
assert table.sections[0].rows == (("00:00–01:00", "Bar central"),)
|
||||
|
||||
|
||||
def test_positions_table_is_empty_without_any_shift():
|
||||
assert _positions_table([], _BOUNDARY) is None
|
||||
|
||||
|
||||
def test_field_text_role_is_referent_only_for_lead_accounts():
|
||||
@@ -111,3 +91,16 @@ def test_field_text_free_text_is_the_volunteer_badge_text():
|
||||
|
||||
assert _field_text(field, with_text, {}, set()) == "Accès backstage"
|
||||
assert _field_text(field, without, {}, set()) == ""
|
||||
|
||||
|
||||
def test_field_text_free_text_falls_back_to_the_common_editor_text():
|
||||
field = BadgeField(
|
||||
edition_id=uuid.uuid4(), field_type=BadgeFieldType.FREE_TEXT,
|
||||
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=8, color="#000000",
|
||||
text="Staff 2026",
|
||||
)
|
||||
own = _volunteer(first_name="Alex", last_name="Martin", badge_text="Accès backstage")
|
||||
other = _volunteer(first_name="Sam", last_name="Nuit")
|
||||
|
||||
assert _field_text(field, own, {}, set()) == "Accès backstage"
|
||||
assert _field_text(field, other, {}, set()) == "Staff 2026"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
|
||||
from app.badges.pdf import (
|
||||
ASSUMED_DPI,
|
||||
@@ -10,11 +12,15 @@ from app.badges.pdf import (
|
||||
DEFAULT_BADGE_WIDTH_MM,
|
||||
BadgeFieldSpec,
|
||||
BadgeSpec,
|
||||
BadgeTable,
|
||||
BadgeTableSection,
|
||||
_cell_origin,
|
||||
_column_widths,
|
||||
_fit_photo,
|
||||
_grid_size,
|
||||
generate_badge_sheets_pdf,
|
||||
image_size_mm,
|
||||
wrap_text,
|
||||
)
|
||||
|
||||
|
||||
@@ -151,3 +157,82 @@ def test_unreadable_photo_does_not_crash_generation():
|
||||
[_photo_badge(b"pas une image")], front_image=None, back_image=None
|
||||
)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_wrap_text_keeps_a_short_text_on_one_line():
|
||||
assert wrap_text("Alex Martin", "Helvetica", 12, 60 * mm) == ["Alex Martin"]
|
||||
|
||||
|
||||
def test_wrap_text_breaks_between_words_to_fit_the_width():
|
||||
# Assez large pour « Marie-Christine » seul, trop étroit pour le tout.
|
||||
width = 40 * mm
|
||||
lines = wrap_text("Marie-Christine de La Fontaine", "Helvetica-Bold", 12, width)
|
||||
|
||||
assert len(lines) > 1
|
||||
assert " ".join(lines) == "Marie-Christine de La Fontaine"
|
||||
assert all(stringWidth(line, "Helvetica-Bold", 12) <= width for line in lines)
|
||||
|
||||
|
||||
def test_wrap_text_keeps_explicit_line_breaks_and_wraps_each_line():
|
||||
text = "Bar central 19:00–22:00 (Référent : Sam Nuit)\nAccueil 09:00–13:00"
|
||||
width = 40 * mm
|
||||
lines = wrap_text(text, "Helvetica", 8, width)
|
||||
|
||||
assert lines[0].startswith("Bar central 19:00–22:00")
|
||||
assert "Accueil 09:00–13:00" in lines
|
||||
assert all(stringWidth(line, "Helvetica", 8) <= width for line in lines)
|
||||
|
||||
|
||||
def test_wrap_text_cuts_a_single_word_too_long_for_the_frame():
|
||||
width = 10 * mm
|
||||
lines = wrap_text("Anticonstitutionnellement", "Helvetica", 12, width)
|
||||
|
||||
assert "".join(lines) == "Anticonstitutionnellement"
|
||||
assert all(stringWidth(line, "Helvetica", 12) <= width for line in lines)
|
||||
|
||||
|
||||
def _table_badge(rows: int, height_mm: float = 30) -> BadgeSpec:
|
||||
return BadgeSpec(
|
||||
fields=(
|
||||
BadgeFieldSpec(
|
||||
text="", x_mm=5, y_mm=5, width_mm=60, height_mm=height_mm,
|
||||
font_size=7, align="LEFT", bold=False, color="#000000",
|
||||
table=BadgeTable(
|
||||
sections=(
|
||||
BadgeTableSection(
|
||||
title="Samedi 11",
|
||||
rows=tuple(
|
||||
("19:00–22:00", "Bar central très très long")
|
||||
for _ in range(rows)
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_column_widths_keep_short_columns_and_share_the_rest():
|
||||
widths = _column_widths([20, 30, 200, 100], available=200)
|
||||
|
||||
assert widths[:2] == [20, 30]
|
||||
assert widths[2] == widths[3] == 75
|
||||
assert sum(widths) == 200
|
||||
|
||||
|
||||
def test_column_widths_give_the_spare_room_to_the_last_column():
|
||||
assert _column_widths([20, 30, 40], available=150) == [20, 30, 100]
|
||||
|
||||
|
||||
def test_table_field_generates_a_valid_pdf():
|
||||
pdf = generate_badge_sheets_pdf([_table_badge(3)], front_image=None, back_image=None)
|
||||
assert pdf.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_table_rows_beyond_the_frame_are_not_drawn():
|
||||
# Cadre trop petit pour 50 lignes : celles qui débordent sont omises,
|
||||
# la planche fait la même taille qu'avec quelques lignes seulement.
|
||||
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)
|
||||
assert abs(len(many) - len(few)) < 50
|
||||
|
||||
@@ -444,6 +444,9 @@ export interface BadgeFieldIn {
|
||||
align: BadgeAlign;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
|
||||
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
|
||||
text?: string | null;
|
||||
}
|
||||
|
||||
export interface BadgeFieldsSet {
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
"config.vol.field.phone": "Téléphone",
|
||||
"config.vol.field.comment": "Commentaire",
|
||||
"config.vol.field.badgeText": "Texte du badge",
|
||||
"config.vol.field.badgeText.help": "Imprimé sur le badge si le champ « Texte libre » y est placé (écran Badges).",
|
||||
"config.vol.field.badgeText.help": "Remplace, pour ce bénévole seulement, le texte libre commun saisi dans l'écran Badges. Vide = texte commun.",
|
||||
"config.vol.photo.title": "Photo",
|
||||
"config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.",
|
||||
"config.vol.photo.none": "Aucune photo",
|
||||
@@ -355,8 +355,12 @@
|
||||
"badges.fields": "Champs du recto",
|
||||
"badges.field.fullName": "Prénom + nom",
|
||||
"badges.field.fullName.placeholder": "Prénom Nom",
|
||||
"badges.field.positions": "Poste(s) affecté(s)",
|
||||
"badges.field.positions": "Créneaux affectés (tableau)",
|
||||
"badges.field.positions.placeholder": "Bar central 19:00–22:00\nAccueil 09:00–13:00 (Référent : Sam Nuit)",
|
||||
"badges.field.positions.sample1.day": "Vendredi 10",
|
||||
"badges.field.positions.sample1.rows": "18:00–22:00|Bar central",
|
||||
"badges.field.positions.sample2.day": "Samedi 11",
|
||||
"badges.field.positions.sample2.rows": "09:00–13:00|Accueil\n23:00–01:00|Bar central",
|
||||
"badges.field.category": "Catégorie",
|
||||
"badges.field.category.placeholder": "Bar",
|
||||
"badges.field.role": "Rôle (référent / bénévole)",
|
||||
@@ -366,7 +370,8 @@
|
||||
"badges.field.photo.help": "La photo du bénévole (envoyée avec le formulaire d'inscription ou ajoutée sur sa fiche), recadrée au format du cadre. Rien n'est imprimé pour un bénévole sans photo.",
|
||||
"badges.field.freeText": "Texte libre",
|
||||
"badges.field.freeText.placeholder": "Accès backstage",
|
||||
"badges.field.freeText.help": "Texte propre à chaque bénévole, à saisir sur sa fiche (Configuration › Bénévoles, « Texte du badge »). Vide = rien d'imprimé.",
|
||||
"badges.field.freeText.help": "Texte saisi ci-dessous, imprimé sur tous les badges. Pour un bénévole en particulier, le « Texte du badge » de sa fiche (Configuration › Bénévoles) le remplace.",
|
||||
"badges.field.freeText.input": "Texte à imprimer (ex. STAFF 2026)",
|
||||
"badges.field.size": "Taille",
|
||||
"badges.editor.hint": "Badge {width} × {height} mm. Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
|
||||
"badges.size.width": "Largeur du badge (mm)",
|
||||
|
||||
@@ -117,6 +117,24 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/** Ramène un champ à l'intérieur du badge (réduit s'il est plus grand,
|
||||
* décalé s'il dépasse). Les positions par défaut sont pensées pour un CR80
|
||||
* paysage (85,6 × 54 mm) : sur un badge plus étroit (portrait…), un champ
|
||||
* activé pouvait tomber hors du badge — invisible, et les bornes négatives
|
||||
* du glisser-déposer le bloquaient (bug corrigé une fois : photo
|
||||
* impossible à déplacer ni redimensionner). */
|
||||
function fitToBadge<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>;
|
||||
@@ -168,19 +186,28 @@ function BadgeCustomizeTab() {
|
||||
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
|
||||
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
|
||||
}, [template.data]);
|
||||
|
||||
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
||||
const 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]: { ...prev[type], ...patch } } : prev));
|
||||
setFieldsState((prev) =>
|
||||
prev
|
||||
? { ...prev, [type]: fitToBadge({ ...prev[type], ...patch }, badgeW, badgeH) }
|
||||
: prev,
|
||||
);
|
||||
// Un champ qu'on vient d'activer passe au premier plan, sélectionné.
|
||||
if (patch.enabled) setSelected(type);
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
@@ -197,6 +224,7 @@ function BadgeCustomizeTab() {
|
||||
align: f.align,
|
||||
bold: f.bold,
|
||||
color: f.color,
|
||||
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -434,8 +462,8 @@ function FieldBox({
|
||||
const dxMm = (e.clientX - d.pointerXPx) / scale;
|
||||
const dyMm = (e.clientY - d.pointerYPx) / scale;
|
||||
onChange({
|
||||
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
|
||||
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
|
||||
x_mm: clamp(d.startXMm + dxMm, 0, Math.max(0, badgeW - field.width_mm)),
|
||||
y_mm: clamp(d.startYMm + dyMm, 0, Math.max(0, badgeH - field.height_mm)),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -451,9 +479,11 @@ function FieldBox({
|
||||
const d = resizeRef.current;
|
||||
const dwMm = (e.clientX - d.pointerXPx) / scale;
|
||||
const dhMm = (e.clientY - d.pointerYPx) / scale;
|
||||
const maxW = badgeW - field.x_mm;
|
||||
const maxH = badgeH - field.y_mm;
|
||||
onChange({
|
||||
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
|
||||
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
|
||||
width_mm: clamp(d.startWMm + dwMm, Math.min(10, maxW), maxW),
|
||||
height_mm: clamp(d.startHMm + dhMm, Math.min(5, maxH), maxH),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -471,9 +501,14 @@ function FieldBox({
|
||||
top: field.y_mm * scale,
|
||||
width: field.width_mm * scale,
|
||||
height: field.height_mm * scale,
|
||||
// Le champ sélectionné passe devant les autres : sinon un champ
|
||||
// recouvert (ex. la photo sous un texte) ne se saisit plus.
|
||||
zIndex: selected ? 10 : undefined,
|
||||
}}
|
||||
>
|
||||
{field.field_type === 'PHOTO' ? (
|
||||
{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>
|
||||
@@ -488,7 +523,9 @@ function FieldBox({
|
||||
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
|
||||
}}
|
||||
>
|
||||
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
||||
{field.field_type === 'FREE_TEXT' && field.text?.trim()
|
||||
? field.text
|
||||
: t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
@@ -501,6 +538,47 @@ function FieldBox({
|
||||
);
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -533,6 +611,16 @@ function FieldEditorRow({
|
||||
) : 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' ? (
|
||||
|
||||
Reference in New Issue
Block a user