Badges : format dérivé de l'image importée, postes avec horaire et référent

- Le format du badge n'est plus fixé à CR80 : il est déduit des pixels de
  l'image du recto à l'import (résolution supposée 300 dpi,
  app/badges/pdf.py:image_size_mm), stocké sur BadgeTemplate (migration
  0012). L'éditeur (BadgesPage.tsx) et la génération PDF s'alignent sur ce
  format réel ; CR80 reste le repère par défaut tant qu'aucune image n'est
  importée. Le verso est ajusté sans déformation (preserveAspectRatio).

- Le champ « Postes affectés » liste désormais une ligne par créneau
  (triées chronologiquement) : nom du poste + horaire, et le référent du
  poste s'il y en a un et que ce n'est pas le bénévole badgé lui-même.
  Texte multi-lignes pris en charge par la génération PDF (découpe sur
  `\n`, hauteur de ligne calculée en mm) et par l'aperçu de l'éditeur.

- Corrige au passage un bug d'unités dans le positionnement vertical du
  texte (police en points confondue avec des millimètres).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-09-20 20:35:11 +02:00
co-authored by Claude Sonnet 5
parent b7fc80a7dc
commit 579ea5d9db
17 changed files with 534 additions and 87 deletions
+39 -23
View File
@@ -16,11 +16,12 @@ import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
// Badge format CR80 (85,6 × 54 mm — cartes/badges d'événement standard), voir
// `backend/app/badges/pdf.py` (même format, mêmes millimètres). `SCALE` fixe
// la taille d'édition à l'écran ; les coordonnées enregistrées restent en mm.
const BADGE_W_MM = 85.6;
const BADGE_H_MM = 54;
// 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 };
@@ -42,23 +43,26 @@ const FIELD_PLACEHOLDER_KEY: Record<BadgeFieldType, string> = {
};
// Position/taille par défaut à l'activation d'un champ — empilés
// verticalement pour ne pas se superposer, taille de police décroissante.
// 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: 6, width_mm: 70, height_mm: 12,
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: 20, width_mm: 70, height_mm: 10,
font_size: 9, align: 'LEFT', bold: false, color: '#000000',
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: 32, width_mm: 70, height_mm: 9,
font_size: 9, align: 'LEFT', bold: false, color: '#000000',
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: 43, width_mm: 70, height_mm: 9,
font_size: 9, align: 'LEFT', bold: true, color: '#a1442b',
x_mm: 5, y_mm: 47, width_mm: 70, height_mm: 6,
font_size: 8, align: 'LEFT', bold: true, color: '#a1442b',
},
};
@@ -123,6 +127,9 @@ function BadgeCustomizeTab() {
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));
};
@@ -165,8 +172,8 @@ function BadgeCustomizeTab() {
<div
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
style={{
width: BADGE_W_MM * SCALE,
height: BADGE_H_MM * SCALE,
width: badgeW * SCALE,
height: badgeH * SCALE,
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
}}
onPointerDown={() => setSelected(null)}
@@ -175,6 +182,8 @@ function BadgeCustomizeTab() {
<FieldBox
key={type}
field={fields[type]}
badgeW={badgeW}
badgeH={badgeH}
selected={selected === type}
onSelect={() => setSelected(type)}
onChange={(patch) => updateField(type, patch)}
@@ -182,7 +191,10 @@ function BadgeCustomizeTab() {
))}
</div>
<p className="mt-2 font-mono text-[9px] leading-relaxed opacity-60">
{t('badges.editor.hint')}
{t('badges.editor.hint', {
width: Math.round(badgeW * 10) / 10,
height: Math.round(badgeH * 10) / 10,
})}
</p>
</div>
</Panel>
@@ -201,7 +213,7 @@ function BadgeCustomizeTab() {
/>
<div
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
style={{ width: BADGE_W_MM * SCALE, height: BADGE_H_MM * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
style={{ width: badgeW * SCALE, height: badgeH * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
/>
</div>
</Panel>
@@ -276,11 +288,15 @@ function ImageUploadRow({
function FieldBox({
field,
badgeW,
badgeH,
selected,
onSelect,
onChange,
}: {
field: FieldState;
badgeW: number;
badgeH: number;
selected: boolean;
onSelect: () => void;
onChange: (patch: Partial<FieldState>) => void;
@@ -305,8 +321,8 @@ function FieldBox({
const dxMm = (e.clientX - d.pointerXPx) / SCALE;
const dyMm = (e.clientY - d.pointerYPx) / SCALE;
onChange({
x_mm: clamp(d.startXMm + dxMm, 0, BADGE_W_MM - field.width_mm),
y_mm: clamp(d.startYMm + dyMm, 0, BADGE_H_MM - field.height_mm),
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
});
};
@@ -323,8 +339,8 @@ function FieldBox({
const dwMm = (e.clientX - d.pointerXPx) / SCALE;
const dhMm = (e.clientY - d.pointerYPx) / SCALE;
onChange({
width_mm: clamp(d.startWMm + dwMm, 10, BADGE_W_MM - field.x_mm),
height_mm: clamp(d.startHMm + dhMm, 5, BADGE_H_MM - field.y_mm),
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
});
};
@@ -334,7 +350,7 @@ function FieldBox({
onPointerMove={onDragMove}
onPointerUp={() => (dragRef.current = null)}
className={cx(
'absolute flex cursor-move items-center overflow-hidden border-[1.5px] bg-white/55 px-1',
'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={{
@@ -345,7 +361,7 @@ function FieldBox({
}}
>
<span
className="pointer-events-none truncate font-sans"
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,