É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
+10
View File
@@ -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) =>
+11
View File
@@ -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' }),
+14
View File
@@ -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()),
},
+7
View File
@@ -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 //
// --------------------------------------------------------------------------- //
+5
View File
@@ -38,6 +38,7 @@ import type {
PlanningNotificationResult,
PositionCreate,
PositionRead,
PositionSheetsRequest,
PositionUpdate,
PublicFormConfig,
PublicFormSubmission,
@@ -170,6 +171,10 @@ export interface PlanningApi {
getPublicConfig(editionId: string): Promise<PublicFormConfig>;
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: {
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
run(editionId: string): Promise<SolverRunResult>;