É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:
co-authored by
Claude Sonnet 5
parent
4bcdafca96
commit
b7fc80a7dc
@@ -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' }),
|
||||
|
||||
Reference in New Issue
Block a user