É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
@@ -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) =>
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -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 //
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
Reference in New Issue
Block a user