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 au ratio du cadre sans déformation, orientation EXIF appliquée et réduite
à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de à 300 dpi (`app/badges/pdf.py:_fit_photo` — une photo de téléphone de
plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou plusieurs Mo répétée sur 200 badges alourdirait le PDF) ; sans photo ou
photo illisible, les **initiales** du bénévole sont imprimées à la photo illisible, **rien n'est imprimé** (cadre vide, choix de la
place (fond `#E7E0CE`, même forme) plutôt que de faire échouer la coordination — avant l'étape 61, les initiales sur fond beige), sans
planche ; `OVAL` découpe la photo en ellipse. Le texte d'un champ passe à la ligne pour tenir dans la largeur 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 de son cadre (`app/badges/pdf.py:wrap_text` — entre deux mots, ou au
milieu d'un mot trop long seul ; les `\n` restent des retours à la milieu d'un mot trop long seul ; les `\n` restent des retours à la
ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme ligne), et ce qui dépasse la hauteur du cadre n'est pas imprimé — comme
@@ -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. avec les bénévoles affectés et leur téléphone, pour affichage sur place.
`POST /editions/{id}/position-sheets/generate` (COORDINATION, `POST /editions/{id}/position-sheets/generate` (COORDINATION,
`{position_ids?}` — absent = tous) ; un créneau à besoin 0/0 sans aucune `{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, - **Statistiques** (`GET /editions/{id}/stats`, COORDINATION,
`app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune `app/api/stats.py` ; écran `/statistiques`, `StatsPage.tsx`) : aucune
nouvelle entité, tout est recalculé à chaque appel. Inscriptions (total, 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, en fonction du nombre de tranches) : peu de tranches ⇒ colonnes larges,
beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes beaucoup ⇒ largeur compacte avec défilement horizontal — jamais de colonnes
étroites par défaut. étroites par défaut.
La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle, La « Vue par bénévole » (`VolunteerViewPage.tsx`) utilise le même axe des
distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`) temps (`buildTimeGrid` / `placeInTimeGrid`, même largeur de tranche par
plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus jour) : chaque affectation est une barre étirée sur toute la durée de son
adapté à son tableau bénévole × créneau. 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 | | 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 | | 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 | | 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 **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, IA. Frontend : squelette + 6 écrans + de formulaire, IA. Frontend : squelette + 6 écrans +
+2 -3
View File
@@ -37,7 +37,6 @@ from app.badges.pdf import (
BadgeTableSection, BadgeTableSection,
generate_badge_sheets_pdf, generate_badge_sheets_pdf,
image_size_mm, image_size_mm,
initials,
) )
from app.db import get_session from app.db import get_session
from app.i18n import t from app.i18n import t
@@ -320,8 +319,8 @@ def _field_text(
# Prénom puis nom, chacun sur sa ligne. # Prénom puis nom, chacun sur sa ligne.
return f"{volunteer.first_name}\n{volunteer.last_name}" return f"{volunteer.first_name}\n{volunteer.last_name}"
if field.field_type == BadgeFieldType.PHOTO: if field.field_type == BadgeFieldType.PHOTO:
# Imprimées à la place d'une photo absente ou illisible. # Une image, pas un texte (rien d'imprimé sans photo).
return initials(volunteer.first_name, volunteer.last_name) return ""
if field.field_type == BadgeFieldType.FREE_TEXT: if field.field_type == BadgeFieldType.FREE_TEXT:
# Texte commun saisi dans l'éditeur, remplacé pour un bénévole par # Texte commun saisi dans l'éditeur, remplacé pour un bénévole par
# le « Texte du badge » de sa fiche s'il en a un. # le « Texte du badge » de sa fiche s'il en a un.
+31 -30
View File
@@ -42,8 +42,6 @@ _AUTO_FIT_STEP_PT = 0.5
_AUTO_FIT_MIN_PT = 4.0 _AUTO_FIT_MIN_PT = 4.0
# Photo absente ou illisible : initiales sur ce fond, à cette taille (part de # Photo absente ou illisible : initiales sur ce fond, à cette taille (part de
# la largeur du cadre). # la largeur du cadre).
_INITIALS_BG = "#E7E0CE"
_INITIALS_SIZE_RATIO = 0.28
# Résolution supposée d'une image de fond importée, pour convertir ses pixels # Résolution supposée d'une image de fond importée, pour convertir ses pixels
# en millimètres réels (donc en taille de badge) — 300 dpi est la résolution # en millimètres réels (donc en taille de badge) — 300 dpi est la résolution
@@ -86,9 +84,8 @@ class BadgeFieldSpec:
`is_photo` : le champ est la photo du bénévole (`image`, ses octets `is_photo` : le champ est la photo du bénévole (`image`, ses octets
bruts tels que stockés) plutôt qu'un texte, découpée selon bruts tels que stockés) plutôt qu'un texte, découpée selon
`photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), les `photo_shape` (`RECT`/`OVAL`). Sans photo (ou photo illisible), rien
initiales du bénévole (`text`) sont imprimées à la place, sur un fond n'est imprimé : le cadre reste vide.
neutre de la même forme.
`table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`. `table` : le champ est un tableau (`text` ignoré) — voir `_draw_table`.
@@ -193,6 +190,9 @@ def _fit_photo(image: bytes, width_mm: float, height_mm: float) -> bytes | None:
) )
try: try:
with Image.open(io.BytesIO(image)) as img: 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) fitted = ImageOps.fit(ImageOps.exif_transpose(img).convert("RGB"), target)
except (UnidentifiedImageError, OSError): except (UnidentifiedImageError, OSError):
return None return None
@@ -224,34 +224,33 @@ def _shape_path(c: canvas.Canvas, shape: str, x: float, y: float, w: float, h: f
return path return path
def initials(first_name: str, last_name: str) -> str: # Photos déjà recadrées pendant une génération, par (photo, largeur,
"""Initiales imprimées à la place d'une photo absente : première lettre # hauteur du cadre) : un bénévole a souvent la même photo au recto, au verso
du prénom et du nom, en majuscules.""" # et sur plusieurs exemplaires (`badge_count`) — elle n'est traitée qu'une
return "".join(part.strip()[:1] for part in (first_name, last_name)).upper() # fois.
PhotoCache = dict[tuple[bytes, float, float], bytes | None]
def _draw_initials(c: canvas.Canvas, f: BadgeFieldSpec, x: float, y: float, w: float, h: float):
c.setFillColor(HexColor(_INITIALS_BG))
c.drawPath(_shape_path(c, f.photo_shape, x, y, w, h), stroke=0, fill=1)
if not f.text:
return
size = max(_AUTO_FIT_MIN_PT, w * _INITIALS_SIZE_RATIO)
c.setFont("Helvetica-Bold", size)
c.setFillColor(HexColor(f.color))
# Ligne de base décalée d'environ un tiers de la taille : centre
# visuel des majuscules (hauteur de capitale ≈ 0,7 × la taille).
c.drawCentredString(x + w / 2, y + h / 2 - size * 0.35, f.text)
def _draw_photo( def _draw_photo(
c: canvas.Canvas, f: BadgeFieldSpec, x0_mm: float, y0_mm: float, badge_height_mm: float c: canvas.Canvas,
f: BadgeFieldSpec,
x0_mm: float,
y0_mm: float,
badge_height_mm: float,
cache: PhotoCache,
) -> None: ) -> None:
x, y, w, h = _frame_rect(f, x0_mm, y0_mm, badge_height_mm) 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 photo = None
c.saveState() 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: if photo is None:
_draw_initials(c, f, x, y, w, h) # Pas de photo (ou illisible) : cadre laissé vide, choix de la
else: # coordination — avant, les initiales étaient imprimées à la place.
return
c.saveState()
if f.photo_shape == "OVAL": if f.photo_shape == "OVAL":
# Découpe en ellipse inscrite dans le cadre. # Découpe en ellipse inscrite dans le cadre.
c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0) c.clipPath(_shape_path(c, "OVAL", x, y, w, h), stroke=0, fill=0)
@@ -495,10 +494,11 @@ def _draw_fields(
x0_mm: float, x0_mm: float,
y0_mm: float, y0_mm: float,
badge_height_mm: float, badge_height_mm: float,
photos: PhotoCache,
) -> None: ) -> None:
for f in fields: for f in fields:
if f.is_photo: if f.is_photo:
_draw_photo(c, f, x0_mm, y0_mm, badge_height_mm) _draw_photo(c, f, x0_mm, y0_mm, badge_height_mm, photos)
continue continue
_draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm) _draw_zone_background(c, f, x0_mm, y0_mm, badge_height_mm)
if f.table is not None: if f.table is not None:
@@ -522,17 +522,18 @@ def generate_badge_sheets_pdf(
buffer = io.BytesIO() buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4) c = canvas.Canvas(buffer, pagesize=A4)
photos: PhotoCache = {}
for page_badges in pages: for page_badges in pages:
for i, badge in enumerate(page_badges): for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm) x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm)
_draw_background(c, front_image, x0, y0, 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() c.showPage()
for i, badge in enumerate(page_badges): for i, badge in enumerate(page_badges):
x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True) x0, y0 = _cell_origin(i, cols, badge_width_mm, badge_height_mm, mirrored=True)
_draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm) _draw_background(c, back_image, x0, y0, badge_width_mm, badge_height_mm)
_draw_fields(c, badge.back_fields, x0, y0, badge_height_mm) _draw_fields(c, badge.back_fields, x0, y0, badge_height_mm, photos)
c.showPage() c.showPage()
c.save() c.save()
return buffer.getvalue() return buffer.getvalue()
+41 -8
View File
@@ -13,11 +13,12 @@ import datetime as dt
import io import io
from dataclasses import dataclass from dataclasses import dataclass
from PIL import Image as PILImage
from PIL import ImageOps, UnidentifiedImageError
from reportlab.lib import colors from reportlab.lib import colors
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.lib.utils import ImageReader
from reportlab.platypus import ( from reportlab.platypus import (
Image, Image,
PageBreak, PageBreak,
@@ -29,6 +30,8 @@ from reportlab.platypus import (
) )
_PHOTO_BOX_MM = 14 * mm _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"] _WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
_MONTHS_FR = [ _MONTHS_FR = [
@@ -115,16 +118,45 @@ def _styles() -> dict[str, ParagraphStyle]:
} }
def _photo_thumbnail(photo: bytes) -> Image: def _thumbnail(photo: bytes) -> tuple[bytes, int, int] | None:
"""Vignette carrée (14 mm de côté max), proportions préservées.""" """Photo réduite à la taille de la vignette (orientation EXIF appliquée),
width_px, height_px = ImageReader(io.BytesIO(photo)).getSize() 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) 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"]] + [ 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 for v in shift.volunteers
] ]
table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm]) table = Table(rows, colWidths=[18 * mm, 92 * mm, 55 * mm])
@@ -153,6 +185,7 @@ def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
) )
story = [] story = []
thumbnails: dict[bytes, tuple[bytes, int, int] | None] = {}
for i, sheet in enumerate(sheets): for i, sheet in enumerate(sheets):
if i > 0: if i > 0:
story.append(PageBreak()) 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"] style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
story.append(Paragraph(header, style)) story.append(Paragraph(header, style))
if shift.volunteers: if shift.volunteers:
story.append(_shift_table(shift)) story.append(_shift_table(shift, thumbnails))
else: else:
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"])) story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
story.append(Spacer(1, 0)) 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].bg_color == "#FFD23F"
assert back[False].auto_fit is True assert back[False].auto_fit is True
assert back[True].photo_shape == "OVAL" 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): 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 assert client.post(f"{_badges_url(edition)}/generate", json={}).status_code == 200
(badge,) = captured["badges"] (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): 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" 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") volunteer = _volunteer(first_name="alex", last_name="martin")
field = BadgeField( field = BadgeField(
edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO, edition_id=uuid.uuid4(), field_type=BadgeFieldType.PHOTO,
x_mm=0, y_mm=0, width_mm=10, height_mm=10, font_size=9, 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()) == ""
+12 -19
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 # # Éditeur v2 : verso, fond de zone, réduction automatique, photo #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
from app.badges import pdf as pdf_module # noqa: E402 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: def _text_field(**kw) -> BadgeFieldSpec:
@@ -269,9 +269,9 @@ def test_back_page_draws_each_volunteer_back_fields_at_the_mirrored_slot(monkeyp
calls = [] calls = []
original = pdf_module._draw_fields 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)) 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) monkeypatch.setattr(pdf_module, "_draw_fields", spy)
back = (_text_field(text="Verso Alex"),) 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 assert middle < top
def test_initials_are_the_uppercase_first_letters(): def test_missing_photo_prints_nothing(monkeypatch):
assert initials("alex", "martin") == "AM" calls = []
assert initials("Élodie", "de la Tour") == "ÉD" for name in ("drawImage", "drawPath", "drawCentredString", "drawString"):
def test_missing_photo_prints_the_initials(monkeypatch):
drawn = []
monkeypatch.setattr( monkeypatch.setattr(
pdf_module.canvas.Canvas, "drawCentredString", pdf_module.canvas.Canvas, name,
lambda self, x, y, text, *a, **k: drawn.append(text), lambda self, *a, _name=name, **k: calls.append(_name),
) )
for image in (None, b"pas une image"): for image in (None, b"pas une image"):
badge = BadgeSpec( generate_badge_sheets_pdf([_photo_badge(image)], None, None)
fields=(BadgeFieldSpec(**{**_photo_badge(image).fields[0].__dict__, "text": "AM"}),) assert calls == []
)
generate_badge_sheets_pdf([badge], None, None)
assert drawn == ["AM", "AM"]
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): for image in (_jpeg(400, 500), None):
field = _photo_badge(image).fields[0] field = _photo_badge(image).fields[0]
badge = BadgeSpec( 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) pdf = generate_badge_sheets_pdf([badge], None, None)
assert pdf.startswith(b"%PDF") assert pdf.startswith(b"%PDF")
+32
View File
@@ -2,6 +2,7 @@
import base64 import base64
import datetime as dt import datetime as dt
import io
from app.sheets.pdf import ( from app.sheets.pdf import (
PositionDaySheet, PositionDaySheet,
@@ -142,3 +143,34 @@ def test_generates_a_valid_pdf_without_a_lineup():
assert sheet.artists == () assert sheet.artists == ()
pdf = generate_position_sheets_pdf([sheet]) pdf = generate_position_sheets_pdf([sheet])
assert pdf.startswith(b"%PDF") 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.all": "TOUS",
"volunteers.filter.unassigned": "SANS AFFECTATION", "volunteers.filter.unassigned": "SANS AFFECTATION",
"volunteers.legend.assigned": "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.count": "{count} BÉNÉVOLES",
"volunteers.totalHours": "TOTAL H", "volunteers.totalHours": "TOTAL H",
"volunteers.sort.label": "TRIER PAR", "volunteers.sort.label": "TRIER PAR",
@@ -494,6 +498,7 @@
"badges.preview.zoomIn": "Agrandir l'aperçu", "badges.preview.zoomIn": "Agrandir l'aperçu",
"badges.preview.zoomOut": "Réduire l'aperçu", "badges.preview.zoomOut": "Réduire l'aperçu",
"badges.preview.zoom": "{percent} %", "badges.preview.zoom": "{percent} %",
"badges.preview.noPhoto": "sans photo",
"badges.preview.truncated": "▲ TEXTE COUPÉ", "badges.preview.truncated": "▲ TEXTE COUPÉ",
"badges.preview.dims": "{width} × {height} mm", "badges.preview.dims": "{width} × {height} mm",
"badges.preview.reducedTo": " · réduit à {size} pt", "badges.preview.reducedTo": " · réduit à {size} pt",
@@ -526,7 +531,7 @@
"badges.inspector.photoShape": "Forme de la photo", "badges.inspector.photoShape": "Forme de la photo",
"badges.inspector.photoRect": "▢ Rectangle", "badges.inspector.photoRect": "▢ Rectangle",
"badges.inspector.photoOval": "◯ Ovale", "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.geometry": "Position et taille (mm)",
"badges.inspector.x": "X", "badges.inspector.x": "X",
"badges.inspector.y": "Y", "badges.inspector.y": "Y",
@@ -560,8 +565,8 @@
"badges.issue.reduced.other": "{where} : réduit automatiquement sur {count} badges, jusqu'à {size} pt.", "badges.issue.reduced.other": "{where} : réduit automatiquement sur {count} badges, jusqu'à {size} pt.",
"badges.issue.margin": "{where} : touche la marge de découpe (1,5 mm), risque d'être rogné.", "badges.issue.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.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.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 : leurs initiales seront imprimées à la place.", "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.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
"badge.empty_image": "L'image est vide.", "badge.empty_image": "L'image est vide.",
"badge.image_too_large": "L'image est trop volumineuse.", "badge.image_too_large": "L'image est trop volumineuse.",
+2 -82
View File
@@ -2,7 +2,6 @@ import type {
ArtistRead, ArtistRead,
AssignmentRead, AssignmentRead,
DayPartRead, DayPartRead,
MatrixResponse,
PositionCategory, PositionCategory,
ShiftRead, ShiftRead,
Violation, Violation,
@@ -108,22 +107,6 @@ export function candidateChanceScore(params: {
return score; 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 /** 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ù * 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 * le dernier de la journée se termine) — même règle que
@@ -169,68 +152,6 @@ export function dayBoundaryFromDayParts(
return clockLabel(bestStart); 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 * 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, * 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, // // Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, //
// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés // // eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés //
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. // // dans cette grille fixe en fusionnant les cases qu'ils couvrent. Même axe //
// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », // // pour la « Vue par bénévole » (`VolunteerViewPage.tsx`). //
// une colonne par créneau distinct plutôt qu'un axe temporel continu.) //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
const TIME_GRID_SLOT_MINUTES = 30; const TIME_GRID_SLOT_MINUTES = 30;
+224 -89
View File
@@ -1,11 +1,21 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } from '@/api/hooks'; 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 { Chip, HelpTip } from '@/components/ui';
import { EmptyNote, ErrorNote, Loading } from '@/components/state'; import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer } from '@/lib/planning'; import {
import { datePart, shortDayLabel, timePart } from '@/lib/time'; 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 { t } from '@/i18n';
import { cx } from '@/lib/cx'; import { cx } from '@/lib/cx';
@@ -14,15 +24,47 @@ type Filter = 'all' | 'unassigned';
* total d'heures affectées, croissant ou décroissant. */ * total d'heures affectées, croissant ou décroissant. */
type Sort = 'name' | 'hoursAsc' | 'hoursDesc'; 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() { export function VolunteerViewPage() {
const volunteers = useVolunteers(); const volunteers = useVolunteers();
const assignments = useAssignments(); const assignments = useAssignments();
const shifts = useShifts(); const shifts = useShifts();
const positions = usePositions(); const positions = usePositions();
const dayParts = useDayParts(); const dayParts = useDayParts();
const now = useNowMinutes();
const [filter, setFilter] = useState<Filter>('all'); const [filter, setFilter] = useState<Filter>('all');
const [sort, setSort] = useState<Sort>('name'); const [sort, setSort] = useState<Sort>('name');
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const shiftsById = useMemo( const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])), () => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data], [shifts.data],
@@ -31,60 +73,85 @@ export function VolunteerViewPage() {
() => new Map((positions.data ?? []).map((p) => [p.id, p])), () => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data], [positions.data],
); );
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const lattice = useMemo(
() => buildLattice(shifts.data ?? [], dayBoundary),
[shifts.data, dayBoundary],
);
const hours = useMemo( const hours = useMemo(
() => hoursByVolunteer(assignments.data ?? [], shiftsById), () => hoursByVolunteer(assignments.data ?? [], shiftsById),
[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 rows = useMemo(() => {
const all = assignments.data ?? []; const byVolunteer = new Map<string, Array<{ id: string; shiftId: string }>>();
const byVolunteer = new Map<string, AssignmentRead[]>(); for (const a of assignments.data ?? []) {
for (const a of all) { byVolunteer.set(a.volunteer_id, [...(byVolunteer.get(a.volunteer_id) ?? []), { id: a.id, shiftId: a.shift_id }]);
const list = byVolunteer.get(a.volunteer_id) ?? [];
list.push(a);
byVolunteer.set(a.volunteer_id, list);
} }
const layoutByDay = new Map(axis.layout.map((l) => [l.day.date, l]));
const built = (volunteers.data ?? []) const built = (volunteers.data ?? [])
.filter((v) => { .filter((v) => filter === 'all' || !(byVolunteer.get(v.id)?.length))
const own = byVolunteer.get(v.id) ?? [];
if (filter === 'unassigned') return own.length === 0;
return true;
})
.map((v) => { .map((v) => {
const own = byVolunteer.get(v.id) ?? []; const items = (byVolunteer.get(v.id) ?? []).flatMap(({ id, shiftId }) => {
const cellByKey = new Map<string, AssignmentRead & { shift: ShiftRead }>(); const shift = shiftsById.get(shiftId);
for (const a of own) { return shift ? [{ id, shift, starts_at: shift.starts_at, ends_at: shift.ends_at }] : [];
const shift = shiftsById.get(a.shift_id); });
if (shift) const bars: Bar[] = [];
cellByKey.set( for (const [day, placements] of placeInTimeGrid(axis.days, items, dayBoundary)) {
`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`, const l = layoutByDay.get(day);
{ ...a, shift }, 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 { return {
volunteer: v, volunteer: v,
cellByKey, bars,
lanes: Math.max(1, laneEnds.length),
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10, total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
empty: own.length === 0, empty: items.length === 0,
}; };
}); });
if (sort === 'name') return built; if (sort === 'name') return built;
const direction = sort === 'hoursAsc' ? 1 : -1; const direction = sort === 'hoursAsc' ? 1 : -1;
// `sort` est stable : à total égal, l'ordre par nom est conservé. // `sort` est stable : à total égal, l'ordre par nom est conservé.
return built.sort((a, b) => direction * (a.total - b.total)); 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 ( // Position (px) de l'heure actuelle dans la piste, `null` hors grille.
volunteers.isLoading || const nowX = useMemo(() => nowPosition(axis.layout, now, dayBoundary), [axis.layout, now, dayBoundary]);
shifts.isLoading ||
assignments.isLoading || if (volunteers.isLoading || shifts.isLoading || assignments.isLoading || positions.isLoading)
positions.isLoading
)
return <Loading />; return <Loading />;
const error = volunteers.error ?? shifts.error ?? assignments.error ?? positions.error; const error = volunteers.error ?? shifts.error ?? assignments.error ?? positions.error;
if (error) return <ErrorNote error={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 ( return (
<div className="flex min-h-0 flex-1 flex-col"> <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]"> <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')} {t('volunteers.sort.hoursDesc')}
</Chip> </Chip>
</div> </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> <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-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>
</div> </div>
@@ -134,54 +200,77 @@ export function VolunteerViewPage() {
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyNote>{t('volunteers.empty')}</EmptyNote> <EmptyNote>{t('volunteers.empty')}</EmptyNote>
) : ( ) : (
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}> <div className="w-max border-b-[3px] border-ink">
<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"> {/* 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 })} {t('volunteers.count', { count: rows.length })}
<HelpTip text={t('volunteers.footnote')} side="bottom" /> <HelpTip text={t('volunteers.footnote')} side="bottom" />
</div> </div>
{lattice.days.map((day) => ( {axis.layout.map(({ day, width }) => (
<div <div
key={day.date} 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" 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={{ gridColumn: `span ${day.columns.length}` }} style={{ width }}
> >
<span className="font-display text-[12px] font-black"> <span className="font-display text-[12px] font-black">{shortDayLabel(`${day.date}T12:00`)}</span>
{shortDayLabel(`${day.date}T12:00`)}
</span>
</div> </div>
))} ))}
<button <button
type="button" type="button"
onClick={() => setSort(sort === 'hoursDesc' ? 'hoursAsc' : 'hoursDesc')} onClick={() => setSort(sort === 'hoursDesc' ? 'hoursAsc' : 'hoursDesc')}
title={t('volunteers.sort.toggle')} 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" 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')} {t('volunteers.totalHours')}
{sort === 'hoursAsc' ? ' ↑' : sort === 'hoursDesc' ? ' ↓' : null} {sort === 'hoursAsc' ? ' ↑' : sort === 'hoursDesc' ? ' ↓' : null}
</button> </button>
</div>
<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]"> <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')} {t('volunteers.name')}
</div> </div>
{lattice.columns.map((column) => ( <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 <div
key={column.key} key={`${day.date}-${i}`}
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]" 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 }}
> >
{column.time} {label}
</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]"> )}
{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')} {t('volunteers.cumulative')}
</div> </div>
</div>
{rows.map((row) => ( {rows.map((row) => (
<VolunteerRow <VolunteerRow key={row.volunteer.id} row={row} layout={axis.layout} width={axis.width} nowX={nowX} />
key={row.volunteer.id}
row={row}
columns={lattice.columns}
abbr={abbr}
/>
))} ))}
</div> </div>
)} )}
@@ -190,6 +279,20 @@ export function VolunteerViewPage() {
); );
} }
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 }) { function LegendItem({ swatch, children }: { swatch: string; children: React.ReactNode }) {
return ( return (
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold"> <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({ function VolunteerRow({
row, row,
columns, layout,
abbr, width,
nowX,
}: { }: {
row: Row; row: { volunteer: VolunteerRead; bars: Bar[]; lanes: number; total: number; empty: boolean };
columns: { key: string }[]; layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>;
abbr: (positionId: string) => string; width: number;
nowX: number | null;
}) { }) {
const height = row.lanes * LANE_H + ROW_PAD * 2;
return ( return (
<> <div className="flex" style={{ height }}>
<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="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"> <span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
{row.volunteer.first_name} {row.volunteer.last_name} {row.volunteer.first_name} {row.volunteer.last_name}
</span> </span>
@@ -227,21 +329,54 @@ function VolunteerRow({
</span> </span>
) : null} ) : null}
</div> </div>
{columns.map((column) => {
const cell = row.cellByKey.get(column.key); <div className="relative flex-none border-b border-ink bg-sand" style={{ width }}>
if (!cell) return <div key={column.key} className="h-8 border-b border-r border-ink bg-sand" />; {/* Tranches de 30 min, filet épais entre deux jours. */}
return ( {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 <div
key={column.key} key={bar.assignmentId}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold" 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>
); ))}
})} {nowX !== null ? (
<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"> <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} {row.total}
</div> </div>
</> </div>
); );
} }
+6 -16
View File
@@ -16,7 +16,6 @@ import {
import { import {
FACE_LABEL_KEY, FACE_LABEL_KEY,
fmt, fmt,
initials,
isVerticallyCentered, isVerticallyCentered,
renderField, renderField,
SAFE_MARGIN_MM, SAFE_MARGIN_MM,
@@ -25,8 +24,6 @@ import {
type FieldState, type FieldState,
} from './model'; } 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). */ /** Pas d'accroche du glisser-déposer (mm). */
const DRAG_SNAP_MM = 0.5; const DRAG_SNAP_MM = 0.5;
/** Plus petit cadre possible au redimensionnement (mm). */ /** Plus petit cadre possible au redimensionnement (mm). */
@@ -301,7 +298,7 @@ function FieldBox({
}} }}
> >
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? ( {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' ? ( ) : rendered?.kind === 'table' ? (
<TableContent field={field} layout={rendered.layout} scale={scale} /> <TableContent field={field} layout={rendered.layout} scale={scale} />
) : rendered?.kind === 'text' ? ( ) : rendered?.kind === 'text' ? (
@@ -450,12 +447,10 @@ function PhotoContent({
field, field,
volunteer, volunteer,
photoUrl, photoUrl,
scale,
}: { }: {
field: FieldState; field: FieldState;
volunteer: VolunteerRead | null; volunteer: VolunteerRead | null;
photoUrl: string | null; photoUrl: string | null;
scale: number;
}) { }) {
const radius = field.photo_shape === 'OVAL' ? '50%' : 0; const radius = field.photo_shape === 'OVAL' ? '50%' : 0;
if (volunteer?.has_photo && photoUrl) { 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 ( return (
<div <div
className="pointer-events-none flex h-full w-full items-center justify-center font-bold" 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={{ style={{ borderRadius: radius }}
borderRadius: radius,
background: PHOTO_PLACEHOLDER_BG,
color: field.color,
fontFamily: FONT_STACK,
// Comme le PDF : 0,28 × la largeur du cadre, 4 pt au moins.
fontSize: Math.max(4 * MM_PER_PT, field.width_mm * 0.28) * scale,
}}
> >
{volunteer ? initials(volunteer) : null} {t('badges.preview.noPhoto')}
</div> </div>
); );
} }
-4
View File
@@ -258,10 +258,6 @@ export interface BadgeData {
dayBoundary: string; 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 { export function fullNameText(volunteer: VolunteerRead): string {
return `${volunteer.first_name}\n${volunteer.last_name}`; return `${volunteer.first_name}\n${volunteer.last_name}`;
} }