Refonte du créateur de badges et nombre de badges par bénévole
ci-cd / build-backend (push) Successful in 30s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 22s

Créateur de badges refait d'après la maquette : champs sur le recto et le
verso (BadgeField.face, verso placé en miroir dans le PDF pour le
recto-verso), fond de zone, réduction automatique du texte, photo ovale,
initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en
trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles,
inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de
découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant
génération.

Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut,
migration 0027) : champ sur la fiche, autant d'exemplaires sur les
planches, total affiché à la génération, colonne dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 19:20:42 +01:00
co-authored by Claude Opus 5.5
parent 1a23881e81
commit ced0855d47
28 changed files with 3738 additions and 803 deletions
+59 -1
View File
@@ -461,6 +461,64 @@ function checkDayBoundary() {
console.log(`ok ${'dayBoundary'.padEnd(26)} ${cases.length} cas`);
}
// Éditeur de badges (onglet « Personnaliser ») : un champ par face, rendu
// avec les vraies données (nom du premier bénévole dans l'ordre
// d'impression), contrôle avant génération présent ; le mock refuse un même
// type de champ deux fois sur une face, mais l'accepte sur les deux.
async function checkBadgeEditor() {
if (!coordinator) throw new Error('badges : pas de session coordination pour ce test');
const base = {
x_mm: 4, y_mm: 4, width_mm: 40, height_mm: 12, font_size: 9, align: 'LEFT' as const,
bold: true, color: '#14110E', bg_color: null, auto_fit: true, photo_shape: 'RECT' as const,
};
let duplicate = 'non';
try {
await api.badges.setFields(e, {
fields: [
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
],
});
} catch (err) {
duplicate = err instanceof ApiError ? err.code : String(err);
}
if (duplicate !== 'badge.duplicate_field_type') throw new Error(`doublon accepté : ${duplicate}`);
await api.badges.setFields(e, {
fields: [
{ ...base, field_type: 'FULL_NAME', face: 'FRONT' },
{ ...base, field_type: 'POSITIONS', face: 'FRONT', y_mm: 18, height_mm: 30, bold: false },
{ ...base, field_type: 'FULL_NAME', face: 'BACK', bg_color: '#FFD23F' },
{ ...base, field_type: 'FREE_TEXT', face: 'BACK', y_mm: 30, text: 'STAFF' },
],
});
const vols = [...(await api.volunteers.list(e))].sort(
(a, b) =>
a.last_name.localeCompare(b.last_name, 'fr') || a.first_name.localeCompare(b.first_name, 'fr'),
);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await prefetchCore(client);
await Promise.all([
client.prefetchQuery({ queryKey: ['badgeTemplate', e], queryFn: () => api.badges.getTemplate(e) }),
client.prefetchQuery({ queryKey: ['dayParts', e], queryFn: () => api.dayParts.list(e) }),
client.prefetchQuery({ queryKey: ['accounts', e], queryFn: () => api.accounts.list(e) }),
]);
const html = renderToString(
createElement(
QueryClientProvider,
{ client },
createElement(
AuthContext.Provider,
{ value: authValue(coordinator) },
createElement(MemoryRouter, null, createElement(BadgesPage)),
),
),
);
const expected = [vols[0]?.last_name ?? '', 'STAFF', 'Contrôle des'];
const missing = expected.filter((text) => !html.includes(text));
if (missing.length) throw new Error(`badges : absent du rendu ${JSON.stringify(missing)}`);
console.log(`ok ${'/badges'.padEnd(26)} ${html.length} octets`);
}
// --------------------------------------------------------------------------- //
try {
checkDayBoundary();
@@ -484,7 +542,7 @@ if (coordinator) {
await renderProtected('/grille', coordinator, createElement(GrillePage));
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
await renderProtected('/badges', coordinator, createElement(BadgesPage));
await checkBadgeEditor().catch((err) => fail('checkBadgeEditor', err));
await renderProtected('/inscriptions', coordinator, createElement(FormBuilderPage));
await renderProtected('/statistiques', coordinator, createElement(StatsPage), async (client) => {
await client.prefetchQuery({ queryKey: ['stats', e], queryFn: () => api.stats.get(e) });