diff --git a/CLAUDE.md b/CLAUDE.md index 9674bbe..4c6df5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,6 +175,15 @@ les tables sauf les tables d'association. 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 à 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 d'une édition (une ligne, créée à la demande). `is_open` coupe les nouvelles soumissions sans effacer la configuration. @@ -233,6 +242,7 @@ Matrice appliquée par les endpoints : | `availability` lecture / écriture | ✔ / ✔ | ✔ / — | la sienne / — | | `matrix`, `import`, `accounts` | ✔ / ✔ / ✔ | ✔ / — / — | — | | `badges` (config + génération) | ✔ | — | — | +| `position-sheets` (génération) | ✔ | — | — | | `form` (config) | ✔ | — | — | | `form/public`, `form/public/submit` | *(aucune authentification — voir § 2, `FormField`)* ||| @@ -485,7 +495,7 @@ l'étape 21.)* │ │ │ # day_parts, imports, artists, volunteers, │ │ │ # availability, positions, shifts, │ │ │ # assignments, solver, matrix, notifications, -│ │ │ # badges, forms +│ │ │ # badges, position_sheets, forms │ │ ├── auth/ # mot de passe + lien magique + JWT + rôles │ │ ├── email/ # SMTP (sender) + rendu des emails (templates) │ │ ├── importing/ # import CSV/Excel (parser, text, service) @@ -493,6 +503,7 @@ l'étape 21.)* │ │ ├── validation/ # moteur pur (models, engine) + loader (base) │ │ ├── solver/ # suggestions OR-Tools (models, engine, loader) │ │ ├── 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 │ │ ├── bootstrap.py # instance vierge : édition + 1er compte │ │ ├── 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 | | 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 | +| 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 de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + diff --git a/backend/app/api/position_sheets.py b/backend/app/api/position_sheets.py new file mode 100644 index 0000000..0641fdc --- /dev/null +++ b/backend/app/api/position_sheets.py @@ -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"'}, + ) diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 9e8b2e0..f1750b8 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -398,6 +398,14 @@ class BadgeGenerateRequest(ApiModel): 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 # # --------------------------------------------------------------------------- # diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index 5b9a68a..1a3939c 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -80,5 +80,7 @@ "form.photo_type_not_supported": "La photo doit être au format PNG ou JPEG.", "form.photo_too_large": "La photo est trop volumineuse.", "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." } diff --git a/backend/app/main.py b/backend/app/main.py index fb1c5be..964d0a3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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.matrix import router as matrix_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.shifts import router as shift_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(badge_router) app.include_router(form_router) +app.include_router(position_sheets_router) diff --git a/backend/app/sheets/__init__.py b/backend/app/sheets/__init__.py new file mode 100644 index 0000000..d0c77ba --- /dev/null +++ b/backend/app/sheets/__init__.py @@ -0,0 +1,13 @@ +from app.sheets.pdf import ( + PositionDaySheet, + SheetShift, + SheetVolunteer, + generate_position_sheets_pdf, +) + +__all__ = [ + "PositionDaySheet", + "SheetShift", + "SheetVolunteer", + "generate_position_sheets_pdf", +] diff --git a/backend/app/sheets/pdf.py b/backend/app/sheets/pdf.py new file mode 100644 index 0000000..49c497c --- /dev/null +++ b/backend/app/sheets/pdf.py @@ -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() diff --git a/backend/tests/test_position_sheets_api.py b/backend/tests/test_position_sheets_api.py new file mode 100644 index 0000000..5f2722a --- /dev/null +++ b/backend/tests/test_position_sheets_api.py @@ -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 diff --git a/backend/tests/test_position_sheets_pdf.py b/backend/tests/test_position_sheets_pdf.py new file mode 100644 index 0000000..6f6bad7 --- /dev/null +++ b/backend/tests/test_position_sheets_pdf.py @@ -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) diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index a9a2f85..5da7181 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -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) { await Promise.all([ 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 checkPublicForm().catch((err) => fail('checkPublicForm', err)); +await checkPositionSheets().catch((err) => fail('checkPositionSheets', err)); renderPublic('/connexion'); renderPublic('/connexion/coordination'); diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index c06ca7c..26797ba 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -28,6 +28,7 @@ import type { ImportMapping, PlanningNotificationRequest, PositionCreate, + PositionSheetsRequest, PositionUpdate, PublicFormSubmission, 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() { return useMutation({ mutationFn: (body: PlanningNotificationRequest) => diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 479cde1..59de2a5 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -20,6 +20,7 @@ import type { MagicLinkRequest, PlanningNotificationRequest, PositionCreate, + PositionSheetsRequest, PositionUpdate, PublicFormSubmission, ShiftCreate, @@ -299,6 +300,16 @@ export function createHttpApi(baseUrl: string): PlanningApi { 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: { run: (editionId) => request(`${base(editionId)}/solver/suggestions`, { method: 'POST' }), diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 4fb048c..6def28a 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -30,6 +30,7 @@ import type { ImportRowReport, MatrixResponse, PlanningNotificationRequest, + PositionSheetsRequest, PublicFormConfig, PublicFormSubmission, 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: { run: () => delay(runSolver()), }, diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 6770098..bcc72d5 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -473,6 +473,13 @@ export interface BadgeSizeUpdate { 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 // // --------------------------------------------------------------------------- // diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b19e7fb..fd6f62b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -38,6 +38,7 @@ import type { PlanningNotificationResult, PositionCreate, PositionRead, + PositionSheetsRequest, PositionUpdate, PublicFormConfig, PublicFormSubmission, @@ -170,6 +171,10 @@ export interface PlanningApi { getPublicConfig(editionId: string): Promise; submitPublic(editionId: string, body: PublicFormSubmission): Promise; }; + positionSheets: { + /** Feuilles de poste PDF (une page par poste et par jour). */ + generate(editionId: string, body: PositionSheetsRequest): Promise; + }; solver: { /** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */ run(editionId: string): Promise; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 8d9572d..6e89211 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -318,6 +318,8 @@ "grille.solver.run": "SUGGESTIONS (SOLVEUR)", "grille.solver.running": "CALCUL EN COURS…", "grille.solver.result": "{count} suggestion(s)", + "grille.sheets.generate": "FEUILLES DE POSTE", + "grille.sheets.generating": "GÉNÉRATION…", "grille.panel.suggested": "Suggéré", "config.artists.startTime": "Heure de début 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_too_large": "La photo est trop volumineuse.", "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." } diff --git a/frontend/src/pages/GrillePage.tsx b/frontend/src/pages/GrillePage.tsx index 4207be1..d93d4a9 100644 --- a/frontend/src/pages/GrillePage.tsx +++ b/frontend/src/pages/GrillePage.tsx @@ -1,6 +1,14 @@ 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 { Chip, HelpTip } from '@/components/ui'; import { EmptyNote, ErrorNote, Loading } from '@/components/state'; @@ -20,6 +28,7 @@ export function GrillePage() { const matrix = useMatrix(); const dayParts = useDayParts(); const runSolver = useRunSolver(); + const generateSheets = useGeneratePositionSheets(); const assignments = useAssignments(); const volunteers = useVolunteers(); const [dayFilter, setDayFilter] = useState<'all' | string>('all'); @@ -85,6 +94,22 @@ export function GrillePage() { .find((entry) => entry.shift.shift_id === selectedShiftId) ?? 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 (
@@ -135,6 +160,21 @@ export function GrillePage() { {t('error.generic')} ) : null} + + {generateSheets.isError ? ( + + {generateSheets.error instanceof ApiError + ? t(generateSheets.error.code) + : t('error.generic')} + + ) : null}