É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
+65
View File
@@ -7,6 +7,8 @@ import type {
AssignmentUpdate,
AssignmentValidateRequest,
AvailabilitySet,
BadgeFieldsSet,
BadgeGenerateRequest,
DayPartCreate,
DayPartUpdate,
EditionUpdate,
@@ -66,6 +68,35 @@ export function createHttpApi(baseUrl: string): PlanningApi {
return body as T;
}
// Réponses binaires (images du badge, PDF généré) : pas de JSON, on ne
// veut que les octets (ou `null` pour une image absente, en 404).
async function requestBlob(path: string, init?: RequestInit): Promise<Blob | null> {
const token = getAuthToken();
const isForm = init?.body instanceof FormData;
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
...(isForm ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init?.headers,
},
credentials: 'include',
});
if (res.status === 404) return null;
if (!res.ok) {
let code = 'error.generic';
try {
const body = (await res.json()) as { detail?: { code?: string } };
code = body.detail?.code ?? code;
} catch {
// Corps non JSON (ex. erreur réseau) : garde le code générique.
}
if (res.status === 401) notifyUnauthorized();
throw new ApiError(res.status, code, []);
}
return res.blob();
}
const query = (params: Record<string, string | undefined>) => {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
@@ -191,6 +222,40 @@ export function createHttpApi(baseUrl: string): PlanningApi {
matrix: {
get: (editionId) => request(`${base(editionId)}/matrix`),
},
badges: {
getTemplate: (editionId) => request(`${base(editionId)}/badges/template`),
uploadFrontImage: (editionId, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/badges/template/front-image`, {
method: 'PUT',
body: form,
});
},
uploadBackImage: (editionId, file: File) => {
const form = new FormData();
form.append('file', file);
return request(`${base(editionId)}/badges/template/back-image`, {
method: 'PUT',
body: form,
});
},
frontImageBlob: (editionId) => requestBlob(`${base(editionId)}/badges/template/front-image`),
backImageBlob: (editionId) => requestBlob(`${base(editionId)}/badges/template/back-image`),
setFields: (editionId, body: BadgeFieldsSet) =>
request(`${base(editionId)}/badges/template/fields`, {
method: 'PUT',
body: JSON.stringify(body),
}),
generate: async (editionId, body: BadgeGenerateRequest) => {
const blob = await requestBlob(`${base(editionId)}/badges/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' }),