Étape 22 : feuilles de poste (PDF), complément des badges

Une page par poste et par jour, listant les créneaux avec les bénévoles
affectés et leur téléphone — à imprimer et afficher sur place. Aucune
nouvelle entité : dérivé de Position/Shift/Assignment/Volunteer
existants (contrairement aux badges, pas de personnalisation à
configurer). Un créneau à besoin 0/0 sans affectation n'apparaît pas.

Backend : app/sheets/pdf.py (ReportLab, Platypus — SimpleDocTemplate/
Table plutôt que le canvas bas niveau des badges, plus adapté à un
rapport texte multi-pages), POST /editions/{id}/position-sheets/generate
(COORDINATION, {position_ids?} — absent = tous les postes).

Frontend : bouton « Feuilles de poste » dans la grille des besoins,
respecte le filtre de catégorie déjà actif à l'écran.

Vérifié : pytest (284 passés), ruff, alembic check, tsc --noEmit,
eslint, vite build, smoke (flux de bout en bout en mode démonstration)
— tous verts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-21 21:27:01 +02:00
co-authored by Claude Sonnet 5
parent 111d1e5c4b
commit 40127a2252
17 changed files with 561 additions and 4 deletions
+13 -1
View File
@@ -175,6 +175,15 @@ les tables sauf les tables d'association.
pour rien : juste ReportLab) : pages recto personnalisées suivies, planche pour rien : juste ReportLab) : pages recto personnalisées suivies, planche
par planche, de la page de verso correspondante (même image répétée à par planche, de la page de verso correspondante (même image répétée à
chaque emplacement) — impression recto-verso classique. chaque emplacement) — impression recto-verso classique.
- **Feuilles de poste** (PDF, `app/sheets/pdf.py` + `app/api/position_sheets.py`) :
aucune nouvelle entité — dérivées de `Position`/`Shift`/`Assignment`/
`Volunteer` existants. Complément des badges (un badge identifie un
bénévole ; une feuille identifie un poste pour la journée) : une page par
poste et par jour, listant les créneaux du jour 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).
- **`FormTemplate`** : configuration du formulaire public d'inscription - **`FormTemplate`** : configuration du formulaire public d'inscription
d'une édition (une ligne, créée à la demande). `is_open` coupe les d'une édition (une ligne, créée à la demande). `is_open` coupe les
nouvelles soumissions sans effacer la configuration. nouvelles soumissions sans effacer la configuration.
@@ -233,6 +242,7 @@ Matrice appliquée par les endpoints :
| `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — | | `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — |
| `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — | | `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — |
| `badges` (config + génération) | ✔ | — | — | | `badges` (config + génération) | ✔ | — | — |
| `position-sheets` (génération) | ✔ | — | — |
| `form` (config) | ✔ | — | — | | `form` (config) | ✔ | — | — |
| `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* ||| | `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* |||
@@ -485,7 +495,7 @@ l'étape 21.)*
│ │ │ # day_parts, imports, artists, volunteers, │ │ │ # day_parts, imports, artists, volunteers,
│ │ │ # availability, positions, shifts, │ │ │ # availability, positions, shifts,
│ │ │ # assignments, solver, matrix, notifications, │ │ │ # assignments, solver, matrix, notifications,
│ │ │ # badges, forms │ │ │ # badges, position_sheets, forms
│ │ ├── auth/ # mot de passe + lien magique + JWT + rôles │ │ ├── auth/ # mot de passe + lien magique + JWT + rôles
│ │ ├── email/ # SMTP (sender) + rendu des emails (templates) │ │ ├── email/ # SMTP (sender) + rendu des emails (templates)
│ │ ├── importing/ # import CSV/Excel (parser, text, service) │ │ ├── importing/ # import CSV/Excel (parser, text, service)
@@ -493,6 +503,7 @@ l'étape 21.)*
│ │ ├── validation/ # moteur pur (models, engine) + loader (base) │ │ ├── validation/ # moteur pur (models, engine) + loader (base)
│ │ ├── solver/ # suggestions OR-Tools (models, engine, loader) │ │ ├── solver/ # suggestions OR-Tools (models, engine, loader)
│ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab) │ │ ├── badges/ # génération PDF des planches (pdf.py, ReportLab)
│ │ ├── sheets/ # génération PDF des feuilles de poste (pdf.py)
│ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py │ │ ├── forms/ # hCaptcha (captcha.py) — voir api/forms.py
│ │ ├── bootstrap.py # instance vierge : édition + 1er compte │ │ ├── bootstrap.py # instance vierge : édition + 1er compte
│ │ ├── seed.py # jeu de démonstration (python -m app.seed) │ │ ├── seed.py # jeu de démonstration (python -m app.seed)
@@ -605,6 +616,7 @@ Aucune dépendance locale requise hors Docker.
| 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider | | 19 | Revue de sécurité avant ouverture au public : refus de démarrer en production avec un `JWT_SECRET` par défaut/vide/court, avertissement si `EMAIL_ENABLED=false` en production, en-têtes de sécurité (nginx + Caddy), pas de jeton dans les logs d'accès du frontend, limiteur de tentatives sur `/auth/login` et `/auth/magic-link` | à valider |
| 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider | | 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider |
| 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider | | 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider |
| 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+115
View File
@@ -0,0 +1,115 @@
"""Feuilles de poste (PDF) : une page par poste et par jour, listant les
créneaux du jour avec les bénévoles affectés et leur téléphone — à imprimer
et afficher sur place. Complément des badges (`app/api/badges.py`) : un
badge identifie un bénévole, une feuille identifie un poste pour la
journée. Voir `app/sheets/pdf.py` pour la composition du PDF.
"""
import datetime as dt
import uuid
from collections import defaultdict
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlmodel import Session, select
from app.api.deps import get_edition
from app.api.schemas import PositionSheetsRequest
from app.auth.deps import require_coordinator
from app.db import get_session
from app.models import Account, Assignment, Edition, Position, Shift, Volunteer
from app.sheets.pdf import (
PositionDaySheet,
SheetShift,
SheetVolunteer,
generate_position_sheets_pdf,
)
router = APIRouter(
prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"]
)
@router.post("/generate")
def generate_position_sheets(
payload: PositionSheetsRequest,
edition: Edition = Depends(get_edition),
_guard: Account = Depends(require_coordinator),
session: Session = Depends(get_session),
) -> Response:
query = select(Position).where(Position.edition_id == edition.id)
if payload.position_ids is not None:
query = query.where(Position.id.in_(payload.position_ids))
positions = list(session.exec(query.order_by(Position.name)))
if not positions:
raise HTTPException(
status_code=422, detail={"code": "position_sheets.no_positions"}
)
position_ids = [p.id for p in positions]
shifts = list(
session.exec(
select(Shift)
.where(Shift.position_id.in_(position_ids))
.order_by(Shift.starts_at)
)
)
shift_ids = [s.id for s in shifts]
volunteers_by_shift: dict[uuid.UUID, list[Volunteer]] = defaultdict(list)
if shift_ids:
rows = session.exec(
select(Assignment.shift_id, Volunteer)
.join(Volunteer, Volunteer.id == Assignment.volunteer_id)
.where(Assignment.shift_id.in_(shift_ids))
).all()
for shift_id, volunteer in rows:
volunteers_by_shift[shift_id].append(volunteer)
for vs in volunteers_by_shift.values():
vs.sort(key=lambda v: (v.last_name, v.first_name))
# Un jour sans aucun besoin réel (créneau 0/0 généré automatiquement,
# jamais rempli) n'apparaît pas — évite des pages « personne affecté »
# pour des créneaux qui ne représentent aucun besoin.
shifts_by_position_day: dict[tuple[uuid.UUID, dt.date], list[Shift]] = defaultdict(list)
for shift in shifts:
undefined_need = shift.min_staff == 0 and shift.ideal_staff == 0
if undefined_need and not volunteers_by_shift.get(shift.id):
continue
shifts_by_position_day[(shift.position_id, shift.starts_at.date())].append(shift)
positions_by_id = {p.id: p for p in positions}
sheets = [
PositionDaySheet(
position_name=positions_by_id[position_id].name,
position_location=positions_by_id[position_id].location,
date=date,
shifts=tuple(
SheetShift(
starts_at=s.starts_at,
ends_at=s.ends_at,
min_staff=s.min_staff,
ideal_staff=s.ideal_staff,
volunteers=tuple(
SheetVolunteer(name=f"{v.first_name} {v.last_name}", phone=v.phone)
for v in volunteers_by_shift.get(s.id, [])
),
)
for s in sorted(day_shifts, key=lambda s: s.starts_at)
),
)
for (position_id, date), day_shifts in sorted(
shifts_by_position_day.items(),
key=lambda item: (positions_by_id[item[0][0]].name, item[0][1]),
)
]
if not sheets:
raise HTTPException(
status_code=422, detail={"code": "position_sheets.no_shifts"}
)
pdf_bytes = generate_position_sheets_pdf(sheets)
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="feuilles-de-poste.pdf"'},
)
+8
View File
@@ -398,6 +398,14 @@ class BadgeGenerateRequest(ApiModel):
volunteer_ids: list[uuid.UUID] | None = None volunteer_ids: list[uuid.UUID] | None = None
# --------------------------------------------------------------------------- #
# Feuilles de poste (PDF) #
# --------------------------------------------------------------------------- #
class PositionSheetsRequest(ApiModel):
# None/absent = tous les postes de l'édition.
position_ids: list[uuid.UUID] | None = None
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Authentification et comptes # # Authentification et comptes #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+3 -1
View File
@@ -80,5 +80,7 @@
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.", "form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
"form.photo_too_large": "La photo est trop volumineuse.", "form.photo_too_large": "La photo est trop volumineuse.",
"form.too_many_position_wishes": "Deux vœux de poste au maximum.", "form.too_many_position_wishes": "Deux vœux de poste au maximum.",
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival." "form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
"position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes."
} }
+2
View File
@@ -16,6 +16,7 @@ from app.api.health import router as health_router
from app.api.imports import router as import_router from app.api.imports import router as import_router
from app.api.matrix import router as matrix_router from app.api.matrix import router as matrix_router
from app.api.notifications import router as notification_router from app.api.notifications import router as notification_router
from app.api.position_sheets import router as position_sheets_router
from app.api.positions import router as position_router from app.api.positions import router as position_router
from app.api.shifts import router as shift_router from app.api.shifts import router as shift_router
from app.api.solver import router as solver_router from app.api.solver import router as solver_router
@@ -74,3 +75,4 @@ app.include_router(matrix_router)
app.include_router(notification_router) app.include_router(notification_router)
app.include_router(badge_router) app.include_router(badge_router)
app.include_router(form_router) app.include_router(form_router)
app.include_router(position_sheets_router)
+13
View File
@@ -0,0 +1,13 @@
from app.sheets.pdf import (
PositionDaySheet,
SheetShift,
SheetVolunteer,
generate_position_sheets_pdf,
)
__all__ = [
"PositionDaySheet",
"SheetShift",
"SheetVolunteer",
"generate_position_sheets_pdf",
]
+148
View File
@@ -0,0 +1,148 @@
"""Génération des feuilles de poste (PDF).
Module isolé, fonction pure (`generate_position_sheets_pdf`) : aucun accès
base ici — voir `app/api/position_sheets.py` pour l'assemblage des données.
Une page par poste et par jour, listant les créneaux du jour avec les
bénévoles affectés et leur téléphone — à imprimer et afficher sur place.
Complément des badges (`app/badges/pdf.py`) : un badge identifie un
bénévole, une feuille identifie un poste pour la journée.
"""
import datetime as dt
import io
from dataclasses import dataclass
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.platypus import (
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
_WEEKDAYS_FR = ["Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi", "Dimanche"]
_MONTHS_FR = [
"janvier", "février", "mars", "avril", "mai", "juin",
"juillet", "août", "septembre", "octobre", "novembre", "décembre",
]
def _format_day_fr(date: dt.date) -> str:
return f"{_WEEKDAYS_FR[date.weekday()]} {date.day} {_MONTHS_FR[date.month - 1]} {date.year}"
@dataclass(frozen=True)
class SheetVolunteer:
name: str
phone: str | None
@dataclass(frozen=True)
class SheetShift:
starts_at: dt.datetime
ends_at: dt.datetime
min_staff: int
ideal_staff: int
volunteers: tuple[SheetVolunteer, ...]
@property
def understaffed(self) -> bool:
return len(self.volunteers) < self.min_staff
@dataclass(frozen=True)
class PositionDaySheet:
"""Une page : un poste, un jour, ses créneaux triés chronologiquement."""
position_name: str
position_location: str | None
date: dt.date
shifts: tuple[SheetShift, ...]
def _styles() -> dict[str, ParagraphStyle]:
base = getSampleStyleSheet()
return {
"title": ParagraphStyle(
"SheetTitle", parent=base["Title"], alignment=0, fontSize=22, spaceAfter=2
),
"subtitle": ParagraphStyle(
"SheetSubtitle", parent=base["Normal"],
fontSize=12, textColor=colors.grey, spaceAfter=16,
),
"shiftHeader": ParagraphStyle(
"ShiftHeader", parent=base["Heading3"], fontSize=13, spaceBefore=12, spaceAfter=4
),
"shiftHeaderWarning": ParagraphStyle(
"ShiftHeaderWarning", parent=base["Heading3"],
fontSize=13, spaceBefore=12, spaceAfter=4, textColor=colors.HexColor("#b23a1f"),
),
"normal": base["Normal"],
}
def _shift_table(shift: SheetShift) -> Table:
rows = [["Bénévole", "Téléphone"]] + [
[v.name, v.phone or "—"] for v in shift.volunteers
]
table = Table(rows, colWidths=[110 * mm, 55 * mm])
table.setStyle(
TableStyle(
[
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 10.5),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LINEBELOW", (0, 0), (-1, 0), 0.75, colors.black),
("GRID", (0, 1), (-1, -1), 0.25, colors.lightgrey),
]
)
)
return table
def generate_position_sheets_pdf(sheets: list[PositionDaySheet]) -> bytes:
styles = _styles()
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer, pagesize=A4,
leftMargin=18 * mm, rightMargin=18 * mm, topMargin=16 * mm, bottomMargin=16 * mm,
)
story = []
for i, sheet in enumerate(sheets):
if i > 0:
story.append(PageBreak())
story.append(Paragraph(sheet.position_name, styles["title"]))
subtitle = _format_day_fr(sheet.date)
if sheet.position_location:
subtitle += f" — {sheet.position_location}"
story.append(Paragraph(subtitle, styles["subtitle"]))
if not sheet.shifts:
story.append(Paragraph("Aucun créneau ce jour-là.", styles["normal"]))
continue
for shift in sheet.shifts:
header = (
f"{shift.starts_at:%H:%M} – {shift.ends_at:%H:%M}"
f" ({len(shift.volunteers)}/{shift.ideal_staff})"
)
if shift.understaffed:
header += " ⚠ sous l'effectif minimal"
style = styles["shiftHeaderWarning"] if shift.understaffed else styles["shiftHeader"]
story.append(Paragraph(header, style))
if shift.volunteers:
story.append(_shift_table(shift))
else:
story.append(Paragraph("Personne affecté pour l'instant.", styles["normal"]))
story.append(Spacer(1, 0))
doc.build(story)
return buffer.getvalue()
+69
View File
@@ -0,0 +1,69 @@
"""Feuilles de poste (PDF) — API."""
from app.models import Assignment, Role
from tests.factories import dt_at, make_position, make_shift, make_volunteer
def _url(edition):
return f"/editions/{edition.id}/position-sheets/generate"
def test_is_coordinator_only(edition, make_account, as_account):
lead = make_account(Role.LEAD, email="lead@e.fr")
client = as_account(lead)
resp = client.post(_url(edition), json={})
assert resp.status_code == 403
def test_rejects_when_no_position_matches(client, edition):
resp = client.post(
_url(edition),
json={"position_ids": ["00000000-0000-0000-0000-000000000000"]},
)
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position_sheets.no_positions"
def test_rejects_when_no_real_need_to_print(client, edition, session):
# Créneaux générés à 0/0, jamais remplis : rien à imprimer.
make_position(session, edition, name="Bar central")
resp = client.post(_url(edition), json={})
assert resp.status_code == 422
assert resp.json()["detail"]["code"] == "position_sheets.no_shifts"
def test_generates_a_pdf_with_assigned_volunteers(client, edition, session):
position = make_position(session, edition, name="Bar central")
shift = make_shift(
session, edition, position, start=dt_at(9, 19), end=dt_at(9, 22),
min_staff=1, ideal_staff=2,
)
volunteer = make_volunteer(session, edition, first_name="Alex", last_name="Martin")
session.add(
Assignment(edition_id=edition.id, volunteer_id=volunteer.id, shift_id=shift.id)
)
session.commit()
resp = client.post(_url(edition), json={})
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/pdf"
assert resp.content.startswith(b"%PDF")
def test_position_ids_filter_scopes_the_sheets(client, edition, session):
bar = make_position(session, edition, name="Bar central")
welcome = make_position(session, edition, name="Accueil")
make_shift(
session, edition, bar, start=dt_at(9, 19), end=dt_at(9, 22),
min_staff=1, ideal_staff=2,
)
make_shift(
session, edition, welcome, start=dt_at(9, 9), end=dt_at(9, 13),
min_staff=1, ideal_staff=2,
)
resp = client.post(_url(edition), json={"position_ids": [str(bar.id)]})
assert resp.status_code == 200
resp = client.post(_url(edition), json={"position_ids": [str(welcome.id)]})
assert resp.status_code == 200
+70
View File
@@ -0,0 +1,70 @@
"""Génération des feuilles de poste (module pur, sans base)."""
import datetime as dt
from app.sheets.pdf import (
PositionDaySheet,
SheetShift,
SheetVolunteer,
generate_position_sheets_pdf,
)
def _sheet(volunteers=(), min_staff=1, ideal_staff=2) -> PositionDaySheet:
return PositionDaySheet(
position_name="Bar central",
position_location="Chapiteau",
date=dt.date(2026, 7, 10),
shifts=(
SheetShift(
starts_at=dt.datetime(2026, 7, 10, 19, 0),
ends_at=dt.datetime(2026, 7, 10, 22, 0),
min_staff=min_staff,
ideal_staff=ideal_staff,
volunteers=volunteers,
),
),
)
def test_shift_is_understaffed_when_below_min_staff():
fully_staffed = _sheet(
volunteers=(SheetVolunteer(name="Alex Martin", phone="0600000000"),),
min_staff=1,
)
assert fully_staffed.shifts[0].understaffed is False
understaffed = _sheet(volunteers=(), min_staff=1)
assert understaffed.shifts[0].understaffed is True
def test_generates_a_valid_pdf_with_volunteers():
sheet = _sheet(
volunteers=(
SheetVolunteer(name="Alex Martin", phone="0600000000"),
SheetVolunteer(name="Sam Nuit", phone=None),
)
)
pdf = generate_position_sheets_pdf([sheet])
assert pdf.startswith(b"%PDF")
assert pdf.rstrip().endswith(b"%%EOF")
def test_generates_a_valid_pdf_with_no_volunteers_assigned():
pdf = generate_position_sheets_pdf([_sheet(volunteers=())])
assert pdf.startswith(b"%PDF")
def test_generates_a_valid_pdf_with_no_shifts_that_day():
sheet = PositionDaySheet(
position_name="Bar central", position_location=None,
date=dt.date(2026, 7, 10), shifts=(),
)
pdf = generate_position_sheets_pdf([sheet])
assert pdf.startswith(b"%PDF")
def test_multiple_sheets_produce_a_larger_pdf():
one = generate_position_sheets_pdf([_sheet()])
two = generate_position_sheets_pdf([_sheet(), _sheet()])
assert len(two) > len(one)
+27
View File
@@ -155,6 +155,32 @@ async function checkPublicForm() {
); );
} }
/** Feuilles de poste PDF (mode mock) : la sélection filtre bien, l'absence
* de créneau/poste rejette avec le bon code. */
async function checkPositionSheets() {
const positions = await api.positions.list(e);
const first = positions[0];
if (!first) throw new Error('feuilles de poste : aucun poste en démo pour tester');
const blob = await api.positionSheets.generate(e, { position_ids: [first.id] });
if (!(blob instanceof Blob) || blob.size === 0) {
throw new Error('feuilles de poste : PDF vide');
}
let noPositionsRejectedWith = 'non';
try {
await api.positionSheets.generate(e, {
position_ids: ['00000000-0000-0000-0000-000000000000'],
});
} catch (err) {
noPositionsRejectedWith = err instanceof ApiError ? err.code : String(err);
}
console.log(
JSON.stringify({ pdfBytes: blob.size, noPositionsRejectedWith }, null, 2),
);
}
async function prefetchCore(client: QueryClient) { async function prefetchCore(client: QueryClient) {
await Promise.all([ await Promise.all([
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }), client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
@@ -223,6 +249,7 @@ function renderPublic(path: string) {
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
await checkAuth().catch((err) => fail('checkAuth', err)); await checkAuth().catch((err) => fail('checkAuth', err));
await checkPublicForm().catch((err) => fail('checkPublicForm', err)); await checkPublicForm().catch((err) => fail('checkPublicForm', err));
await checkPositionSheets().catch((err) => fail('checkPositionSheets', err));
renderPublic('/connexion'); renderPublic('/connexion');
renderPublic('/connexion/coordination'); renderPublic('/connexion/coordination');
+10
View File
@@ -28,6 +28,7 @@ import type {
ImportMapping, ImportMapping,
PlanningNotificationRequest, PlanningNotificationRequest,
PositionCreate, PositionCreate,
PositionSheetsRequest,
PositionUpdate, PositionUpdate,
PublicFormSubmission, PublicFormSubmission,
ShiftCreate, ShiftCreate,
@@ -526,6 +527,15 @@ export function useRunSolver() {
}); });
} }
/** Feuilles de poste PDF (une page par poste et par jour, créneaux +
* bénévoles affectés + téléphone) ; ne fait que retourner le fichier (le
* composant appelant déclenche le téléchargement). */
export function useGeneratePositionSheets() {
return useMutation({
mutationFn: (body: PositionSheetsRequest) => api.positionSheets.generate(edition, body),
});
}
export function useSendPlanningNotification() { export function useSendPlanningNotification() {
return useMutation({ return useMutation({
mutationFn: (body: PlanningNotificationRequest) => mutationFn: (body: PlanningNotificationRequest) =>
+11
View File
@@ -20,6 +20,7 @@ import type {
MagicLinkRequest, MagicLinkRequest,
PlanningNotificationRequest, PlanningNotificationRequest,
PositionCreate, PositionCreate,
PositionSheetsRequest,
PositionUpdate, PositionUpdate,
PublicFormSubmission, PublicFormSubmission,
ShiftCreate, ShiftCreate,
@@ -299,6 +300,16 @@ export function createHttpApi(baseUrl: string): PlanningApi {
return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form }); return request(`${base(editionId)}/form/public/submit`, { method: 'POST', body: form });
}, },
}, },
positionSheets: {
generate: async (editionId, body: PositionSheetsRequest) => {
const blob = await requestBlob(`${base(editionId)}/position-sheets/generate`, {
method: 'POST',
body: JSON.stringify(body),
});
if (!blob) throw new ApiError(500, 'error.generic', []);
return blob;
},
},
solver: { solver: {
run: (editionId) => run: (editionId) =>
request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }), request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }),
+14
View File
@@ -30,6 +30,7 @@ import type {
ImportRowReport, ImportRowReport,
MatrixResponse, MatrixResponse,
PlanningNotificationRequest, PlanningNotificationRequest,
PositionSheetsRequest,
PublicFormConfig, PublicFormConfig,
PublicFormSubmission, PublicFormSubmission,
ShiftRead, ShiftRead,
@@ -1557,6 +1558,19 @@ export function createMockApi(): PlanningApi {
}, },
}, },
positionSheets: {
generate: (_edition, body: PositionSheetsRequest) => {
const ids = body.position_ids ?? state.positions.map((p) => p.id);
const matching = state.positions.filter((p) => ids.includes(p.id));
if (matching.length === 0) throw new ApiError(422, 'position_sheets.no_positions');
const hasShifts = state.shifts.some((s) => matching.some((p) => p.id === s.position_id));
if (!hasShifts) throw new ApiError(422, 'position_sheets.no_shifts');
// La composition réelle du PDF (reportlab) est réservée au back-end :
// le mode démonstration renvoie un PDF minimal valide, téléchargeable.
return delay(MOCK_PDF_BLOB());
},
},
solver: { solver: {
run: () => delay(runSolver()), run: () => delay(runSolver()),
}, },
+7
View File
@@ -473,6 +473,13 @@ export interface BadgeSizeUpdate {
badge_height_mm: number; badge_height_mm: number;
} }
/** Feuilles de poste (PDF) : une page par poste et par jour, créneaux +
* bénévoles affectés + téléphone. */
export interface PositionSheetsRequest {
// null/absent = tous les postes de l'édition.
position_ids?: string[] | null;
}
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Authentification // // Authentification //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
+5
View File
@@ -38,6 +38,7 @@ import type {
PlanningNotificationResult, PlanningNotificationResult,
PositionCreate, PositionCreate,
PositionRead, PositionRead,
PositionSheetsRequest,
PositionUpdate, PositionUpdate,
PublicFormConfig, PublicFormConfig,
PublicFormSubmission, PublicFormSubmission,
@@ -170,6 +171,10 @@ export interface PlanningApi {
getPublicConfig(editionId: string): Promise<PublicFormConfig>; getPublicConfig(editionId: string): Promise<PublicFormConfig>;
submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>; submitPublic(editionId: string, body: PublicFormSubmission): Promise<FormSubmissionResult>;
}; };
positionSheets: {
/** Feuilles de poste PDF (une page par poste et par jour). */
generate(editionId: string, body: PositionSheetsRequest): Promise<Blob>;
};
solver: { solver: {
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */ /** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
run(editionId: string): Promise<SolverRunResult>; run(editionId: string): Promise<SolverRunResult>;
+5 -1
View File
@@ -318,6 +318,8 @@
"grille.solver.run": "SUGGESTIONS (SOLVEUR)", "grille.solver.run": "SUGGESTIONS (SOLVEUR)",
"grille.solver.running": "CALCUL EN COURS…", "grille.solver.running": "CALCUL EN COURS…",
"grille.solver.result": "{count} suggestion(s)", "grille.solver.result": "{count} suggestion(s)",
"grille.sheets.generate": "FEUILLES DE POSTE",
"grille.sheets.generating": "GÉNÉRATION…",
"grille.panel.suggested": "Suggéré", "grille.panel.suggested": "Suggéré",
"config.artists.startTime": "Heure de début du passage", "config.artists.startTime": "Heure de début du passage",
"config.artists.endTime": "Heure de fin du passage", "config.artists.endTime": "Heure de fin du passage",
@@ -406,5 +408,7 @@
"form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.", "form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.",
"form.photo_too_large": "La photo est trop volumineuse.", "form.photo_too_large": "La photo est trop volumineuse.",
"form.too_many_position_wishes": "Deux vœux de poste au maximum.", "form.too_many_position_wishes": "Deux vœux de poste au maximum.",
"form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival." "form.too_many_artists_same_day": "Un seul artiste souhaité par soir de festival.",
"position_sheets.no_positions": "Aucun poste ne correspond à cette sélection.",
"position_sheets.no_shifts": "Aucun créneau à imprimer pour ces postes."
} }
+41 -1
View File
@@ -1,6 +1,14 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useAssignments, useDayParts, useMatrix, useRunSolver, useVolunteers } from '@/api/hooks'; import {
useAssignments,
useDayParts,
useGeneratePositionSheets,
useMatrix,
useRunSolver,
useVolunteers,
} from '@/api/hooks';
import { ApiError } from '@/api';
import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema'; import type { MatrixPosition, MatrixShift, PositionCategory } 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';
@@ -20,6 +28,7 @@ export function GrillePage() {
const matrix = useMatrix(); const matrix = useMatrix();
const dayParts = useDayParts(); const dayParts = useDayParts();
const runSolver = useRunSolver(); const runSolver = useRunSolver();
const generateSheets = useGeneratePositionSheets();
const assignments = useAssignments(); const assignments = useAssignments();
const volunteers = useVolunteers(); const volunteers = useVolunteers();
const [dayFilter, setDayFilter] = useState<'all' | string>('all'); const [dayFilter, setDayFilter] = useState<'all' | string>('all');
@@ -85,6 +94,22 @@ export function GrillePage() {
.find((entry) => entry.shift.shift_id === selectedShiftId) ?? null .find((entry) => entry.shift.shift_id === selectedShiftId) ?? null
: null; : null;
const downloadPositionSheets = async () => {
try {
const blob = await generateSheets.mutateAsync({
position_ids: shownPositions.map((p) => p.id),
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'feuilles-de-poste.pdf';
a.click();
URL.revokeObjectURL(url);
} catch {
// L'erreur est déjà exposée via generateSheets.isError / .error.
}
};
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]">
@@ -135,6 +160,21 @@ export function GrillePage() {
{t('error.generic')} {t('error.generic')}
</span> </span>
) : null} ) : null}
<button
type="button"
onClick={downloadPositionSheets}
disabled={generateSheets.isPending}
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wide shadow-brutal-sm hover:bg-yellow disabled:opacity-60"
>
{generateSheets.isPending ? t('grille.sheets.generating') : t('grille.sheets.generate')}
</button>
{generateSheets.isError ? (
<span className="font-mono text-[9px] font-bold text-orange-dark">
{generateSheets.error instanceof ApiError
? t(generateSheets.error.code)
: t('error.generic')}
</span>
) : null}
</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 items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">