É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:
co-authored by
Claude Sonnet 5
parent
111d1e5c4b
commit
40127a2252
@@ -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 +
|
||||||
|
|||||||
@@ -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"'},
|
||||||
|
)
|
||||||
@@ -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 #
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from app.sheets.pdf import (
|
||||||
|
PositionDaySheet,
|
||||||
|
SheetShift,
|
||||||
|
SheetVolunteer,
|
||||||
|
generate_position_sheets_pdf,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PositionDaySheet",
|
||||||
|
"SheetShift",
|
||||||
|
"SheetVolunteer",
|
||||||
|
"generate_position_sheets_pdf",
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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()),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 //
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
Reference in New Issue
Block a user