Files
benevoles/frontend/src/pages/BadgesPage.tsx
T
AdamThomasandClaude Opus 5.5 54cba467fc Badges : photo du bénévole et texte libre
Champ « Photo du bénévole » (PHOTO) : photo du formulaire recadrée au
cadre, orientation EXIF appliquée, réduite à 300 dpi ; cadre vide sans
photo. Champ « Texte libre » (FREE_TEXT) alimenté par le nouveau « Texte
du badge » de la fiche bénévole (Volunteer.badge_text, migration 0024),
repris dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:12:09 +01:00

688 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<BadgeFieldType, string> = {
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<Record<BadgeFieldType, string>> = {
PHOTO: 'badges.field.photo.help',
FREE_TEXT: 'badges.field.freeText.help',
};
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',
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<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',
},
// 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<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]);
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 <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-3 sm: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}
scale={scale}
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-0 flex-1 lg:min-w-[320px]">
<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,
scale,
selected,
onSelect,
onChange,
}: {
field: FieldState;
badgeW: number;
badgeH: number;
scale: 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 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' ? (
<span className="pointer-events-none flex h-full w-full items-center justify-center bg-sand-dark/70 font-mono text-[10px] font-bold uppercase text-ink/60">
{t(FIELD_PLACEHOLDER_KEY.PHOTO)}
</span>
) : (
<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-[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"
/>
</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}>
<div className="flex items-center">
<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_HELP_KEY[type] ? (
<HelpTip text={t(FIELD_HELP_KEY[type])} className="ml-[6px]" />
) : null}
</div>
{/* La photo n'a pas de style de texte : seuls sa place et son cadre
(sur l'aperçu) comptent. */}
{field.enabled && type !== 'PHOTO' ? (
<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-3 sm: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');
}