Refonte du créateur de badges et nombre de badges par bénévole
ci-cd / build-backend (push) Successful in 30s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 22s

Créateur de badges refait d'après la maquette : champs sur le recto et le
verso (BadgeField.face, verso placé en miroir dans le PDF pour le
recto-verso), fond de zone, réduction automatique du texte, photo ovale,
initiales sans photo, nom sur deux lignes (migration 0026) ; éditeur en
trois colonnes (champs, aperçu recto/verso avec de vrais bénévoles,
inspecteur), formats prédéfinis, annuler, flèches, zoom, repères de
découpe, dépôt d'image sur l'aperçu, contrôle de tous les badges avant
génération.

Nombre de badges par bénévole (Volunteer.badge_count, 1 par défaut,
migration 0027) : champ sur la fiche, autant d'exemplaires sur les
planches, total affiché à la génération, colonne dans l'export Excel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-27 19:20:42 +01:00
co-authored by Claude Opus 5.5
parent 1a23881e81
commit ced0855d47
28 changed files with 3738 additions and 803 deletions
+460
View File
@@ -0,0 +1,460 @@
import { useRef, useState } from 'react';
import type { BadgeFace, VolunteerRead } from '@/api/schema';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
import {
CELL_PAD_X_MM,
CELL_PAD_Y_MM,
FIRST_BASELINE,
LINE_HEIGHT,
MM_PER_PT,
type TableLayout,
type TextLayout,
} from './layout';
import {
FACE_LABEL_KEY,
fmt,
initials,
isVerticallyCentered,
renderField,
SAFE_MARGIN_MM,
type BadgeData,
type FieldState,
} from './model';
/** Fond d'une photo absente (initiales à la place), comme le PDF. */
export const PHOTO_PLACEHOLDER_BG = '#E7E0CE';
/** Pas d'accroche du glisser-déposer (mm). */
const DRAG_SNAP_MM = 0.5;
/** Plus petit cadre possible au redimensionnement (mm). */
const MIN_W_MM = 3;
const MIN_H_MM = 2;
/** Décalage entre la ligne de base d'une ligne CSS (interligne 1,25, police
* Helvetica/Arial) et celle du PDF (0,85 × taille sous le haut du cadre). */
const CSS_BASELINE = 0.97;
const FONT_STACK = 'Helvetica, Arial, sans-serif';
function snap(value: number): number {
return Math.round(value / DRAG_SNAP_MM) * DRAG_SNAP_MM;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export interface BadgeCanvasProps {
face: BadgeFace;
fields: FieldState[];
data: BadgeData;
volunteer: VolunteerRead | null;
photoUrl: string | null;
imageUrl: string | null;
badgeW: number;
badgeH: number;
scale: number;
showGuides: boolean;
selectedType: string | null;
uploading: boolean;
onSelect: (field: FieldState) => void;
/** `record` : enregistrer l'état actuel dans l'historique d'annulation
* avant d'appliquer `patch` (une seule fois par glisser-déposer). */
onChange: (field: FieldState, patch: Partial<FieldState>, record: boolean) => void;
onDropImage: (file: File) => void;
}
/** Aperçu d'une face du badge, éditable (glisser, redimensionner) et zone de
* dépôt de son image de fond. */
export function BadgeCanvas({
face,
fields,
data,
volunteer,
photoUrl,
imageUrl,
badgeW,
badgeH,
scale,
showGuides,
selectedType,
uploading,
onSelect,
onChange,
onDropImage,
}: BadgeCanvasProps) {
const [dropping, setDropping] = useState(false);
const shown = fields.filter((f) => f.enabled);
const isFaceSelected = shown.some((f) => f.field_type === selectedType);
return (
<div className="flex min-w-0 flex-col gap-[9px]">
<div className="flex items-baseline gap-2">
<span
className={cx(
'border-2 border-ink px-2 py-1 font-display text-[11px] font-black uppercase tracking-wider',
isFaceSelected ? 'bg-ink text-yellow' : 'bg-paper text-ink',
)}
>
{t(FACE_LABEL_KEY[face])}
</span>
<span className="font-mono text-[10px] opacity-70">
{t('badges.dims', { width: fmt(badgeW), height: fmt(badgeH) })}
</span>
</div>
<div
className={cx(
'relative flex-none select-none border-[3px] border-ink bg-sand-dark bg-contain bg-center bg-no-repeat shadow-brutal-lg',
dropping && 'outline outline-[3px] outline-offset-2 outline-blue-strong',
)}
// + 6 : bordure de 3 px de chaque côté (les champs se placent dans
// la boîte intérieure, à l'échelle exacte du badge).
style={{
width: badgeW * scale + 6,
height: badgeH * scale + 6,
backgroundImage: imageUrl ? `url(${imageUrl})` : undefined,
}}
onDragOver={(e) => {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
setDropping(true);
}}
onDragLeave={() => setDropping(false)}
onDrop={(e) => {
e.preventDefault();
setDropping(false);
const file = e.dataTransfer.files[0];
if (file) onDropImage(file);
}}
>
{/* Invitation à déposer une image : seulement sur une face encore
vide (elle passerait sous les champs sinon), ou par-dessus tout
pendant un dépôt / un envoi. */}
{uploading || dropping || (!imageUrl && shown.length === 0) ? (
<div
className={cx(
'pointer-events-none absolute inset-0 flex items-center justify-center p-3 text-center font-mono text-[10px] font-medium leading-relaxed',
uploading || dropping ? 'z-20 bg-blue/80 text-ink' : 'text-ink/60',
)}
>
{uploading
? t('badges.uploading')
: t(face === 'FRONT' ? 'badges.drop.front' : 'badges.drop.back')}
</div>
) : null}
{showGuides ? (
<div
className="pointer-events-none absolute border-[1.5px] border-dashed border-yellow opacity-90"
style={{ inset: SAFE_MARGIN_MM * scale }}
/>
) : null}
{shown.map((field) => (
<FieldBox
key={field.field_type}
field={field}
data={data}
volunteer={volunteer}
photoUrl={photoUrl}
badgeW={badgeW}
badgeH={badgeH}
scale={scale}
showGuides={showGuides}
selected={selectedType === field.field_type}
onSelect={() => onSelect(field)}
onChange={(patch, record) => onChange(field, patch, record)}
/>
))}
</div>
</div>
);
}
function FieldBox({
field,
data,
volunteer,
photoUrl,
badgeW,
badgeH,
scale,
showGuides,
selected,
onSelect,
onChange,
}: {
field: FieldState;
data: BadgeData;
volunteer: VolunteerRead | null;
photoUrl: string | null;
badgeW: number;
badgeH: number;
scale: number;
showGuides: boolean;
selected: boolean;
onSelect: () => void;
onChange: (patch: Partial<FieldState>, record: boolean) => void;
}) {
const dragRef = useRef<{
mode: 'move' | 'resize';
x: number;
y: number;
w: number;
h: number;
px: number;
py: number;
moved: boolean;
} | null>(null);
const start = (mode: 'move' | 'resize') => (e: React.PointerEvent) => {
e.stopPropagation();
e.preventDefault();
onSelect();
(e.currentTarget as Element).setPointerCapture(e.pointerId);
dragRef.current = {
mode,
x: field.x_mm,
y: field.y_mm,
w: field.width_mm,
h: field.height_mm,
px: e.clientX,
py: e.clientY,
moved: false,
};
};
const move = (e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
e.stopPropagation();
const dx = (e.clientX - d.px) / scale;
const dy = (e.clientY - d.py) / scale;
if (!d.moved && Math.abs(dx) < 0.2 && Math.abs(dy) < 0.2) return;
const record = !d.moved;
d.moved = true;
if (d.mode === 'move') {
// Bornes jamais négatives : un champ plus grand que le badge reste
// saisissable (voir `fitToBadge`).
onChange(
{
x_mm: clamp(snap(d.x + dx), 0, Math.max(0, badgeW - d.w)),
y_mm: clamp(snap(d.y + dy), 0, Math.max(0, badgeH - d.h)),
},
record,
);
} else {
const maxW = Math.max(0, badgeW - d.x);
const maxH = Math.max(0, badgeH - d.y);
onChange(
{
width_mm: clamp(snap(d.w + dx), Math.min(MIN_W_MM, maxW), maxW),
height_mm: clamp(snap(d.h + dy), Math.min(MIN_H_MM, maxH), maxH),
},
record,
);
}
};
const end = (e: React.PointerEvent) => {
if (dragRef.current) e.stopPropagation();
dragRef.current = null;
};
const rendered = volunteer ? renderField(field, volunteer, data) : null;
const layout = rendered && rendered.kind !== 'photo' ? rendered.layout : null;
const truncated = layout?.truncated ?? false;
return (
<div
onPointerDown={start('move')}
onPointerMove={move}
onPointerUp={end}
onPointerCancel={end}
className="absolute cursor-move touch-none"
style={{
left: field.x_mm * scale,
top: field.y_mm * scale,
width: field.width_mm * scale,
height: field.height_mm * scale,
outline: selected
? '2px solid #2A6BFF'
: truncated
? '2px dashed #FF5C39'
: showGuides
? '1px dashed rgba(20,17,14,.45)'
: 'none',
// Le champ sélectionné passe devant les autres : sinon un champ
// recouvert (ex. la photo sous un texte) ne se saisit plus.
zIndex: selected ? 10 : undefined,
}}
>
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
) : rendered?.kind === 'table' ? (
<TableContent field={field} layout={rendered.layout} scale={scale} />
) : rendered?.kind === 'text' ? (
<TextContent field={field} layout={rendered.layout} scale={scale} />
) : null}
{truncated ? (
<span className="pointer-events-none absolute -left-[3px] -top-[19px] whitespace-nowrap border-2 border-ink bg-orange px-1 py-[2px] font-display text-[9px] font-extrabold leading-none">
{t('badges.preview.truncated')}
</span>
) : null}
{selected ? (
<>
<span className="pointer-events-none absolute -bottom-[19px] left-0 z-10 whitespace-nowrap bg-blue-strong px-[5px] py-[2px] font-mono text-[9px] font-semibold leading-tight text-white">
{t('badges.preview.dims', { width: fmt(field.width_mm), height: fmt(field.height_mm) })}
{layout?.reduced
? t('badges.preview.reducedTo', { size: fmt(layout.fontSize) })
: null}
</span>
<span
onPointerDown={start('resize')}
onPointerMove={move}
onPointerUp={end}
onPointerCancel={end}
className="absolute -bottom-[7px] -right-[7px] h-[15px] w-[15px] cursor-nwse-resize touch-none border-2 border-ink bg-yellow lg:h-[13px] lg:w-[13px]"
/>
</>
) : null}
</div>
);
}
function TextContent({ field, layout, scale }: { field: FieldState; layout: TextLayout; scale: number }) {
const fontPx = layout.fontSize * MM_PER_PT * scale;
const lineHeightPx = fontPx * LINE_HEIGHT;
// Bandeaux (texte libre, rôle) centrés verticalement, les autres en haut —
// même calcul que le PDF : bloc = interlignes + une hauteur de caractère.
const blockPx = Math.max(0, layout.visible.length - 1) * lineHeightPx + fontPx;
const boxPx = field.height_mm * scale;
const offset = isVerticallyCentered(field.field_type) ? Math.max(0, (boxPx - blockPx) / 2) : 0;
const top = offset + (FIRST_BASELINE - CSS_BASELINE) * fontPx;
return (
<div
className="pointer-events-none relative h-full w-full overflow-hidden"
style={{ background: field.bg_color ?? 'transparent' }}
>
<div
className="absolute inset-x-0"
style={{
top,
color: field.color,
fontFamily: FONT_STACK,
fontSize: fontPx,
lineHeight: `${lineHeightPx}px`,
fontWeight: field.bold ? 700 : 400,
textAlign: field.align === 'CENTER' ? 'center' : field.align === 'RIGHT' ? 'right' : 'left',
}}
>
{layout.visible.map((line, i) => (
<div key={i} className="whitespace-pre">
{line || ' '}
</div>
))}
</div>
</div>
);
}
/** Tableau des créneaux, mêmes colonnes, hauteurs de ligne et lignes
* retenues que le PDF (`_draw_table`). */
function TableContent({ field, layout, scale }: { field: FieldState; layout: TableLayout; scale: number }) {
const fontPx = layout.fontSize * MM_PER_PT * scale;
const lineHeightPx = fontPx * LINE_HEIGHT;
const rule = `${Math.max(0.5, 0.4 * MM_PER_PT * scale)}px solid ${field.color}`;
const padX = CELL_PAD_X_MM * scale;
const padY = CELL_PAD_Y_MM * scale;
const fullWidth = layout.widthsMm.reduce((a, b) => a + b, 0) * scale;
return (
<div
className="pointer-events-none h-full w-full overflow-hidden"
style={{ background: field.bg_color ?? 'transparent' }}
>
{layout.rows.length ? (
<div
style={{
width: fullWidth,
border: rule,
color: field.color,
fontFamily: FONT_STACK,
fontSize: fontPx,
lineHeight: `${lineHeightPx}px`,
boxSizing: 'border-box',
}}
>
{layout.rows.map((row, i) => (
<div
key={i}
className="flex"
style={{
height: row.heightMm * scale,
borderTop: i > 0 ? rule : undefined,
fontWeight: row.isTitle ? 700 : 400,
}}
>
{row.cells.map((lines, col) => (
<div
key={col}
className="flex-none overflow-hidden"
style={{
width: row.isTitle ? '100%' : layout.widthsMm[col] * scale,
padding: `${padY}px ${padX}px`,
borderLeft: col > 0 ? rule : undefined,
boxSizing: 'border-box',
}}
>
{lines.map((line, j) => (
<div key={j} className="whitespace-pre">
{line || ' '}
</div>
))}
</div>
))}
</div>
))}
</div>
) : null}
</div>
);
}
function PhotoContent({
field,
volunteer,
photoUrl,
scale,
}: {
field: FieldState;
volunteer: VolunteerRead | null;
photoUrl: string | null;
scale: number;
}) {
const radius = field.photo_shape === 'OVAL' ? '50%' : 0;
if (volunteer?.has_photo && photoUrl) {
return (
<img
src={photoUrl}
alt=""
draggable={false}
className="pointer-events-none h-full w-full object-cover"
style={{ borderRadius: radius }}
/>
);
}
return (
<div
className="pointer-events-none flex h-full w-full items-center justify-center font-bold"
style={{
borderRadius: radius,
background: PHOTO_PLACEHOLDER_BG,
color: field.color,
fontFamily: FONT_STACK,
// Comme le PDF : 0,28 × la largeur du cadre, 4 pt au moins.
fontSize: Math.max(4 * MM_PER_PT, field.width_mm * 0.28) * scale,
}}
>
{volunteer ? initials(volunteer) : null}
</div>
);
}