Fiches de poste rapides, badges sans initiales, vue par bénévole sur l'axe des temps
ci-cd / build-backend (push) Successful in 34s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 11s

Étape 62 : les fiches de poste intégraient la photo d'origine de chaque
bénévole pour chaque créneau (CPU à 300 %, puis erreur) — photos réduites
à la vignette une fois par PDF ; badges : photo recadrée une fois par
génération et décodée à taille réduite.

Étape 61 : sans photo, le cadre de la photo reste vide sur le badge.

Étape 60 : vue par bénévole en tranches de 30 min, affectations étirées
sur toute leur durée, postes simultanés empilés, ligne de l'heure actuelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-28 19:01:48 +01:00
co-authored by Claude Opus 5.5
parent c57c70569c
commit c6ea7378c7
13 changed files with 424 additions and 296 deletions
+27 -8
View File
@@ -247,9 +247,10 @@ les tables sauf les tables d'association.
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, les **initiales** du bénévole sont imprimées à la
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
photo illisible, **rien n'est imprimé** (cadre vide, choix de la
coordination — avant l'étape 61, les initiales sur fond beige), sans
faire échouer la planche ; l'éditeur n'y montre qu'un repère pointillé
« sans photo », non imprimé ; `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
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
@@ -280,7 +281,16 @@ les tables sauf les tables d'association.
avec les bénévoles affectés et leur téléphone, pour affichage sur place.
`POST /editions/{id}/position-sheets/generate` (COORDINATION,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune
affectation n'apparaît pas (rien à imprimer).
affectation n'apparaît pas (rien à imprimer). Photo de chaque bénévole en
vignette de 14 mm : réduite à 300 dpi (`app/sheets/pdf.py:_thumbnail`,
décodage JPEG à taille réduite via `draft`) une seule fois par PDF, une
photo illisible laissant la case vide. Avant l'étape 62, la photo
d'origine (plusieurs Mo, prise au téléphone) était intégrée telle quelle
pour chaque bénévole de chaque créneau : génération de plusieurs minutes,
serveur à 300 % de CPU, PDF énorme et « erreur inattendue » (bug corrigé
une fois). Même principe pour les badges (`_fit_photo` + `PhotoCache` :
une photo recadrée une fois par génération, malgré recto, verso et
exemplaires multiples).
- **Statistiques** (`GET /editions/{id}/stats`, COORDINATION,
`app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune
nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total,
@@ -786,10 +796,16 @@ bug. Largeur adaptative par jour (`columnWidthPx`,
en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges,
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
étroites par défaut.
La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle,
distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`)
plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus
adapté à son tableau bénévole × créneau.
La « Vue par bénévole » (`VolunteerViewPage.tsx`) utilise le même axe des
temps (`buildTimeGrid` / `placeInTimeGrid`, même largeur de tranche par
jour) : chaque affectation est une barre étirée sur toute la durée de son
créneau, nom du poste dedans. Deux postes tenus en même temps s'empilent
sur deux lignes dans la rangée du bénévole (qui s'agrandit) et passent en
jaune. Ligne verticale orange à l'heure actuelle, avec l'heure dans
l'en-tête des tranches. Avant l'étape 60, une colonne par créneau distinct
(`buildLattice`, supprimé) : une affectation n'apparaissait que dans la
colonne de son propre créneau, jamais sur les autres colonnes qu'elle
couvrait, et deux postes simultanés se masquaient.
---
@@ -1035,6 +1051,9 @@ Aucune dépendance locale requise hors Docker.
| 57 | Référent aussi bénévole : onglets « Mon équipe » / « Mon planning » sur l'écran référent (compte rattaché à une fiche bénévole), ouverture sur celui qui correspond à ce qu'il fait en ce moment, pastille sur l'onglet dont un créneau est en cours, mention « TOI » dans l'équipe pointée | à valider |
| 58 | Pointage enregistré : présent / absent du référent sauvegardé sur l'affectation (`Assignment.attendance` + heure, migration `0030`, `PUT .../assignments/{id}/attendance`, COORDINATION ou référent du poste) — il survivait seulement jusqu'au rechargement de la page ; heure du pointage affichée, message si l'enregistrement échoue | à valider |
| 59 | Statistiques de présence : section « Présence » de l'écran Statistiques — en ce moment (créneaux en cours : attendus, présents, absents, non pointés par poste) et créneaux terminés (même détail + taux de présence parmi les pointés), rafraîchie toutes les 30 s, en tête de l'écran pendant un créneau | à valider |
| 60 | Vue par bénévole sur un vrai axe des temps (tranches de 30 min par jour, comme la grille des besoins) : affectations étirées sur toute leur durée avec le nom du poste, deux postes simultanés empilés sur deux lignes et signalés en jaune, ligne verticale orange de l'heure actuelle avec l'heure — une affectation n'apparaissait que dans la colonne de son propre créneau | à valider |
| 61 | Badges : sans photo, le cadre de la photo reste vide (plus d'initiales sur fond beige) — PDF, aperçu de l'éditeur (repère pointillé non imprimé) et messages de contrôle | à valider |
| 62 | Feuilles de poste : génération interminable (CPU à 300 %, puis « erreur inattendue ») — photos d'origine intégrées en pleine résolution pour chaque bénévole de chaque créneau ; désormais réduites à la vignette une fois par PDF (40 photos : 15 s / 24 Mo ⇒ 0,2 s / 109 Ko), photo illisible ignorée. Badges : photo recadrée une seule fois par génération et décodée à taille réduite (120 badges : 10 s ⇒ 0,3 s) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, IA. Frontend : squelette + 6 écrans +
+2 -3
View File
@@ -37,7 +37,6 @@ from app.badges.pdf import (
BadgeTableSection,
generate_badge_sheets_pdf,
image_size_mm,
initials,
)
from app.db import get_session
from app.i18n import t
@@ -320,8 +319,8 @@ def _field_text(
# 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)
# Une image, pas un texte (rien d'imprimé sans photo).
return ""
if field.field_type == BadgeFieldType.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.
+35 -34
View File
@@ -42,8 +42,6 @@ _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
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
@@ -86,9 +84,8 @@ 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, découpée selon
`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.
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), rien
n'est imprimé : le cadre reste vide.
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.
@@ -193,6 +190,9 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
)
try:
with Image.open(io.BytesIO(image)) as img:
# JPEG : décodage directement à taille réduite (bien plus rapide
# qu'une photo de téléphone décodée en pleine résolution).
img.draft("RGB", (target[0] * 2, target[1] * 2))
fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target)
except (UnidentifiedImageError, OSError):
return None
@@ -224,38 +224,37 @@ def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: f
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)
# Photos déjà recadrées pendant une génération, par (photo, largeur,
# hauteur du cadre) : un bénévole a souvent la même photo au recto, au verso
# et sur plusieurs exemplaires (`badge_count`) — elle n'est traitée qu'une
# fois.
PhotoCache = dict[tuple[bytes, float, float], bytes | None]
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,
cache: PhotoCache,
) -> None:
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm)
photo = _fit_photo(f.image, f.width_mm, f.height_mm) if f.image else None
c.saveState()
photo = None
if f.image:
key = (f.image, f.width_mm, f.height_mm)
if key not in cache:
cache[key] = _fit_photo(f.image, f.width_mm, f.height_mm)
photo = cache[key]
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)
# Pas de photo (ou illisible) : cadre laissé vide, choix de la
# coordination — avant, les initiales étaient imprimées à la place.
return
c.saveState()
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()
@@ -495,10 +494,11 @@ def _draw_fields(
x0_mm: float,
y0_mm: float,
badge_height_mm: float,
photos: PhotoCache,
) -> None:
for f in fields:
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, photos)
continue
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
if f.table is not None:
@@ -522,17 +522,18 @@ def generate_badge_sheets_pdf(
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
photos: PhotoCache = {}
for page_badges in pages:
for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
_draw_background(c, front_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.fields, x0, y0, badge_height_mm)
_draw_fields(c, badge.fields, x0, y0, badge_height_mm, photos)
c.showPage()
for i, badge in enumerate(page_badges):
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_fields(c, badge.back_fields, x0, y0, badge_height_mm)
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm, photos)
c.showPage()
c.save()
return buffer.getvalue()
+41 -8
View File
@@ -13,11 +13,12 @@ import datetime as dt
import io
from dataclasses import dataclass
from PIL import Image as PILImage
from PIL import ImageOps, UnidentifiedImageError
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.platypus import (
Image,
PageBreak,
@@ -29,6 +30,8 @@ from reportlab.platypus import (
)
_PHOTO_BOX_MM = 14 * mm
# Côté de la vignette en pixels : 14 mm à 300 dpi.
_PHOTO_BOX_PX = round(14 / 25.4 * 300)
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
_MONTHS_FR = [
@@ -115,16 +118,45 @@ def _styles() -> dict[str, ParagraphStyle]:
}
def _photo_thumbnail(photo: bytes) -> Image:
"""Vignette carrée (14 mm de côté max), proportions préservées."""
width_px, height_px = ImageReader(io.BytesIO(photo)).getSize()
def _thumbnail(photo: bytes) -> tuple[bytes, int, int] | None:
"""Photo réduite à la taille de la vignette (orientation EXIF appliquée),
en JPEG : `(octets, largeur, hauteur)`, `None` si illisible. La photo
d'origine (souvent plusieurs Mo, prise au téléphone) intégrée telle
quelle pour chaque bénévole de chaque créneau faisait tourner la
génération plusieurs minutes et produisait un PDF énorme — jusqu'à
l'erreur (bug corrigé une fois)."""
try:
with PILImage.open(io.BytesIO(photo)) as img:
# JPEG : décodage directement à taille réduite (bien plus rapide).
img.draft("RGB", (_PHOTO_BOX_PX, _PHOTO_BOX_PX))
thumb = ImageOps.exif_transpose(img).convert("RGB")
thumb.thumbnail((_PHOTO_BOX_PX, _PHOTO_BOX_PX))
except (UnidentifiedImageError, OSError, ValueError):
return None
out = io.BytesIO()
thumb.save(out, format="JPEG", quality=85)
return out.getvalue(), thumb.width, thumb.height
def _photo_cell(photo: bytes | None, cache: dict[bytes, tuple[bytes, int, int] | None]):
"""Vignette carrée (14 mm de côté max), proportions préservées. Chaque
photo n'est réduite qu'une fois par PDF (`cache`), même si le bénévole
figure sur plusieurs créneaux."""
if not photo:
return ""
if photo not in cache:
cache[photo] = _thumbnail(photo)
thumb = cache[photo]
if thumb is None:
return ""
data, width_px, height_px = thumb
scale = _PHOTO_BOX_MM / max(width_px, height_px)
return Image(io.BytesIO(photo), width=width_px * scale, height=height_px * scale)
return Image(io.BytesIO(data), width=width_px * scale, height=height_px * scale)
def _shift_table(shift: SheetShift) -> Table:
def _shift_table(shift: SheetShift, cache: dict[bytes, tuple[bytes, int, int] | None]) -> Table:
rows = [["", "Bénévole", "Téléphone"]] + [
[_photo_thumbnail(v.photo) if v.photo else "", v.name, v.phone or "—"]
[_photo_cell(v.photo, cache), v.name, v.phone or "—"]
for v in shift.volunteers
]
table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm])
@@ -153,6 +185,7 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
)
story = []
thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {}
for i, sheet in enumerate(sheets):
if i > 0:
story.append(PageBreak())
@@ -190,7 +223,7 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
story.append(Paragraph(header, style))
if shift.volunteers:
story.append(_shift_table(shift))
story.append(_shift_table(shift, thumbnails))
else:
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
story.append(Spacer(1, 0))
+6 -2
View File
@@ -360,7 +360,7 @@ def test_generating_with_back_fields(client, edition, session, monkeypatch):
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
assert back[True].text == "" # pas de photo : rien d'imprimé
def test_generating_prints_as_many_badges_as_requested(client, edition, session, monkeypatch):
@@ -427,7 +427,11 @@ def test_fields_are_drawn_in_stacking_order(client, edition, session, monkeypatc
assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200
(badge,) = captured["badges"]
assert [f.text for f in badge.fields] == ["Alex\nMartin", "AM", "Staff"]
assert [(f.is_photo, f.text) for f in badge.fields] == [
(False, "Alex\nMartin"),
(True, ""),
(False, "Staff"),
]
def test_photo_is_drawn_below_text_at_equal_stacking_order(client, edition):
+3 -2
View File
@@ -115,10 +115,11 @@ def test_field_text_full_name_is_first_name_then_last_name_on_two_lines():
assert _field_text(field, volunteer, {}, set()) == "Alex\nMartin"
def test_field_text_photo_is_the_initials_used_without_a_photo():
def test_field_text_photo_is_empty():
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"
# Une image, pas un texte : rien n'est imprimé sans photo.
assert _field_text(field, volunteer, {}, set()) == ""
+14 -21
View File
@@ -242,7 +242,7 @@ def test_table_rows_beyond_the_frame_are_not_drawn():
# É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
from app.badges.pdf import fit_text # noqa: E402
def _text_field(**kw) -> BadgeFieldSpec:
@@ -269,9 +269,9 @@ def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeyp
calls = []
original = pdf_module._draw_fields
def spy(c, fields, x0, y0, badge_height_mm):
def spy(c, fields, x0, y0, badge_height_mm, photos):
calls.append((fields, x0, y0))
original(c, fields, x0, y0, badge_height_mm)
original(c, fields, x0, y0, badge_height_mm, photos)
monkeypatch.setattr(pdf_module, "_draw_fields", spy)
back = (_text_field(text="Verso Alex"),)
@@ -376,30 +376,23 @@ def test_middle_aligned_text_is_lower_than_top_aligned_text(monkeypatch):
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"}),)
def test_missing_photo_prints_nothing(monkeypatch):
calls = []
for name in ("drawImage", "drawPath", "drawCentredString", "drawString"):
monkeypatch.setattr(
pdf_module.canvas.Canvas, name,
lambda self, *a, _name=name, **k: calls.append(_name),
)
generate_badge_sheets_pdf([badge], None, None)
assert drawn == ["AM", "AM"]
for image in (None, b"pas une image"):
generate_badge_sheets_pdf([_photo_badge(image)], None, None)
assert calls == []
def test_oval_photo_and_oval_initials_generate_a_valid_pdf():
def test_oval_photo_and_missing_oval_photo_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"}),)
fields=(BadgeFieldSpec(**{**field.__dict__, "photo_shape": "OVAL"}),)
)
pdf = generate_badge_sheets_pdf([badge], None, None)
assert pdf.startswith(b"%PDF")
+32
View File
@@ -2,6 +2,7 @@
import base64
import datetime as dt
import io
from app.sheets.pdf import (
PositionDaySheet,
@@ -142,3 +143,34 @@ def test_generates_a_valid_pdf_without_a_lineup():
assert sheet.artists == ()
pdf = generate_position_sheets_pdf([sheet])
assert pdf.startswith(b"%PDF")
def _phone_photo(seed: int) -> bytes:
"""Photo de téléphone : 3000 × 4000 px, plusieurs Mo."""
from PIL import Image as PILImage
img = PILImage.new("RGB", (3000, 4000), (seed * 40 % 255, 120, 60))
img.paste(PILImage.effect_noise((800, 800), 80).convert("RGB"), (seed * 100, 200))
out = io.BytesIO()
img.save(out, format="JPEG", quality=92)
return out.getvalue()
def test_large_photos_are_shrunk_to_the_thumbnail():
# Régression : la photo d'origine était intégrée telle quelle pour
# chaque bénévole de chaque créneau — génération interminable, PDF
# énorme, puis erreur.
photos = [_phone_photo(i) for i in range(5)]
volunteers = tuple(
SheetVolunteer(name=f"Bénévole {i}", phone=None, photo=p) for i, p in enumerate(photos)
)
pdf = generate_position_sheets_pdf([_sheet(volunteers=volunteers)])
assert sum(len(p) for p in photos) > 2_000_000
assert len(pdf) < 200_000
def test_unreadable_photo_does_not_break_the_sheet():
pdf = generate_position_sheets_pdf(
[_sheet(volunteers=(SheetVolunteer(name="Alex", phone=None, photo=b"pas une image"),))]
)
assert pdf.startswith(b"%PDF")
+8 -3
View File
@@ -106,6 +106,10 @@
"volunteers.filter.all": "TOUS",
"volunteers.filter.unassigned": "SANS AFFECTATION",
"volunteers.legend.assigned": "AFFECTATION",
"volunteers.legend.overlap": "DEUX POSTES EN MÊME TEMPS",
"volunteers.legend.now": "MAINTENANT",
"volunteers.bar.title": "{position} · {time}",
"volunteers.bar.overlapTitle": "{position} · {time} — en même temps qu'un autre poste",
"volunteers.count": "{count} BÉNÉVOLES",
"volunteers.totalHours": "TOTAL H",
"volunteers.sort.label": "TRIER PAR",
@@ -494,6 +498,7 @@
"badges.preview.zoomIn": "Agrandir l'aperçu",
"badges.preview.zoomOut": "Réduire l'aperçu",
"badges.preview.zoom": "{percent} %",
"badges.preview.noPhoto": "sans photo",
"badges.preview.truncated": "▲ TEXTE COUPÉ",
"badges.preview.dims": "{width} × {height} mm",
"badges.preview.reducedTo": " · réduit à {size} pt",
@@ -526,7 +531,7 @@
"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.photoStat": "{count} photo(s) reçue(s) sur {total}. Sans photo, le cadre reste vide.",
"badges.inspector.geometry": "Position et taille (mm)",
"badges.inspector.x": "X",
"badges.inspector.y": "Y",
@@ -560,8 +565,8 @@
"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.",
"badges.issue.noPhoto.one": "{count} bénévole n'a pas de photo : le cadre restera vide sur son badge.",
"badges.issue.noPhoto.other": "{count} bénévoles n'ont pas de photo : le cadre restera vide sur leur badge.",
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.",
+2 -82
View File
@@ -2,7 +2,6 @@ import type {
ArtistRead,
AssignmentRead,
DayPartRead,
MatrixResponse,
PositionCategory,
ShiftRead,
Violation,
@@ -108,22 +107,6 @@ export function candidateChanceScore(params: {
return score;
}
/** Colonne de la grille : un créneau distinct (date, heure de début, heure de
* fin) — pas seulement une heure de début : deux créneaux qui démarrent à la
* même heure mais durent différemment ont chacun leur colonne. */
export interface LatticeColumn {
key: string; // `YYYY-MM-DD|HH:MM|HH:MM`
date: string;
time: string;
endTime: string;
label: string; // « 17:00 → 22:00 »
}
export interface LatticeDay {
date: string;
columns: LatticeColumn[];
}
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du plus
* long creux laissé par les créneaux types sur le cadran de 24 h (l'heure où
* le dernier de la journée se termine) — même règle que
@@ -169,68 +152,6 @@ export function dayBoundaryFromDayParts(
return clockLabel(bestStart);
}
/** Construit le treillis de colonnes à partir de créneaux (grille alignée).
* `dayBoundary` (`HH:MM`, défaut minuit) regroupe et trie les colonnes par
* « journée de festival » plutôt que par date calendaire brute : un créneau
* commençant avant cette heure (ex. 02:00, suite d'une soirée qui a passé
* minuit) est rattaché au jour précédent et trié après 23:00, pas avant
* 19:00 — voir CLAUDE.md § 3. */
export function buildLattice(
starts: Array<{ starts_at: string; ends_at: string }>,
dayBoundary = '00:00',
): {
days: LatticeDay[];
columns: LatticeColumn[];
} {
const boundaryMinutes = minutesOfDay(dayBoundary);
const seen = new Map<string, LatticeColumn & { festivalDay: string; sortMinutes: number }>();
for (const s of starts) {
const date = datePart(s.starts_at);
const time = timePart(s.starts_at);
const endTime = timePart(s.ends_at);
const key = `${date}|${time}|${endTime}`;
if (!seen.has(key)) {
const minutes = minutesOfDay(time);
const festivalDay = minutes < boundaryMinutes ? addDays(date, -1) : date;
const sortMinutes = (minutes - boundaryMinutes + 24 * 60) % (24 * 60);
seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}`, festivalDay, sortMinutes });
}
}
const ordered = [...seen.values()].sort(
(a, b) => a.festivalDay.localeCompare(b.festivalDay) || a.sortMinutes - b.sortMinutes,
);
const columns: LatticeColumn[] = ordered.map(({ key, date, time, endTime, label }) => ({
key,
date,
time,
endTime,
label,
}));
const days: LatticeDay[] = [];
for (const column of ordered) {
let day = days.find((d) => d.date === column.festivalDay);
if (!day) {
day = { date: column.festivalDay, columns: [] };
days.push(day);
}
day.columns.push({
key: column.key,
date: column.date,
time: column.time,
endTime: column.endTime,
label: column.label,
});
}
return { days, columns };
}
export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) {
return buildLattice(
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
dayBoundary,
);
}
/**
* Largeur (px) des tranches de 30 min d'une journée, en fonction de leur
* nombre : une journée courte (peu de tranches) reste lisible en large,
@@ -247,9 +168,8 @@ export function columnWidthPx(slotsInDay: number): number {
// --------------------------------------------------------------------------- //
// Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, //
// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés //
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. //
// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », //
// une colonne par créneau distinct plutôt qu'un axe temporel continu.) //
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. Même axe //
// pour la « Vue par bénévole » (`VolunteerViewPage.tsx`). //
// --------------------------------------------------------------------------- //
const TIME_GRID_SLOT_MINUTES = 30;
+248 -113
View File
@@ -1,11 +1,21 @@
import { useMemo, useState } from 'react';
import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
import type { VolunteerRead } from '@/api/schema';
import { Chip, HelpTip } from '@/components/ui';
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer } from '@/lib/planning';
import { datePart, shortDayLabel, timePart } from '@/lib/time';
import {
buildTimeGrid,
columnWidthPx,
dayBoundaryFromDayParts,
festivalDay,
hoursByVolunteer,
placeInTimeGrid,
type TimeGridDay,
} from '@/lib/planning';
import { clockLabel } from '@/lib/slotTimeline';
import { minutesOfDay, naiveMinutes, shortDayLabel, timePart } from '@/lib/time';
import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
@@ -14,15 +24,47 @@ type Filter = 'all' | 'unassigned';
* total d'heures affectées, croissant ou décroissant. */
type Sort = 'name' | 'hoursAsc' | 'hoursDesc';
const NAME_W = 230;
const TOTAL_W = 84;
const SLOT_MINUTES = 30;
/** Hauteur d'une ligne de poste dans la rangée d'un bénévole : deux postes
* en même temps ⇒ deux lignes, la rangée s'agrandit. */
const LANE_H = 26;
const ROW_PAD = 3;
interface Bar {
assignmentId: string;
positionName: string;
start: number;
end: number;
/** Position horizontale (px) dans la piste des créneaux. */
left: number;
width: number;
lane: number;
/** Chevauche un autre créneau du même bénévole. */
overlap: boolean;
label: string;
}
/**
* Vue par bénévole (COORDINATION) : un vrai axe des temps, comme la grille
* des besoins (une colonne = 30 min, bornée par jour de festival à la plage
* utilisée — `buildTimeGrid`). Chaque affectation s'étire sur toute la
* durée de son créneau, avec le nom du poste ; deux postes tenus en même
* temps s'empilent sur deux lignes (la rangée s'agrandit) et sont signalés.
* Ligne rouge verticale à l'heure actuelle quand elle tombe dans la grille.
*/
export function VolunteerViewPage() {
const volunteers = useVolunteers();
const assignments = useAssignments();
const shifts = useShifts();
const positions = usePositions();
const dayParts = useDayParts();
const now = useNowMinutes();
const [filter, setFilter] = useState<Filter>('all');
const [sort, setSort] = useState<Sort>('name');
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data],
@@ -31,60 +73,85 @@ export function VolunteerViewPage() {
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data],
);
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const lattice = useMemo(
() => buildLattice(shifts.data ?? [], dayBoundary),
[shifts.data, dayBoundary],
);
const hours = useMemo(
() => hoursByVolunteer(assignments.data ?? [], shiftsById),
[assignments.data, shiftsById],
);
// Axe des temps : jours de festival, largeur de tranche par jour et
// décalage (px) de chaque jour dans la piste.
const axis = useMemo(() => {
const days = buildTimeGrid(shifts.data ?? [], dayBoundary);
let offset = 0;
const layout = days.map((day) => {
const slotW = columnWidthPx(day.slotLabels.length);
const entry = { day, slotW, offset, width: slotW * day.slotLabels.length };
offset += entry.width;
return entry;
});
return { days, layout, width: offset };
}, [shifts.data, dayBoundary]);
const rows = useMemo(() => {
const all = assignments.data ?? [];
const byVolunteer = new Map<string, AssignmentRead[]>();
for (const a of all) {
const list = byVolunteer.get(a.volunteer_id) ?? [];
list.push(a);
byVolunteer.set(a.volunteer_id, list);
const byVolunteer = new Map<string, Array<{ id: string; shiftId: string }>>();
for (const a of assignments.data ?? []) {
byVolunteer.set(a.volunteer_id, [...(byVolunteer.get(a.volunteer_id) ?? []), { id: a.id, shiftId: a.shift_id }]);
}
const layoutByDay = new Map(axis.layout.map((l) => [l.day.date, l]));
const built = (volunteers.data ?? [])
.filter((v) => {
const own = byVolunteer.get(v.id) ?? [];
if (filter === 'unassigned') return own.length === 0;
return true;
})
.filter((v) => filter === 'all' || !(byVolunteer.get(v.id)?.length))
.map((v) => {
const own = byVolunteer.get(v.id) ?? [];
const cellByKey = new Map<string, AssignmentRead & { shift: ShiftRead }>();
for (const a of own) {
const shift = shiftsById.get(a.shift_id);
if (shift)
cellByKey.set(
`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`,
{ ...a, shift },
);
const items = (byVolunteer.get(v.id) ?? []).flatMap(({ id, shiftId }) => {
const shift = shiftsById.get(shiftId);
return shift ? [{ id, shift, starts_at: shift.starts_at, ends_at: shift.ends_at }] : [];
});
const bars: Bar[] = [];
for (const [day, placements] of placeInTimeGrid(axis.days, items, dayBoundary)) {
const l = layoutByDay.get(day);
if (!l) continue;
for (const p of placements) {
const { shift } = p.item;
bars.push({
assignmentId: p.item.id,
positionName: positionsById.get(shift.position_id)?.name ?? '?',
start: naiveMinutes(shift.starts_at),
end: naiveMinutes(shift.ends_at),
left: l.offset + p.columnStart * l.slotW,
width: p.columnSpan * l.slotW,
lane: 0,
overlap: false,
label: `${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`,
});
}
}
// Lignes : un créneau prend la première ligne libre à son début.
bars.sort((a, b) => a.start - b.start || a.end - b.end);
const laneEnds: number[] = [];
for (const bar of bars) {
let lane = laneEnds.findIndex((end) => end <= bar.start);
if (lane === -1) lane = laneEnds.length;
laneEnds[lane] = bar.end;
bar.lane = lane;
bar.overlap = bars.some((o) => o !== bar && o.start < bar.end && bar.start < o.end);
}
return {
volunteer: v,
cellByKey,
bars,
lanes: Math.max(1, laneEnds.length),
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
empty: own.length === 0,
empty: items.length === 0,
};
});
if (sort === 'name') return built;
const direction = sort === 'hoursAsc' ? 1 : -1;
// `sort` est stable : à total égal, l'ordre par nom est conservé.
return built.sort((a, b) => direction * (a.total - b.total));
}, [volunteers.data, assignments.data, shiftsById, hours, filter, sort]);
}, [volunteers.data, assignments.data, shiftsById, positionsById, hours, axis, dayBoundary, filter, sort]);
if (
volunteers.isLoading ||
shifts.isLoading ||
assignments.isLoading ||
positions.isLoading
)
// Position (px) de l'heure actuelle dans la piste, `null` hors grille.
const nowX = useMemo(() => nowPosition(axis.layout, now, dayBoundary), [axis.layout, now, dayBoundary]);
if (volunteers.isLoading || shifts.isLoading || assignments.isLoading || positions.isLoading)
return <Loading />;
const error = volunteers.error ?? shifts.error ?? assignments.error ?? positions.error;
if (error) return <ErrorNote error={error} />;
@@ -97,9 +164,6 @@ export function VolunteerViewPage() {
);
}
const gridTemplateColumns = `230px repeat(${lattice.columns.length}, 62px) 84px`;
const abbr = (positionId: string) => (positionsById.get(positionId)?.name ?? positionId).slice(0, 4).toUpperCase();
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
@@ -124,9 +188,11 @@ export function VolunteerViewPage() {
{t('volunteers.sort.hoursDesc')}
</Chip>
</div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
<div className="ml-auto flex flex-wrap items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
<LegendItem swatch="bg-blue border-ink">{t('volunteers.legend.assigned')}</LegendItem>
<LegendItem swatch="bg-yellow border-ink">{t('volunteers.legend.overlap')}</LegendItem>
<LegendItem swatch="bg-orange border-orange !w-[3px]">{t('volunteers.legend.now')}</LegendItem>
</div>
</div>
@@ -134,62 +200,99 @@ export function VolunteerViewPage() {
{rows.length === 0 ? (
<EmptyNote>{t('volunteers.empty')}</EmptyNote>
) : (
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
<div className="sticky left-0 top-0 z-[7] flex items-center gap-[6px] border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('volunteers.count', { count: rows.length })}
<HelpTip text={t('volunteers.footnote')} side="bottom" />
</div>
{lattice.days.map((day) => (
<div
key={day.date}
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
style={{ gridColumn: `span ${day.columns.length}` }}
>
<span className="font-display text-[12px] font-black">
{shortDayLabel(`${day.date}T12:00`)}
</span>
<div className="w-max border-b-[3px] border-ink">
{/* En-tête : jours, puis tranches de 30 min. */}
<div className="sticky top-0 z-[7] flex h-[27px]">
<div
className="sticky left-0 z-[8] flex flex-none items-center gap-[6px] border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 font-display text-[10px] font-extrabold uppercase tracking-wider text-cream"
style={{ width: NAME_W }}
>
{t('volunteers.count', { count: rows.length })}
<HelpTip text={t('volunteers.footnote')} side="bottom" />
</div>
{axis.layout.map(({ day, width }) => (
<div
key={day.date}
className="flex flex-none items-center border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] text-cream"
style={{ width }}
>
<span className="font-display text-[12px] font-black">{shortDayLabel(`${day.date}T12:00`)}</span>
</div>
))}
<button
type="button"
onClick={() => setSort(sort === 'hoursDesc' ? 'hoursAsc' : 'hoursDesc')}
title={t('volunteers.sort.toggle')}
className="sticky right-0 z-[8] flex flex-none cursor-pointer items-center gap-1 border-b-2 border-l-[3px] border-ink bg-yellow px-2 font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-yellow-soft"
style={{ width: TOTAL_W }}
>
{t('volunteers.totalHours')}
{sort === 'hoursAsc' ? ' ↑' : sort === 'hoursDesc' ? ' ↓' : null}
</button>
</div>
))}
<button
type="button"
onClick={() => setSort(sort === 'hoursDesc' ? 'hoursAsc' : 'hoursDesc')}
title={t('volunteers.sort.toggle')}
className="sticky right-0 top-0 z-[7] flex cursor-pointer items-center gap-1 border-b-2 border-l-[3px] border-ink bg-yellow px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-yellow-soft"
>
{t('volunteers.totalHours')}
{sort === 'hoursAsc' ? ' ↑' : sort === 'hoursDesc' ? ' ↓' : null}
</button>
<div className="sticky left-0 top-[27px] z-[7] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px]">
{t('volunteers.name')}
</div>
{lattice.columns.map((column) => (
<div
key={column.key}
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
>
{column.time}
<div className="sticky top-[27px] z-[6] flex">
<div
className="sticky left-0 z-[8] flex flex-none items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px]"
style={{ width: NAME_W }}
>
{t('volunteers.name')}
</div>
<div className="relative flex flex-none border-b-[3px] border-ink bg-sand" style={{ width: axis.width }}>
{axis.layout.map(({ day, slotW }) =>
day.slotLabels.map((label, i) => (
<div
key={`${day.date}-${i}`}
className={cx(
'flex-none overflow-hidden whitespace-nowrap border-r border-ink py-[5px] text-center font-mono text-[9.5px]',
i === day.slotLabels.length - 1 && 'border-r-[3px]',
)}
style={{ width: slotW }}
>
{label}
</div>
)),
)}
{nowX !== null ? (
<span
className="absolute top-1/2 z-[1] -translate-x-1/2 -translate-y-1/2 bg-orange px-[5px] py-[2px] font-mono text-[9.5px] font-bold text-white"
style={{ left: nowX }}
>
{clockLabel(now)}
</span>
) : null}
</div>
<div
className="sticky right-0 z-[8] flex flex-none items-center justify-center border-b-[3px] border-l-[3px] border-ink bg-yellow-soft text-center font-mono text-[8.5px]"
style={{ width: TOTAL_W }}
>
{t('volunteers.cumulative')}
</div>
</div>
))}
<div className="sticky right-0 top-[27px] z-[7] flex items-center justify-center border-b-[3px] border-l-[3px] border-ink bg-yellow-soft text-center font-mono text-[8.5px]">
{t('volunteers.cumulative')}
</div>
{rows.map((row) => (
<VolunteerRow
key={row.volunteer.id}
row={row}
columns={lattice.columns}
abbr={abbr}
/>
))}
</div>
{rows.map((row) => (
<VolunteerRow key={row.volunteer.id} row={row} layout={axis.layout} width={axis.width} nowX={nowX} />
))}
</div>
)}
</div>
</div>
);
}
function nowPosition(
layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>,
now: number,
dayBoundary: string,
): number | null {
const iso = new Date(now * 60_000).toISOString().slice(0, 16);
const entry = layout.find((l) => l.day.date === festivalDay(iso, dayBoundary));
if (!entry) return null;
const boundary = minutesOfDay(dayBoundary);
const offset = ((minutesOfDay(timePart(iso)) - boundary + 1440) % 1440) - entry.day.startOffset;
if (offset < 0 || offset > entry.day.slotLabels.length * SLOT_MINUTES) return null;
return entry.offset + (offset / SLOT_MINUTES) * entry.slotW;
}
function LegendItem({ swatch, children }: { swatch: string; children: React.ReactNode }) {
return (
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
@@ -199,25 +302,24 @@ function LegendItem({ swatch, children }: { swatch: string; children: React.Reac
);
}
type Row = {
volunteer: VolunteerRead;
cellByKey: Map<string, AssignmentRead & { shift: ShiftRead }>;
total: number;
empty: boolean;
};
function VolunteerRow({
row,
columns,
abbr,
layout,
width,
nowX,
}: {
row: Row;
columns: { key: string }[];
abbr: (positionId: string) => string;
row: { volunteer: VolunteerRead; bars: Bar[]; lanes: number; total: number; empty: boolean };
layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>;
width: number;
nowX: number | null;
}) {
const height = row.lanes * LANE_H + ROW_PAD * 2;
return (
<>
<div className="sticky left-0 z-[2] flex h-8 items-center gap-[6px] border-b border-r-[3px] border-ink bg-cream px-2">
<div className="flex" style={{ height }}>
<div
className="sticky left-0 z-[4] flex flex-none items-center gap-[6px] border-b border-r-[3px] border-ink bg-cream px-2"
style={{ width: NAME_W }}
>
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
{row.volunteer.first_name} {row.volunteer.last_name}
</span>
@@ -227,21 +329,54 @@ function VolunteerRow({
</span>
) : null}
</div>
{columns.map((column) => {
const cell = row.cellByKey.get(column.key);
if (!cell) return <div key={column.key} className="h-8 border-b border-r border-ink bg-sand" />;
return (
<div className="relative flex-none border-b border-ink bg-sand" style={{ width }}>
{/* Tranches de 30 min, filet épais entre deux jours. */}
{layout.map((l) => (
<span
key={l.day.date}
className="absolute inset-y-0 border-r-[3px] border-ink"
style={{
left: l.offset,
width: l.width,
backgroundImage: `repeating-linear-gradient(90deg, transparent 0 ${l.slotW - 1}px, rgba(20,17,14,.35) ${l.slotW - 1}px ${l.slotW}px)`,
}}
aria-hidden="true"
/>
))}
{row.bars.map((bar) => (
<div
key={column.key}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold"
key={bar.assignmentId}
title={
bar.overlap
? t('volunteers.bar.overlapTitle', { position: bar.positionName, time: bar.label })
: t('volunteers.bar.title', { position: bar.positionName, time: bar.label })
}
className={cx(
'absolute z-[2] flex items-center overflow-hidden border-2 border-ink px-[5px] font-display text-[10px] font-extrabold leading-none',
bar.overlap ? 'bg-yellow' : 'bg-blue',
)}
style={{
left: bar.left + 1,
width: Math.max(4, bar.width - 2),
top: ROW_PAD + bar.lane * LANE_H,
height: LANE_H - 3,
}}
>
{abbr(cell.shift.position_id)}
<span className="truncate">{bar.positionName}</span>
</div>
);
})}
<div className="sticky right-0 z-[2] flex items-center justify-center border-b border-l-[3px] border-ink bg-paper font-mono text-[13px] font-bold">
))}
{nowX !== null ? (
<span className="absolute inset-y-0 z-[3] w-[2px] -translate-x-1/2 bg-orange" style={{ left: nowX }} aria-hidden="true" />
) : null}
</div>
<div
className="sticky right-0 z-[4] flex flex-none items-center justify-center border-b border-l-[3px] border-ink bg-paper font-mono text-[13px] font-bold"
style={{ width: TOTAL_W }}
>
{row.total}
</div>
</>
</div>
);
}
+6 -16
View File
@@ -16,7 +16,6 @@ import {
import {
FACE_LABEL_KEY,
fmt,
initials,
isVerticallyCentered,
renderField,
SAFE_MARGIN_MM,
@@ -25,8 +24,6 @@ import {
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). */
@@ -301,7 +298,7 @@ function FieldBox({
}}
>
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} />
) : rendered?.kind === 'table' ? (
<TableContent field={field} layout={rendered.layout} scale={scale} />
) : rendered?.kind === 'text' ? (
@@ -450,12 +447,10 @@ 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) {
@@ -469,19 +464,14 @@ function PhotoContent({
/>
);
}
// Sans photo, rien n'est imprimé : juste un repère pointillé dans
// l'éditeur (pas sur le badge) pour savoir où est le cadre.
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,
}}
className="pointer-events-none flex h-full w-full items-center justify-center border border-dashed border-ink/40 text-center font-mono text-[9px] leading-tight text-ink/50"
style={{ borderRadius: radius }}
>
{volunteer ? initials(volunteer) : null}
{t('badges.preview.noPhoto')}
</div>
);
}
-4
View File
@@ -258,10 +258,6 @@ export interface BadgeData {
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}`;
}