É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
+41 -1
View File
@@ -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 (
<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]">
@@ -135,6 +160,21 @@ export function GrillePage() {
{t('error.generic')}
</span>
) : 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 className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">