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 = { FULL_NAME: 'badges.field.fullName', POSITIONS: 'badges.field.positions', CATEGORY: 'badges.field.category', ROLE: 'badges.field.role', }; const FIELD_PLACEHOLDER_KEY: Record = { 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> = { 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 { const byType = new Map(saved.map((f) => [f.field_type, f])); const out = {} as Record; 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 (
{t('badges.admin')} setTab('customize')}> {t('badges.tab.customize')} setTab('generate')}> {t('badges.tab.generate')}
{tab === 'customize' ? : }
); } // --------------------------------------------------------------------------- // // 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 | null>(null); const [selected, setSelected] = useState(null); useEffect(() => { if (template.data) setFieldsState(fieldsFromServer(template.data.fields)); }, [template.data]); if (template.isLoading || !fields) return ; if (template.error) return ; 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) => { 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 (
{t('badges.front')}
uploadFront.mutate(file)} /> setSize.mutate({ badge_width_mm: width_mm, badge_height_mm: height_mm }) } />
setSelected(null)} > {FIELD_TYPES.filter((type) => fields[type].enabled).map((type) => ( setSelected(type)} onChange={(patch) => updateField(type, patch)} /> ))}

{t('badges.editor.hint', { width: Math.round(badgeW * 10) / 10, height: Math.round(badgeH * 10) / 10, })}

{t('badges.back')}
uploadBack.mutate(file)} />
{t('badges.fields')}
{FIELD_TYPES.map((type) => ( setSelected(type)} onChange={(patch) => updateField(type, patch)} /> ))}
{setFields.isPending ? t('badges.saving') : t('badges.save')} {setFields.isSuccess ? ( {t('badges.saved')} ) : null} {setFields.isError ? ( {errorMessage(setFields.error)} ) : null}
); } function ImageUploadRow({ url, pending, error, onPick, }: { url: string | null; pending: boolean; error: unknown; onPick: (file: File) => void; }) { const inputRef = useRef(null); return (
inputRef.current?.click()} disabled={pending}> {pending ? t('badges.uploading') : url ? t('badges.image.replace') : t('badges.image.choose')} { const file = e.target.files?.[0]; if (file) onPick(file); e.target.value = ''; }} /> {error ? ( {errorMessage(error)} ) : null}
); } /** 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 (
{t('badges.size.width')} 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]" /> {t('badges.size.height', { height: Math.round(badgeH * 10) / 10 })}
); } function FieldBox({ field, badgeW, badgeH, selected, onSelect, onChange, }: { field: FieldState; badgeW: number; badgeH: number; selected: boolean; onSelect: () => void; onChange: (patch: Partial) => 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 (
(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, }} > 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])}
(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" />
); } function FieldEditorRow({ type, field, active, onFocus, onChange, }: { type: BadgeFieldType; field: FieldState; active: boolean; onFocus: () => void; onChange: (patch: Partial) => void; }) { return (
{field.enabled ? (
{(['LEFT', 'CENTER', 'RIGHT'] as BadgeAlign[]).map((align) => ( ))}
onChange({ color: e.target.value })} className="h-[24px] w-[30px] cursor-pointer border-2 border-ink p-0" />
) : null}
); } // --------------------------------------------------------------------------- // // Génération // // --------------------------------------------------------------------------- // function BadgeGenerateTab() { const volunteers = useVolunteers(); const generate = useGenerateBadges(); const [mode, setMode] = useState<'all' | 'pick'>('all'); const [picked, setPicked] = useState>(new Set()); const sorted = useMemo( () => [...(volunteers.data ?? [])].sort((a, b) => a.last_name.localeCompare(b.last_name, 'fr')), [volunteers.data], ); if (volunteers.isLoading) return ; if (volunteers.error) return ; if (sorted.length === 0) return {t('volunteers.noData')}; 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 (
{t('badges.generate.title')}
setMode('all')}> {t('badges.generate.all', { count: sorted.length })} setMode('pick')}> {t('badges.generate.pick', { count: picked.size })}
{mode === 'pick' ? (
{sorted.map((v) => ( ))}
) : null}
{generate.isPending ? t('badges.generating') : t('badges.generate.action')} {generate.isError ? ( {errorMessage(generate.error)} ) : null}
); } function errorMessage(error: unknown): string { if (error instanceof ApiError) return t(error.code); return t('error.generic'); }