É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>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HomeRedirect, RequireAuth } from '@/auth/RequireAuth';
|
||||
import { AppShell } from './AppShell';
|
||||
import { AdminLoginPage } from '@/pages/auth/AdminLoginPage';
|
||||
import { ConnexionPage } from '@/pages/auth/ConnexionPage';
|
||||
import { BadgesPage } from '@/pages/BadgesPage';
|
||||
import { ConfigPage } from '@/pages/ConfigPage';
|
||||
import { GrillePage } from '@/pages/GrillePage';
|
||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||
@@ -26,6 +27,7 @@ export const routes: RouteObject[] = [
|
||||
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
||||
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
||||
{ path: 'configuration', element: <ConfigPage /> },
|
||||
{ path: 'badges', element: <BadgesPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ROUTE_ROLES: Record<string, Role[]> = {
|
||||
'/grille': ['COORDINATOR'],
|
||||
'/benevoles': ['COORDINATOR'],
|
||||
'/configuration': ['COORDINATOR'],
|
||||
'/badges': ['COORDINATOR'],
|
||||
'/mobile-benevole': ['VOLUNTEER'],
|
||||
'/mobile-referent': ['LEAD'],
|
||||
};
|
||||
@@ -19,6 +20,7 @@ export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
|
||||
{ to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' },
|
||||
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
||||
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
||||
{ to: '/badges', num: '07', labelKey: 'tab.badges' },
|
||||
];
|
||||
|
||||
export function canAccess(pathname: string, role: Role): boolean {
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"tab.mobileVolunteer": "Bénévole · mobile",
|
||||
"tab.mobileLead": "Référent · mobile",
|
||||
"tab.config": "Configuration",
|
||||
"tab.badges": "Badges",
|
||||
"position.category.bar": "Bar",
|
||||
"position.category.ticketing": "Billetterie",
|
||||
"position.category.welcome": "Accueil",
|
||||
@@ -320,5 +321,41 @@
|
||||
"config.artists.endTime": "Heure de fin du passage",
|
||||
"grille.panel.wishesPosition": "Vœu de poste",
|
||||
"grille.panel.artistConflict": "Manquerait un artiste souhaité",
|
||||
"grille.panel.withCompanion": "Avec {count} compagnon(s) souhaité(s)"
|
||||
"grille.panel.withCompanion": "Avec {count} compagnon(s) souhaité(s)",
|
||||
|
||||
"badges.admin": "PLANCHES DE BADGES",
|
||||
"badges.tab.customize": "PERSONNALISER",
|
||||
"badges.tab.generate": "GÉNÉRER",
|
||||
"badges.front": "Recto",
|
||||
"badges.front.help": "Fond personnalisable + champs positionnables (nom, poste, catégorie, rôle). Propre à cette édition.",
|
||||
"badges.back": "Verso",
|
||||
"badges.back.help": "Une seule image, la même pour tous les badges de cette édition.",
|
||||
"badges.fields": "Champs du recto",
|
||||
"badges.field.fullName": "Prénom + nom",
|
||||
"badges.field.fullName.placeholder": "Prénom Nom",
|
||||
"badges.field.positions": "Poste(s) affecté(s)",
|
||||
"badges.field.positions.placeholder": "Bar central, Accueil",
|
||||
"badges.field.category": "Catégorie",
|
||||
"badges.field.category.placeholder": "Bar",
|
||||
"badges.field.role": "Rôle (référent / bénévole)",
|
||||
"badges.field.role.placeholder": "Référent",
|
||||
"badges.field.size": "Taille",
|
||||
"badges.editor.hint": "Glissez un champ pour le déplacer, tirez son coin bas-droit pour le redimensionner.",
|
||||
"badges.image.choose": "Choisir une image",
|
||||
"badges.image.replace": "Remplacer l'image",
|
||||
"badges.uploading": "Envoi…",
|
||||
"badges.save": "Enregistrer",
|
||||
"badges.saving": "Enregistrement…",
|
||||
"badges.saved": "Enregistré ✓",
|
||||
"badges.generate.title": "Génération de la planche PDF",
|
||||
"badges.generate.all": "Tous les bénévoles ({count})",
|
||||
"badges.generate.pick": "Choisir ({count} sélectionné(s))",
|
||||
"badges.generate.action": "Générer le PDF",
|
||||
"badges.generating": "Génération…",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
"badge.empty_image": "L'image est vide.",
|
||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
|
||||
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
|
||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
useBadgeImageUrl,
|
||||
useBadgeTemplate,
|
||||
useGenerateBadges,
|
||||
useSetBadgeFields,
|
||||
useUploadBadgeBackImage,
|
||||
useUploadBadgeFrontImage,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
|
||||
import { ApiError } from '@/api';
|
||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||
import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
// Badge format CR80 (85,6 × 54 mm — cartes/badges d'événement standard), voir
|
||||
// `backend/app/badges/pdf.py` (même format, mêmes millimètres). `SCALE` fixe
|
||||
// la taille d'édition à l'écran ; les coordonnées enregistrées restent en mm.
|
||||
const BADGE_W_MM = 85.6;
|
||||
const BADGE_H_MM = 54;
|
||||
const SCALE = 4.6; // px par mm à l'écran
|
||||
|
||||
type FieldState = BadgeFieldIn & { enabled: boolean };
|
||||
|
||||
const FIELD_TYPES: BadgeFieldType[] = ['FULL_NAME', 'POSITIONS', 'CATEGORY', 'ROLE'];
|
||||
|
||||
const FIELD_LABEL_KEY: Record<BadgeFieldType, string> = {
|
||||
FULL_NAME: 'badges.field.fullName',
|
||||
POSITIONS: 'badges.field.positions',
|
||||
CATEGORY: 'badges.field.category',
|
||||
ROLE: 'badges.field.role',
|
||||
};
|
||||
|
||||
const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
|
||||
FULL_NAME: 'badges.field.fullName.placeholder',
|
||||
POSITIONS: 'badges.field.positions.placeholder',
|
||||
CATEGORY: 'badges.field.category.placeholder',
|
||||
ROLE: 'badges.field.role.placeholder',
|
||||
};
|
||||
|
||||
// Position/taille par défaut à l'activation d'un champ — empilés
|
||||
// verticalement pour ne pas se superposer, taille de police décroissante.
|
||||
const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = {
|
||||
FULL_NAME: {
|
||||
x_mm: 5, y_mm: 6, width_mm: 70, height_mm: 12,
|
||||
font_size: 13, align: 'LEFT', bold: true, color: '#000000',
|
||||
},
|
||||
POSITIONS: {
|
||||
x_mm: 5, y_mm: 20, width_mm: 70, height_mm: 10,
|
||||
font_size: 9, align: 'LEFT', bold: false, color: '#000000',
|
||||
},
|
||||
CATEGORY: {
|
||||
x_mm: 5, y_mm: 32, width_mm: 70, height_mm: 9,
|
||||
font_size: 9, align: 'LEFT', bold: false, color: '#000000',
|
||||
},
|
||||
ROLE: {
|
||||
x_mm: 5, y_mm: 43, width_mm: 70, height_mm: 9,
|
||||
font_size: 9, align: 'LEFT', bold: true, color: '#a1442b',
|
||||
},
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function fieldsFromServer(saved: BadgeFieldIn[]): Record<BadgeFieldType, FieldState> {
|
||||
const byType = new Map(saved.map((f) => [f.field_type, f]));
|
||||
const out = {} as Record<BadgeFieldType, FieldState>;
|
||||
for (const type of FIELD_TYPES) {
|
||||
const existing = byType.get(type);
|
||||
out[type] = existing
|
||||
? { ...existing, enabled: true }
|
||||
: { field_type: type, enabled: false, ...FIELD_DEFAULTS[type] };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function BadgesPage() {
|
||||
const [tab, setTab] = useState<'customize' | 'generate'>('customize');
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b-2 border-ink bg-sand px-4 py-[9px]">
|
||||
<span className="mr-1 font-mono text-[9px] opacity-60">{t('badges.admin')}</span>
|
||||
<Chip active={tab === 'customize'} onClick={() => setTab('customize')}>
|
||||
{t('badges.tab.customize')}
|
||||
</Chip>
|
||||
<Chip active={tab === 'generate'} onClick={() => setTab('generate')}>
|
||||
{t('badges.tab.generate')}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{tab === 'customize' ? <BadgeCustomizeTab /> : <BadgeGenerateTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Personnalisation //
|
||||
// --------------------------------------------------------------------------- //
|
||||
function BadgeCustomizeTab() {
|
||||
const template = useBadgeTemplate();
|
||||
const frontUrl = useBadgeImageUrl(
|
||||
'front', template.data?.has_front_image ?? false, template.dataUpdatedAt,
|
||||
);
|
||||
const backUrl = useBadgeImageUrl(
|
||||
'back', template.data?.has_back_image ?? false, template.dataUpdatedAt,
|
||||
);
|
||||
const uploadFront = useUploadBadgeFrontImage();
|
||||
const uploadBack = useUploadBadgeBackImage();
|
||||
const setFields = useSetBadgeFields();
|
||||
|
||||
const [fields, setFieldsState] = useState<Record<BadgeFieldType, FieldState> | null>(null);
|
||||
const [selected, setSelected] = useState<BadgeFieldType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
|
||||
}, [template.data]);
|
||||
|
||||
if (template.isLoading || !fields) return <Loading />;
|
||||
if (template.error) return <ErrorNote error={template.error} />;
|
||||
|
||||
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
|
||||
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
const payload: BadgeFieldIn[] = FIELD_TYPES.filter((type) => fields[type].enabled).map(
|
||||
(type) => {
|
||||
const f = fields[type];
|
||||
return {
|
||||
field_type: f.field_type,
|
||||
x_mm: f.x_mm,
|
||||
y_mm: f.y_mm,
|
||||
width_mm: f.width_mm,
|
||||
height_mm: f.height_mm,
|
||||
font_size: f.font_size,
|
||||
align: f.align,
|
||||
bold: f.bold,
|
||||
color: f.color,
|
||||
};
|
||||
},
|
||||
);
|
||||
setFields.mutate({ fields: payload });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4 lg:flex-row lg:items-start">
|
||||
<div className="flex flex-none flex-col gap-4">
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
{t('badges.front')}
|
||||
<HelpTip text={t('badges.front.help')} className="ml-[6px]" />
|
||||
</PanelHeader>
|
||||
<div className="p-3">
|
||||
<ImageUploadRow
|
||||
url={frontUrl}
|
||||
pending={uploadFront.isPending}
|
||||
error={uploadFront.error}
|
||||
onPick={(file) => uploadFront.mutate(file)}
|
||||
/>
|
||||
<div
|
||||
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
|
||||
style={{
|
||||
width: BADGE_W_MM * SCALE,
|
||||
height: BADGE_H_MM * SCALE,
|
||||
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
|
||||
}}
|
||||
onPointerDown={() => setSelected(null)}
|
||||
>
|
||||
{FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => (
|
||||
<FieldBox
|
||||
key={type}
|
||||
field={fields[type]}
|
||||
selected={selected === type}
|
||||
onSelect={() => setSelected(type)}
|
||||
onChange={(patch) => updateField(type, patch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60">
|
||||
{t('badges.editor.hint')}
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel>
|
||||
<PanelHeader>
|
||||
{t('badges.back')}
|
||||
<HelpTip text={t('badges.back.help')} className="ml-[6px]" />
|
||||
</PanelHeader>
|
||||
<div className="p-3">
|
||||
<ImageUploadRow
|
||||
url={backUrl}
|
||||
pending={uploadBack.isPending}
|
||||
error={uploadBack.error}
|
||||
onPick={(file) => uploadBack.mutate(file)}
|
||||
/>
|
||||
<div
|
||||
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
|
||||
style={{ width: BADGE_W_MM * SCALE, height: BADGE_H_MM * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel className="min-w-[320px] flex-1">
|
||||
<PanelHeader>{t('badges.fields')}</PanelHeader>
|
||||
<div className="flex flex-col divide-y-2 divide-ink/10">
|
||||
{FIELD_TYPES.map((type) => (
|
||||
<FieldEditorRow
|
||||
key={type}
|
||||
type={type}
|
||||
field={fields[type]}
|
||||
active={selected === type}
|
||||
onFocus={() => setSelected(type)}
|
||||
onChange={(patch) => updateField(type, patch)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 border-t-2 border-ink p-3">
|
||||
<Btn onClick={save} disabled={setFields.isPending}>
|
||||
{setFields.isPending ? t('badges.saving') : t('badges.save')}
|
||||
</Btn>
|
||||
{setFields.isSuccess ? (
|
||||
<span className="font-mono text-[9.5px] font-bold text-green">{t('badges.saved')}</span>
|
||||
) : null}
|
||||
{setFields.isError ? (
|
||||
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
|
||||
{errorMessage(setFields.error)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageUploadRow({
|
||||
url,
|
||||
pending,
|
||||
error,
|
||||
onPick,
|
||||
}: {
|
||||
url: string | null;
|
||||
pending: boolean;
|
||||
error: unknown;
|
||||
onPick: (file: File) => void;
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
return (
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<Btn variant="ghost" onClick={() => inputRef.current?.click()} disabled={pending}>
|
||||
{pending ? t('badges.uploading') : url ? t('badges.image.replace') : t('badges.image.choose')}
|
||||
</Btn>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onPick(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{error ? (
|
||||
<span className="font-mono text-[9.5px] font-bold text-orange-dark">{errorMessage(error)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldBox({
|
||||
field,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
}: {
|
||||
field: FieldState;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onChange: (patch: Partial<FieldState>) => void;
|
||||
}) {
|
||||
const dragRef = useRef<{ startXMm: number; startYMm: number; pointerXPx: number; pointerYPx: number } | null>(
|
||||
null,
|
||||
);
|
||||
const resizeRef = useRef<{ startWMm: number; startHMm: number; pointerXPx: number; pointerYPx: number } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const startDrag = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
dragRef.current = { startXMm: field.x_mm, startYMm: field.y_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
|
||||
};
|
||||
|
||||
const onDragMove = (e: React.PointerEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
const d = dragRef.current;
|
||||
const dxMm = (e.clientX - d.pointerXPx) / SCALE;
|
||||
const dyMm = (e.clientY - d.pointerYPx) / SCALE;
|
||||
onChange({
|
||||
x_mm: clamp(d.startXMm + dxMm, 0, BADGE_W_MM - field.width_mm),
|
||||
y_mm: clamp(d.startYMm + dyMm, 0, BADGE_H_MM - field.height_mm),
|
||||
});
|
||||
};
|
||||
|
||||
const startResize = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
resizeRef.current = { startWMm: field.width_mm, startHMm: field.height_mm, pointerXPx: e.clientX, pointerYPx: e.clientY };
|
||||
};
|
||||
|
||||
const onResizeMove = (e: React.PointerEvent) => {
|
||||
if (!resizeRef.current) return;
|
||||
const d = resizeRef.current;
|
||||
const dwMm = (e.clientX - d.pointerXPx) / SCALE;
|
||||
const dhMm = (e.clientY - d.pointerYPx) / SCALE;
|
||||
onChange({
|
||||
width_mm: clamp(d.startWMm + dwMm, 10, BADGE_W_MM - field.x_mm),
|
||||
height_mm: clamp(d.startHMm + dhMm, 5, BADGE_H_MM - field.y_mm),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={onDragMove}
|
||||
onPointerUp={() => (dragRef.current = null)}
|
||||
className={cx(
|
||||
'absolute flex cursor-move items-center overflow-hidden border-[1.5px] bg-white/55 px-1',
|
||||
selected ? 'border-blue-strong' : 'border-ink/40',
|
||||
)}
|
||||
style={{
|
||||
left: field.x_mm * SCALE,
|
||||
top: field.y_mm * SCALE,
|
||||
width: field.width_mm * SCALE,
|
||||
height: field.height_mm * SCALE,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none truncate font-sans"
|
||||
style={{
|
||||
fontSize: field.font_size * (SCALE / 2.83), // pt -> px approx. à l'échelle du badge
|
||||
fontWeight: field.bold ? 800 : 400,
|
||||
color: field.color,
|
||||
width: '100%',
|
||||
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
|
||||
}}
|
||||
>
|
||||
{t(FIELD_PLACEHOLDER_KEY[field.field_type])}
|
||||
</span>
|
||||
<div
|
||||
onPointerDown={startResize}
|
||||
onPointerMove={onResizeMove}
|
||||
onPointerUp={() => (resizeRef.current = null)}
|
||||
className="absolute bottom-0 right-0 h-[10px] w-[10px] cursor-nwse-resize border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldEditorRow({
|
||||
type,
|
||||
field,
|
||||
active,
|
||||
onFocus,
|
||||
onChange,
|
||||
}: {
|
||||
type: BadgeFieldType;
|
||||
field: FieldState;
|
||||
active: boolean;
|
||||
onFocus: () => void;
|
||||
onChange: (patch: Partial<FieldState>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={cx('flex flex-col gap-2 p-3', active && 'bg-yellow-soft')} onClick={onFocus}>
|
||||
<label className="flex items-center gap-[8px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.enabled}
|
||||
onChange={(e) => onChange({ enabled: e.target.checked })}
|
||||
className="h-[16px] w-[16px] accent-ink"
|
||||
/>
|
||||
<span className="font-display text-[12px] font-extrabold uppercase tracking-wide">
|
||||
{t(FIELD_LABEL_KEY[type])}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{field.enabled ? (
|
||||
<div className="ml-[24px] flex flex-wrap items-center gap-[10px]">
|
||||
<label className="flex items-center gap-[5px]">
|
||||
<FieldLabel>{t('badges.field.size')}</FieldLabel>
|
||||
<input
|
||||
type="number"
|
||||
min={6}
|
||||
max={36}
|
||||
value={field.font_size}
|
||||
onChange={(e) => onChange({ font_size: Number(e.target.value) })}
|
||||
className="w-[52px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-[4px]">
|
||||
{(['LEFT', 'CENTER', 'RIGHT'] as BadgeAlign[]).map((align) => (
|
||||
<button
|
||||
key={align}
|
||||
type="button"
|
||||
onClick={() => onChange({ align })}
|
||||
className={cx(
|
||||
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-mono text-[10px] font-bold',
|
||||
field.align === align ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
|
||||
)}
|
||||
>
|
||||
{align === 'LEFT' ? '⟵' : align === 'CENTER' ? '↔' : '⟶'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ bold: !field.bold })}
|
||||
className={cx(
|
||||
'h-[24px] w-[24px] cursor-pointer border-2 border-ink font-display text-[11px] font-black',
|
||||
field.bold ? 'bg-ink text-cream' : 'bg-cream hover:bg-yellow',
|
||||
)}
|
||||
>
|
||||
G
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="color"
|
||||
value={field.color}
|
||||
onChange={(e) => onChange({ color: e.target.value })}
|
||||
className="h-[24px] w-[30px] cursor-pointer border-2 border-ink p-0"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Génération //
|
||||
// --------------------------------------------------------------------------- //
|
||||
function BadgeGenerateTab() {
|
||||
const volunteers = useVolunteers();
|
||||
const generate = useGenerateBadges();
|
||||
const [mode, setMode] = useState<'all' | 'pick'>('all');
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...(volunteers.data ?? [])].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr')),
|
||||
[volunteers.data],
|
||||
);
|
||||
|
||||
if (volunteers.isLoading) return <Loading />;
|
||||
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
|
||||
if (sorted.length === 0) return <EmptyNote>{t('volunteers.noData')}</EmptyNote>;
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setPicked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
const volunteer_ids = mode === 'pick' ? [...picked] : undefined;
|
||||
try {
|
||||
const blob = await generate.mutateAsync({ volunteer_ids });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'badges.pdf';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// L'erreur est déjà exposée via `generate.isError` / `generate.error`.
|
||||
}
|
||||
};
|
||||
|
||||
const disabled = generate.isPending || (mode === 'pick' && picked.size === 0);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4">
|
||||
<Panel className="max-w-[560px]">
|
||||
<PanelHeader>{t('badges.generate.title')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex gap-2">
|
||||
<Chip active={mode === 'all'} onClick={() => setMode('all')}>
|
||||
{t('badges.generate.all', { count: sorted.length })}
|
||||
</Chip>
|
||||
<Chip active={mode === 'pick'} onClick={() => setMode('pick')}>
|
||||
{t('badges.generate.pick', { count: picked.size })}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{mode === 'pick' ? (
|
||||
<div className="max-h-[320px] overflow-auto border-2 border-ink">
|
||||
{sorted.map((v) => (
|
||||
<label
|
||||
key={v.id}
|
||||
className="flex cursor-pointer items-center gap-[8px] border-b border-ink/15 px-[10px] py-[7px] font-mono text-[11px] hover:bg-yellow-soft"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={picked.has(v.id)}
|
||||
onChange={() => toggle(v.id)}
|
||||
className="h-[14px] w-[14px] accent-ink"
|
||||
/>
|
||||
{v.first_name} {v.last_name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Btn onClick={download} disabled={disabled}>
|
||||
{generate.isPending ? t('badges.generating') : t('badges.generate.action')}
|
||||
</Btn>
|
||||
{generate.isError ? (
|
||||
<span className="font-mono text-[9.5px] font-bold text-orange-dark">
|
||||
{errorMessage(generate.error)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof ApiError) return t(error.code);
|
||||
return t('error.generic');
|
||||
}
|
||||
Reference in New Issue
Block a user