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 (au plus) // Marges horizontales autour de l'aperçu (page + panneau + bordures), pour // le faire tenir dans un écran étroit. const PREVIEW_GUTTER_PX = 90; /** Échelle d'édition : `SCALE`, réduite sur un écran trop étroit pour un * badge à pleine taille (téléphone, tablette en portrait). Les coordonnées * enregistrées restent en mm, seule la conversion px ↔ mm suit l'échelle. */ function useEditorScale(badgeWidthMm: number): number { const [viewport, setViewport] = useState(() => typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth, ); useEffect(() => { const onResize = () => setViewport(window.innerWidth); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); return Math.min(SCALE, Math.max(1, (viewport - PREVIEW_GUTTER_PX) / badgeWidthMm)); } type FieldState = BadgeFieldIn & { enabled: boolean }; const FIELD_TYPES: BadgeFieldType[] = [ 'FULL_NAME', 'POSITIONS', 'CATEGORY', 'ROLE', 'PHOTO', 'FREE_TEXT', ]; const FIELD_LABEL_KEY: Record = { FULL_NAME: 'badges.field.fullName', POSITIONS: 'badges.field.positions', CATEGORY: 'badges.field.category', ROLE: 'badges.field.role', PHOTO: 'badges.field.photo', FREE_TEXT: 'badges.field.freeText', }; // Indication affichée à côté du nom du champ, pour ceux dont l'origine du // contenu n'est pas évidente. const FIELD_HELP_KEY: Partial> = { PHOTO: 'badges.field.photo.help', FREE_TEXT: 'badges.field.freeText.help', }; 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', PHOTO: 'badges.field.photo.placeholder', FREE_TEXT: 'badges.field.freeText.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', }, // Format portrait d'identité (≈ 3,5 × 4,5 cm réduit), en haut à droite. // Taille de police/couleur sans effet : la photo est une image. PHOTO: { x_mm: 58, y_mm: 4, width_mm: 22, height_mm: 28, font_size: 8, align: 'CENTER', bold: false, color: '#000000', }, FREE_TEXT: { x_mm: 5, y_mm: 39, width_mm: 50, height_mm: 7, font_size: 9, align: 'LEFT', bold: true, color: '#000000', }, }; 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]); const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM; const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM; const scale = useEditorScale(badgeW); if (template.isLoading || !fields) return ; if (template.error) return ; 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, scale, selected, onSelect, onChange, }: { field: FieldState; badgeW: number; badgeH: number; scale: 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 touch-none 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, }} > {field.field_type === 'PHOTO' ? ( {t(FIELD_PLACEHOLDER_KEY.PHOTO)} ) : ( 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-[14px] w-[14px] cursor-nwse-resize touch-none lg:h-[10px] lg:w-[10px] 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_HELP_KEY[type] ? ( ) : null}
{/* La photo n'a pas de style de texte : seuls sa place et son cadre (sur l'aperçu) comptent. */} {field.enabled && type !== 'PHOTO' ? (
{(['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'); }