Le format déduit de l'image importée (300 dpi supposés, étape
précédente) donne un badge minuscule pour une image à résolution web —
ajout d'un contrôle « largeur du badge (mm) » dans l'écran de
personnalisation : la hauteur suit proportionnellement (ratio de
l'image). PUT /editions/{id}/badges/template/size écrase la taille
déduite de l'image ; la réimporter la recalcule à nouveau.
Vérifié : pytest (250 passés), ruff, tsc --noEmit, eslint, vite build,
smoke — tous verts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
625 lines
21 KiB
TypeScript
625 lines
21 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react';
|
||
|
||
import {
|
||
useBadgeImageUrl,
|
||
useBadgeTemplate,
|
||
useGenerateBadges,
|
||
useSetBadgeFields,
|
||
useSetBadgeSize,
|
||
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';
|
||
|
||
// Le format du badge est dérivé de l'image de fond du recto (voir
|
||
// `backend/app/badges/pdf.py:image_size_mm`) — CR80 (85,6 × 54 mm) tant
|
||
// qu'aucune image n'est importée. `SCALE` fixe la taille d'édition à
|
||
// l'écran ; les coordonnées enregistrées restent en mm.
|
||
const DEFAULT_BADGE_W_MM = 85.6;
|
||
const DEFAULT_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 (repères pour un badge CR80 ;
|
||
// l'utilisateur ajuste ensuite à la souris pour son propre format). Postes
|
||
// affectés sur plusieurs lignes (un par créneau, avec horaire) : plus haut
|
||
// que les autres champs.
|
||
const FIELD_DEFAULTS: Record<BadgeFieldType, Omit<BadgeFieldIn, 'field_type'>> = {
|
||
FULL_NAME: {
|
||
x_mm: 5, y_mm: 4, width_mm: 70, height_mm: 10,
|
||
font_size: 13, align: 'LEFT', bold: true, color: '#000000',
|
||
},
|
||
POSITIONS: {
|
||
x_mm: 5, y_mm: 15, width_mm: 70, height_mm: 22,
|
||
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
|
||
},
|
||
CATEGORY: {
|
||
x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 7,
|
||
font_size: 8, align: 'LEFT', bold: false, color: '#000000',
|
||
},
|
||
ROLE: {
|
||
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
|
||
font_size: 8, 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 setSize = useSetBadgeSize();
|
||
|
||
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 badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
||
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
|
||
|
||
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)}
|
||
/>
|
||
<BadgeSizeControl
|
||
badgeW={badgeW}
|
||
badgeH={badgeH}
|
||
pending={setSize.isPending}
|
||
onApply={(width_mm, height_mm) =>
|
||
setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm })
|
||
}
|
||
/>
|
||
<div
|
||
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
|
||
style={{
|
||
width: badgeW * SCALE,
|
||
height: badgeH * SCALE,
|
||
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
|
||
}}
|
||
onPointerDown={() => setSelected(null)}
|
||
>
|
||
{FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => (
|
||
<FieldBox
|
||
key={type}
|
||
field={fields[type]}
|
||
badgeW={badgeW}
|
||
badgeH={badgeH}
|
||
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', {
|
||
width: Math.round(badgeW * 10) / 10,
|
||
height: Math.round(badgeH * 10) / 10,
|
||
})}
|
||
</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: badgeW * SCALE, height: badgeH * 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>
|
||
);
|
||
}
|
||
|
||
/** Largeur du badge en mm — la hauteur suit proportionnellement (ratio de
|
||
* l'image importée). Sert à agrandir un badge devenu minuscule parce que
|
||
* l'image de fond n'était pas à une résolution d'impression (300 dpi
|
||
* supposés à l'import, voir `useBadgeTemplate`). */
|
||
function BadgeSizeControl({
|
||
badgeW,
|
||
badgeH,
|
||
pending,
|
||
onApply,
|
||
}: {
|
||
badgeW: number;
|
||
badgeH: number;
|
||
pending: boolean;
|
||
onApply: (width_mm: number, height_mm: number) => void;
|
||
}) {
|
||
const rounded = Math.round(badgeW * 10) / 10;
|
||
const [width, setWidth] = useState(String(rounded));
|
||
|
||
useEffect(() => setWidth(String(rounded)), [rounded]);
|
||
|
||
const apply = () => {
|
||
const w = Number(width);
|
||
if (!Number.isFinite(w) || w <= 0) {
|
||
setWidth(String(rounded));
|
||
return;
|
||
}
|
||
const ratio = badgeH / badgeW;
|
||
onApply(w, Math.round(w * ratio * 10) / 10);
|
||
};
|
||
|
||
return (
|
||
<div className="mt-2 flex items-center gap-[8px]">
|
||
<FieldLabel>{t('badges.size.width')}</FieldLabel>
|
||
<input
|
||
type="number"
|
||
min={5}
|
||
step={1}
|
||
value={width}
|
||
disabled={pending}
|
||
onChange={(e) => setWidth(e.target.value)}
|
||
onBlur={apply}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') apply();
|
||
}}
|
||
className="w-[64px] border-2 border-ink bg-cream px-[6px] py-[3px] font-mono text-[11px]"
|
||
/>
|
||
<span className="font-mono text-[9.5px] opacity-60">
|
||
{t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FieldBox({
|
||
field,
|
||
badgeW,
|
||
badgeH,
|
||
selected,
|
||
onSelect,
|
||
onChange,
|
||
}: {
|
||
field: FieldState;
|
||
badgeW: number;
|
||
badgeH: number;
|
||
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, badgeW - field.width_mm),
|
||
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - 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, badgeW - field.x_mm),
|
||
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div
|
||
onPointerDown={startDrag}
|
||
onPointerMove={onDragMove}
|
||
onPointerUp={() => (dragRef.current = null)}
|
||
className={cx(
|
||
'absolute flex cursor-move items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]',
|
||
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 whitespace-pre-line font-sans leading-tight"
|
||
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');
|
||
}
|