É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
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ArtistRead,
|
||||
AssignmentRead,
|
||||
AvailabilityRead,
|
||||
BadgeFieldRead,
|
||||
DayPartRead,
|
||||
EditionRead,
|
||||
PositionCategory,
|
||||
@@ -39,6 +40,13 @@ export interface MockState {
|
||||
accounts: AccountRead[];
|
||||
/** accountId -> mot de passe en clair (mock uniquement). */
|
||||
passwords: Map<string, string>;
|
||||
badgeTemplate: {
|
||||
frontImage: string | null; // data URL
|
||||
frontImageType: string | null;
|
||||
backImage: string | null; // data URL
|
||||
backImageType: string | null;
|
||||
fields: BadgeFieldRead[];
|
||||
};
|
||||
seq: number;
|
||||
}
|
||||
|
||||
@@ -412,6 +420,13 @@ export function buildMockState(): MockState {
|
||||
accounts,
|
||||
passwords,
|
||||
availability,
|
||||
badgeTemplate: {
|
||||
frontImage: null,
|
||||
frontImageType: null,
|
||||
backImage: null,
|
||||
backImageType: null,
|
||||
fields: [],
|
||||
},
|
||||
seq,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import type {
|
||||
AssignmentValidateResult,
|
||||
AvailabilityRead,
|
||||
AvailabilitySet,
|
||||
BadgeFieldRead,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
BadgeTemplateRead,
|
||||
DayPartCreate,
|
||||
DayPartRead,
|
||||
DayPartUpdate,
|
||||
@@ -82,6 +86,28 @@ const notFound = (): never => {
|
||||
throw new ApiError(404, 'error.not_found');
|
||||
};
|
||||
|
||||
const fileToDataUrl = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
// PDF valide minimal (une page blanche) — remplace la vraie génération
|
||||
// (réservée au back-end réel) pour que le téléchargement fonctionne en mode
|
||||
// démonstration.
|
||||
const MOCK_PDF_BLOB = () =>
|
||||
new Blob(
|
||||
[
|
||||
'%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n' +
|
||||
'2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n' +
|
||||
'3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 595 842]>>endobj\n' +
|
||||
'trailer<</Root 1 0 R>>\n%%EOF',
|
||||
],
|
||||
{ type: 'application/pdf' },
|
||||
);
|
||||
|
||||
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||
|
||||
const addDays = (day: string, n: number): string => {
|
||||
@@ -1301,6 +1327,69 @@ export function createMockApi(): PlanningApi {
|
||||
get: () => delay(buildMatrix()),
|
||||
},
|
||||
|
||||
badges: {
|
||||
getTemplate: (): Promise<BadgeTemplateRead> =>
|
||||
delay({
|
||||
edition_id: state.edition.id,
|
||||
has_front_image: state.badgeTemplate.frontImage !== null,
|
||||
has_back_image: state.badgeTemplate.backImage !== null,
|
||||
fields: state.badgeTemplate.fields,
|
||||
}),
|
||||
uploadFrontImage: async (_edition, file: File) => {
|
||||
state.badgeTemplate.frontImage = await fileToDataUrl(file);
|
||||
state.badgeTemplate.frontImageType = file.type;
|
||||
return delay<BadgeTemplateRead>({
|
||||
edition_id: state.edition.id,
|
||||
has_front_image: true,
|
||||
has_back_image: state.badgeTemplate.backImage !== null,
|
||||
fields: state.badgeTemplate.fields,
|
||||
});
|
||||
},
|
||||
uploadBackImage: async (_edition, file: File) => {
|
||||
state.badgeTemplate.backImage = await fileToDataUrl(file);
|
||||
state.badgeTemplate.backImageType = file.type;
|
||||
return delay<BadgeTemplateRead>({
|
||||
edition_id: state.edition.id,
|
||||
has_front_image: state.badgeTemplate.frontImage !== null,
|
||||
has_back_image: true,
|
||||
fields: state.badgeTemplate.fields,
|
||||
});
|
||||
},
|
||||
frontImageBlob: async () => {
|
||||
if (!state.badgeTemplate.frontImage) return null;
|
||||
return (await fetch(state.badgeTemplate.frontImage)).blob();
|
||||
},
|
||||
backImageBlob: async () => {
|
||||
if (!state.badgeTemplate.backImage) return null;
|
||||
return (await fetch(state.badgeTemplate.backImage)).blob();
|
||||
},
|
||||
setFields: (_edition, body: BadgeFieldsSet) => {
|
||||
const seen = new Set<string>();
|
||||
for (const f of body.fields) {
|
||||
if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type');
|
||||
seen.add(f.field_type);
|
||||
}
|
||||
const fields: BadgeFieldRead[] = body.fields.map((f) => ({
|
||||
...f,
|
||||
id: `bf${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
}));
|
||||
state.badgeTemplate.fields = fields;
|
||||
return delay(fields);
|
||||
},
|
||||
generate: (_edition, body: BadgeGenerateRequest) => {
|
||||
if (state.badgeTemplate.fields.length === 0) {
|
||||
throw new ApiError(422, 'badge.no_fields');
|
||||
}
|
||||
const ids = body.volunteer_ids ?? state.volunteers.map((v) => v.id);
|
||||
const matching = state.volunteers.filter((v) => ids.includes(v.id));
|
||||
if (matching.length === 0) throw new ApiError(422, 'badge.no_volunteers');
|
||||
// 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()),
|
||||
},
|
||||
|
||||
@@ -416,6 +416,44 @@ export interface MatrixResponse {
|
||||
positions: MatrixPosition[];
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Badges (planches PDF) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export type BadgeFieldType = 'FULL_NAME' | 'POSITIONS' | 'CATEGORY' | 'ROLE';
|
||||
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
|
||||
|
||||
export interface BadgeFieldIn {
|
||||
field_type: BadgeFieldType;
|
||||
x_mm: number;
|
||||
y_mm: number;
|
||||
width_mm: number;
|
||||
height_mm: number;
|
||||
font_size: number;
|
||||
align: BadgeAlign;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface BadgeFieldsSet {
|
||||
fields: BadgeFieldIn[];
|
||||
}
|
||||
|
||||
export interface BadgeFieldRead extends BadgeFieldIn {
|
||||
id: string;
|
||||
edition_id: string;
|
||||
}
|
||||
|
||||
export interface BadgeTemplateRead {
|
||||
edition_id: string;
|
||||
has_front_image: boolean;
|
||||
has_back_image: boolean;
|
||||
fields: BadgeFieldRead[];
|
||||
}
|
||||
|
||||
export interface BadgeGenerateRequest {
|
||||
volunteer_ids?: string[] | null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Authentification //
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
@@ -13,6 +13,10 @@ import type {
|
||||
AssignmentWriteResult,
|
||||
AvailabilityRead,
|
||||
AvailabilitySet,
|
||||
BadgeFieldRead,
|
||||
BadgeFieldsSet,
|
||||
BadgeGenerateRequest,
|
||||
BadgeTemplateRead,
|
||||
DayPartCreate,
|
||||
DayPartRead,
|
||||
DayPartUpdate,
|
||||
@@ -132,6 +136,19 @@ export interface PlanningApi {
|
||||
matrix: {
|
||||
get(editionId: string): Promise<MatrixResponse>;
|
||||
};
|
||||
badges: {
|
||||
/** Configuration du recto/verso (COORDINATION uniquement). */
|
||||
getTemplate(editionId: string): Promise<BadgeTemplateRead>;
|
||||
uploadFrontImage(editionId: string, file: File): Promise<BadgeTemplateRead>;
|
||||
uploadBackImage(editionId: string, file: File): Promise<BadgeTemplateRead>;
|
||||
/** Image brute (aperçu dans l'éditeur), `null` si non définie. */
|
||||
frontImageBlob(editionId: string): Promise<Blob | null>;
|
||||
backImageBlob(editionId: string): Promise<Blob | null>;
|
||||
/** Remplace l'intégralité des champs du recto. */
|
||||
setFields(editionId: string, body: BadgeFieldsSet): Promise<BadgeFieldRead[]>;
|
||||
/** Génère la planche PDF (recto personnalisé + verso fixe). */
|
||||
generate(editionId: string, body: BadgeGenerateRequest): Promise<Blob>;
|
||||
};
|
||||
solver: {
|
||||
/** Calcule des suggestions (CP-SAT) ; ne persiste jamais d'affectation. */
|
||||
run(editionId: string): Promise<SolverRunResult>;
|
||||
|
||||
Reference in New Issue
Block a user