Étape 20 : planches de badges bénévoles (PDF)

Recto personnalisable (fond image + champs positionnés : nom, poste(s)
affectés, catégorie, rôle) via un éditeur visuel glisser-déposer ; verso
fixe, la même image pour tous les bénévoles. Nouvel écran « Badges »
(COORDINATION uniquement, /badges).

Backend : modèle BadgeTemplate/BadgeField (migration 0011), génération
PDF isolée (app/badges/pdf.py, ReportLab — planches recto suivies des
planches verso correspondantes pour une impression recto-verso), route
POST /editions/{id}/badges/generate, tests (module pur + API).

Frontend : client HTTP (upload multipart, réponses binaires images/PDF),
client mock (mode démonstration), éditeur de positionnement en
millimètres (coordonnées partagées avec le backend), écran de génération
avec sélection des bénévoles et téléchargement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-20 20:05:03 +02:00
co-authored by Claude Sonnet 5
parent 4bcdafca96
commit b7fc80a7dc
25 changed files with 1800 additions and 3 deletions
+83
View File
@@ -1,3 +1,5 @@
import { useEffect, useState } from 'react';
import {
useMutation,
useQuery,
@@ -15,6 +17,8 @@ import type {
AssignmentUpdate,
AssignmentValidateResult,
AvailabilitySet,
BadgeFieldsSet,
BadgeGenerateRequest,
DayPartCreate,
DayPartUpdate,
EditionUpdate,
@@ -131,6 +135,47 @@ export function useMatrix() {
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
}
/** Configuration du recto/verso des badges (images, champs). */
export function useBadgeTemplate() {
return useQuery({
queryKey: ['badgeTemplate', edition],
queryFn: () => api.badges.getTemplate(edition),
});
}
/**
* URL objet (aperçu) de l'image recto/verso, ou `null` si non définie.
* `refreshKey` (ex. `dataUpdatedAt` de `useBadgeTemplate`) force un nouveau
* fetch après un remplacement d'image — un simple `hasImage` ne changerait
* pas de valeur entre deux images successives.
*/
export function useBadgeImageUrl(
kind: 'front' | 'back',
hasImage: boolean,
refreshKey: number | string,
) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string | null = null;
let cancelled = false;
if (!hasImage) {
setUrl(null);
return;
}
const fetchBlob = kind === 'front' ? api.badges.frontImageBlob : api.badges.backImageBlob;
fetchBlob(edition).then((blob) => {
if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [kind, hasImage, refreshKey]);
return url;
}
/**
* Dernières suggestions du solveur (lecture seule du cache posé par
* `useRunSolver`). Ne déclenche jamais de calcul elle-même.
@@ -459,3 +504,41 @@ export function useSendPlanningNotification() {
api.notifications.sendPlanning(edition, body),
});
}
function useInvalidateBadgeTemplate() {
const client = useQueryClient();
return () => client.invalidateQueries({ queryKey: ['badgeTemplate', edition] });
}
export function useUploadBadgeFrontImage() {
const invalidate = useInvalidateBadgeTemplate();
return useMutation({
mutationFn: (file: File) => api.badges.uploadFrontImage(edition, file),
onSuccess: invalidate,
});
}
export function useUploadBadgeBackImage() {
const invalidate = useInvalidateBadgeTemplate();
return useMutation({
mutationFn: (file: File) => api.badges.uploadBackImage(edition, file),
onSuccess: invalidate,
});
}
/** Remplace l'intégralité des champs du recto (positions, activation). */
export function useSetBadgeFields() {
const invalidate = useInvalidateBadgeTemplate();
return useMutation({
mutationFn: (body: BadgeFieldsSet) => api.badges.setFields(edition, body),
onSuccess: invalidate,
});
}
/** Génère la planche PDF ; ne fait que retourner le fichier (le composant
* appelant déclenche le téléchargement). */
export function useGenerateBadges() {
return useMutation({
mutationFn: (body: BadgeGenerateRequest) => api.badges.generate(edition, body),
});
}