Fiches de poste rapides, badges sans initiales, vue par bénévole sur l'axe des temps
É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:
co-authored by
Claude Opus 5.5
parent
c57c70569c
commit
c6ea7378c7
@@ -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 +
|
||||
|
||||
@@ -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.
|
||||
|
||||
+31
-30
@@ -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,34 +224,33 @@ 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:
|
||||
# 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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()) == ""
|
||||
|
||||
@@ -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 = []
|
||||
def test_missing_photo_prints_nothing(monkeypatch):
|
||||
calls = []
|
||||
for name in ("drawImage", "drawPath", "drawCentredString", "drawString"):
|
||||
monkeypatch.setattr(
|
||||
pdf_module.canvas.Canvas, "drawCentredString",
|
||||
lambda self, x, y, text, *a, **k: drawn.append(text),
|
||||
pdf_module.canvas.Canvas, name,
|
||||
lambda self, *a, _name=name, **k: calls.append(_name),
|
||||
)
|
||||
for image in (None, b"pas une image"):
|
||||
badge = BadgeSpec(
|
||||
fields=(BadgeFieldSpec(**{**_photo_badge(image).fields[0].__dict__, "text": "AM"}),)
|
||||
)
|
||||
generate_badge_sheets_pdf([badge], None, None)
|
||||
assert drawn == ["AM", "AM"]
|
||||
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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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,54 +200,77 @@ 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">
|
||||
<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>
|
||||
{lattice.days.map((day) => (
|
||||
{axis.layout.map(({ day, width }) => (
|
||||
<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}` }}
|
||||
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>
|
||||
<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 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')}
|
||||
{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]">
|
||||
</div>
|
||||
<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>
|
||||
{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
|
||||
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]"
|
||||
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 }}
|
||||
>
|
||||
{column.time}
|
||||
{label}
|
||||
</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')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rows.map((row) => (
|
||||
<VolunteerRow
|
||||
key={row.volunteer.id}
|
||||
row={row}
|
||||
columns={lattice.columns}
|
||||
abbr={abbr}
|
||||
/>
|
||||
<VolunteerRow key={row.volunteer.id} row={row} layout={axis.layout} width={axis.width} nowX={nowX} />
|
||||
))}
|
||||
</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 }) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user