Refonte du créateur de badges et nombre de badges par bénévole
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:
co-authored by
Claude Opus 5.5
parent
1a23881e81
commit
ced0855d47
@@ -290,6 +290,7 @@ export function buildMockState(): MockState {
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
|
||||
@@ -235,6 +235,7 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
|
||||
has_photo: pending.photo !== null,
|
||||
companion_wish_note: pending.companionWishNote,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: pending.available,
|
||||
willing_to_pay_fee: true,
|
||||
fee_payment_label: pending.paymentLabel,
|
||||
@@ -997,6 +998,7 @@ export function createMockApi(): PlanningApi {
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -1042,6 +1044,14 @@ export function createMockApi(): PlanningApi {
|
||||
? { companion_wish_note: body.companion_wish_note ?? null }
|
||||
: {}),
|
||||
...('badge_text' in body ? { badge_text: body.badge_text ?? null } : {}),
|
||||
...(body.badge_count != null
|
||||
? (() => {
|
||||
if (!Number.isInteger(body.badge_count) || body.badge_count < 1 || body.badge_count > 100) {
|
||||
throw new ApiError(422, 'error.generic');
|
||||
}
|
||||
return { badge_count: body.badge_count };
|
||||
})()
|
||||
: {}),
|
||||
...('self_declared_available' in body && body.self_declared_available != null
|
||||
? { self_declared_available: body.self_declared_available }
|
||||
: {}),
|
||||
@@ -1321,6 +1331,7 @@ export function createMockApi(): PlanningApi {
|
||||
has_photo: false,
|
||||
companion_wish_note: null,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: false,
|
||||
willing_to_pay_fee: false,
|
||||
fee_payment_label: null,
|
||||
@@ -1577,13 +1588,24 @@ export function createMockApi(): PlanningApi {
|
||||
return (await fetch(state.badgeTemplate.backImage)).blob();
|
||||
},
|
||||
setFields: (_edition, body: BadgeFieldsSet) => {
|
||||
// Un même type de champ peut exister une fois par face (recto/verso),
|
||||
// comme côté back-end.
|
||||
const seen = new Set<string>();
|
||||
const hexColor = /^#[0-9a-fA-F]{6}$/;
|
||||
for (const f of body.fields) {
|
||||
if (seen.has(f.field_type)) throw new ApiError(422, 'badge.duplicate_field_type');
|
||||
seen.add(f.field_type);
|
||||
const key = `${f.face ?? 'FRONT'}:${f.field_type}`;
|
||||
if (seen.has(key)) throw new ApiError(422, 'badge.duplicate_field_type');
|
||||
seen.add(key);
|
||||
if (!hexColor.test(f.color) || (f.bg_color != null && !hexColor.test(f.bg_color))) {
|
||||
throw new ApiError(422, 'badge.invalid_color');
|
||||
}
|
||||
}
|
||||
const fields: BadgeFieldRead[] = body.fields.map((f) => ({
|
||||
...f,
|
||||
face: f.face ?? 'FRONT',
|
||||
bg_color: f.bg_color ?? null,
|
||||
auto_fit: f.auto_fit ?? true,
|
||||
photo_shape: f.photo_shape ?? 'RECT',
|
||||
id: `bf${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
}));
|
||||
@@ -1792,6 +1814,7 @@ export function createMockApi(): PlanningApi {
|
||||
has_photo: photo !== null,
|
||||
companion_wish_note: companionWishNote,
|
||||
badge_text: null,
|
||||
badge_count: 1,
|
||||
self_declared_available: fields.has('AVAILABLE') ? available : false,
|
||||
willing_to_pay_fee: fields.has('WILLING_TO_PAY_FEE'),
|
||||
fee_payment_label: null,
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface VolunteerRead {
|
||||
/** Texte libre imprimé sur le badge (champ `FREE_TEXT`), saisi par la
|
||||
* coordination. */
|
||||
badge_text: string | null;
|
||||
/** Exemplaires de son badge imprimés sur les planches (1 par défaut). */
|
||||
badge_count: number;
|
||||
self_declared_available: boolean;
|
||||
willing_to_pay_fee: boolean;
|
||||
/** Paiement de la cotisation via SumUp (voir schema forms) —
|
||||
@@ -161,6 +163,8 @@ export interface VolunteerUpdate {
|
||||
wished_companion_ids?: string[] | null;
|
||||
companion_wish_note?: string | null;
|
||||
badge_text?: string | null;
|
||||
/** 1 à 100. */
|
||||
badge_count?: number | null;
|
||||
self_declared_available?: boolean | null;
|
||||
willing_to_pay_fee?: boolean | null;
|
||||
}
|
||||
@@ -433,9 +437,16 @@ export type BadgeFieldType =
|
||||
| 'PHOTO'
|
||||
| 'FREE_TEXT';
|
||||
export type BadgeAlign = 'LEFT' | 'CENTER' | 'RIGHT';
|
||||
/** Face du badge où se trouve un champ : chaque type de champ peut exister
|
||||
* une fois par face. */
|
||||
export type BadgeFace = 'FRONT' | 'BACK';
|
||||
/** Forme du cadre de la photo (`PHOTO`). */
|
||||
export type BadgePhotoShape = 'RECT' | 'OVAL';
|
||||
|
||||
export interface BadgeFieldIn {
|
||||
field_type: BadgeFieldType;
|
||||
/** Défaut `FRONT`. */
|
||||
face: BadgeFace;
|
||||
x_mm: number;
|
||||
y_mm: number;
|
||||
width_mm: number;
|
||||
@@ -444,6 +455,13 @@ export interface BadgeFieldIn {
|
||||
align: BadgeAlign;
|
||||
bold: boolean;
|
||||
color: string;
|
||||
/** Fond de la zone du champ (`#rrggbb`), `null` = transparent. */
|
||||
bg_color: string | null;
|
||||
/** Réduit la taille du texte (par pas de 0,5 pt, jusqu'à 4 pt) quand il
|
||||
* ne tient pas dans le cadre. */
|
||||
auto_fit: boolean;
|
||||
/** `PHOTO` seulement : rectangle ou ovale inscrit dans le cadre. */
|
||||
photo_shape: BadgePhotoShape;
|
||||
/** `FREE_TEXT` seulement : texte commun à tous les badges, remplacé pour
|
||||
* un bénévole par le « Texte du badge » de sa fiche (`badge_text`). */
|
||||
text?: string | null;
|
||||
|
||||
@@ -231,6 +231,8 @@
|
||||
"config.vol.field.phone": "Téléphone",
|
||||
"config.vol.field.comment": "Commentaire",
|
||||
"config.vol.field.badgeText": "Texte du badge",
|
||||
"config.vol.field.badgeCount": "Nombre de badges",
|
||||
"config.vol.field.badgeCount.help": "Nombre d'exemplaires de son badge imprimés sur les planches (1 à 100). 1 par défaut.",
|
||||
"config.vol.field.badgeText.help": "Remplace, pour ce bénévole seulement, le texte libre commun saisi dans l'écran Badges. Vide = texte commun.",
|
||||
"config.vol.photo.title": "Photo",
|
||||
"config.vol.photo.hint": "Imprimée sur le badge (champ « Photo du bénévole ») et affichée sur les écrans bénévole et référent. Celle envoyée au formulaire d'inscription, ou ajoutée ici. PNG ou JPEG.",
|
||||
@@ -384,13 +386,121 @@
|
||||
"badges.saved": "Enregistré ✓",
|
||||
"badges.generate.title": "Génération de la planche PDF",
|
||||
"badges.generate.all": "Tous les bénévoles ({count})",
|
||||
"badges.generate.total": "{count} badge(s) à imprimer",
|
||||
"badges.generate.pick": "Choisir ({count} sélectionné(s))",
|
||||
"badges.generate.action": "Générer le PDF",
|
||||
"badges.generating": "Génération…",
|
||||
"badges.face.front": "Recto",
|
||||
"badges.face.back": "Verso",
|
||||
"badges.face.front.short": "recto",
|
||||
"badges.face.back.short": "verso",
|
||||
"badges.dims": "{width} × {height} mm",
|
||||
"badges.drop.front": "Déposez ici le visuel du recto (PNG ou JPEG)",
|
||||
"badges.drop.back": "Déposez ici le visuel du verso (PNG ou JPEG)",
|
||||
"badges.format.title": "Format du badge",
|
||||
"badges.format.current": "Format actuel",
|
||||
"badges.preset.cr80Portrait": "Carte CR80 portrait",
|
||||
"badges.preset.cr80Landscape": "Carte CR80 paysage",
|
||||
"badges.preset.a7": "A7 tour de cou",
|
||||
"badges.fieldsOf": "Champs du {face}",
|
||||
"badges.fieldsShown": "{count} affiché(s)",
|
||||
"badges.layer.toggle": "Afficher / masquer",
|
||||
"badges.layer.hidden": "masqué",
|
||||
"badges.layer.meta": "{size} pt · {weight}",
|
||||
"badges.layer.bold": "gras",
|
||||
"badges.layer.regular": "normal",
|
||||
"badges.layer.rect": "rectangle",
|
||||
"badges.layer.oval": "ovale",
|
||||
"badges.layer.issueCount": "▲ {count}",
|
||||
"badges.layer.issueTruncated.one": "{count} badge avec texte coupé",
|
||||
"badges.layer.issueTruncated.other": "{count} badges avec texte coupé",
|
||||
"badges.layer.issueReduced.one": "{count} badge réduit automatiquement",
|
||||
"badges.layer.issueReduced.other": "{count} badges réduits automatiquement",
|
||||
"badges.keys.hint": "Clic sur un champ pour le régler. Flèches : déplacer de 0,5 mm. Maj + flèches : 2 mm. Échap : désélectionner. Ctrl/Cmd + Z : annuler.",
|
||||
"badges.undo": "↶ Annuler",
|
||||
"badges.undo.title": "Ctrl/Cmd + Z",
|
||||
"badges.dirty": "Modifications non enregistrées",
|
||||
"badges.clean": "✓ Enregistré",
|
||||
"badges.savedAt": "✓ Enregistré à {time}",
|
||||
"badges.sample.firstName": "Prénom",
|
||||
"badges.sample.lastName": "Nom",
|
||||
"badges.preview.with": "Aperçu avec",
|
||||
"badges.preview.prev": "Bénévole précédent",
|
||||
"badges.preview.next": "Bénévole suivant",
|
||||
"badges.preview.counter": "Badge {index} / {total} · {role}",
|
||||
"badges.preview.noVolunteer": "Aucun bénévole : exemple fictif",
|
||||
"badges.preview.longest": "Tester le nom le plus long",
|
||||
"badges.preview.guides": "Repères",
|
||||
"badges.preview.guides.help": "Affiche la marge de découpe (1,5 mm) et le cadre de chaque champ",
|
||||
"badges.preview.zoomIn": "Agrandir l'aperçu",
|
||||
"badges.preview.zoomOut": "Réduire l'aperçu",
|
||||
"badges.preview.zoom": "{percent} %",
|
||||
"badges.preview.truncated": "▲ TEXTE COUPÉ",
|
||||
"badges.preview.dims": "{width} × {height} mm",
|
||||
"badges.preview.reducedTo": " · réduit à {size} pt",
|
||||
"badges.pt": "{size} pt",
|
||||
"badges.color.white": "Blanc",
|
||||
"badges.color.black": "Noir",
|
||||
"badges.color.yellow": "Jaune",
|
||||
"badges.color.coral": "Corail",
|
||||
"badges.color.none": "Aucun",
|
||||
"badges.color.custom": "Autre",
|
||||
"badges.align.left": "Gauche",
|
||||
"badges.align.center": "Centre",
|
||||
"badges.align.right": "Droite",
|
||||
"badges.inspector.kicker": "{face} · champ sélectionné",
|
||||
"badges.inspector.content": "Contenu",
|
||||
"badges.inspector.contentHelp": "Identique sur tous les badges — sauf pour un bénévole dont la fiche porte un « Texte du badge », qui le remplace.",
|
||||
"badges.inspector.fontSize": "Taille du texte",
|
||||
"badges.inspector.reducedHere": "Ici réduit à {size} pt",
|
||||
"badges.inspector.capHeight": "Hauteur ≈ {mm} mm",
|
||||
"badges.inspector.smaller": "Diminuer la taille",
|
||||
"badges.inspector.larger": "Augmenter la taille",
|
||||
"badges.inspector.autoFit": "Réduire automatiquement si le texte est trop long",
|
||||
"badges.inspector.align": "Alignement",
|
||||
"badges.inspector.weight": "Graisse",
|
||||
"badges.inspector.regular": "Normal",
|
||||
"badges.inspector.bold": "Gras",
|
||||
"badges.inspector.textColor": "Couleur du texte",
|
||||
"badges.inspector.background": "Fond de la zone",
|
||||
"badges.inspector.lowContrast": "⚠ Contraste faible : le texte risque d'être illisible à l'impression.",
|
||||
"badges.inspector.photoShape": "Forme de la photo",
|
||||
"badges.inspector.photoRect": "▢ Rectangle",
|
||||
"badges.inspector.photoOval": "◯ Ovale",
|
||||
"badges.inspector.photoStat": "{count} photo(s) reçue(s) sur {total}. Sans photo : initiales sur fond beige.",
|
||||
"badges.inspector.geometry": "Position et taille (mm)",
|
||||
"badges.inspector.x": "X",
|
||||
"badges.inspector.y": "Y",
|
||||
"badges.inspector.w": "L",
|
||||
"badges.inspector.h": "H",
|
||||
"badges.inspector.center": "↔ Centrer",
|
||||
"badges.inspector.fullWidth": "⇤⇥ Pleine largeur",
|
||||
"badges.inspector.hide": "Masquer ce champ",
|
||||
"badges.inspector.empty.title": "Sélectionnez un champ sur le badge ou dans la liste de gauche.",
|
||||
"badges.inspector.empty.hint": "Faites-le glisser pour le déplacer, tirez le carré jaune pour le redimensionner. Ses réglages apparaissent ici.",
|
||||
"badges.inspector.empty.drop": "Pour changer le visuel de fond, déposez une image directement sur le recto ou le verso.",
|
||||
"badges.check.title": "Contrôle des {count} badges",
|
||||
"badges.check.subtitle": "Avant génération des planches",
|
||||
"badges.check.critical": "⛔ À corriger",
|
||||
"badges.check.warning": "⚠ À vérifier",
|
||||
"badges.check.fixAutoFit": "Activer la réduction auto",
|
||||
"badges.check.showExample": "Voir un exemple",
|
||||
"badges.check.ok": "Les {count} badges s'affichent sans texte coupé ni champ hors zone de découpe.",
|
||||
"badges.check.estimate": "Estimation faite dans le navigateur : le PDF généré reste la référence.",
|
||||
"badges.issue.where": "{field} ({face})",
|
||||
"badges.issue.truncated.one": "{where} : texte coupé sur {count} badge.",
|
||||
"badges.issue.truncated.other": "{where} : texte coupé sur {count} badges.",
|
||||
"badges.issue.reduced.one": "{where} : réduit automatiquement sur {count} badge, jusqu'à {size} pt.",
|
||||
"badges.issue.reduced.other": "{where} : réduit automatiquement sur {count} badges, jusqu'à {size} pt.",
|
||||
"badges.issue.margin": "{where} : touche la marge de découpe (1,5 mm), risque d'être rogné.",
|
||||
"badges.issue.contrast": "{where} : texte et fond de zone trop proches, illisible.",
|
||||
"badges.issue.noPhoto.one": "{count} bénévole n'a pas de photo : ses initiales seront imprimées à la place.",
|
||||
"badges.issue.noPhoto.other": "{count} bénévoles n'ont pas de photo : leurs initiales seront imprimées à la place.",
|
||||
"badge.image_type_not_supported": "L'image doit être au format PNG ou JPEG.",
|
||||
"badge.empty_image": "L'image est vide.",
|
||||
"badge.image_too_large": "L'image est trop volumineuse.",
|
||||
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois.",
|
||||
"badge.duplicate_field_type": "Ce champ ne peut apparaître qu'une fois par face.",
|
||||
"badge.invalid_color": "Couleur invalide (format attendu : #rrggbb).",
|
||||
"badge.invalid_size": "La taille du badge doit être strictement positive.",
|
||||
"badge.no_fields": "Activez au moins un champ sur le recto avant de générer.",
|
||||
"badge.no_volunteers": "Aucun bénévole ne correspond à cette sélection.",
|
||||
|
||||
@@ -1,677 +1,43 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
useBadgeImageUrl,
|
||||
useBadgeTemplate,
|
||||
useGenerateBadges,
|
||||
useSetBadgeFields,
|
||||
useSetBadgeSize,
|
||||
useUploadBadgeBackImage,
|
||||
useUploadBadgeFrontImage,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
import type { BadgeAlign, BadgeFieldIn, BadgeFieldType } from '@/api/schema';
|
||||
import { useGenerateBadges, useVolunteers } from '@/api/hooks';
|
||||
import { ApiError } from '@/api';
|
||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||
import { Btn, Chip, FieldLabel, HelpTip, Panel, PanelHeader } from '@/components/ui';
|
||||
import { Btn, Chip, 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;
|
||||
import { BadgeCustomizeTab } from './badges/BadgeCustomizeTab';
|
||||
|
||||
/** É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);
|
||||
}
|
||||
|
||||
/** Ramène un champ à l'intérieur du badge (réduit s'il est plus grand,
|
||||
* décalé s'il dépasse). Les positions par défaut sont pensées pour un CR80
|
||||
* paysage (85,6 × 54 mm) : sur un badge plus étroit (portrait…), un champ
|
||||
* activé pouvait tomber hors du badge — invisible, et les bornes négatives
|
||||
* du glisser-déposer le bloquaient (bug corrigé une fois : photo
|
||||
* impossible à déplacer ni redimensionner). */
|
||||
function fitToBadge<F extends BadgeFieldIn>(field: F, badgeW: number, badgeH: number): F {
|
||||
const width_mm = Math.min(field.width_mm, badgeW);
|
||||
const height_mm = Math.min(field.height_mm, badgeH);
|
||||
return {
|
||||
...field,
|
||||
width_mm,
|
||||
height_mm,
|
||||
x_mm: clamp(field.x_mm, 0, badgeW - width_mm),
|
||||
y_mm: clamp(field.y_mm, 0, badgeH - height_mm),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// L'onglet « Personnaliser » (éditeur recto/verso, aperçu avec les vraies
|
||||
// données, contrôle avant génération) vit dans `./badges/`.
|
||||
|
||||
export function BadgesPage() {
|
||||
const [tab, setTab] = useState<'customize' | 'generate'>('customize');
|
||||
|
||||
const tabBar = (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
||||
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
|
||||
|
||||
useEffect(() => {
|
||||
if (!template.data) return;
|
||||
const loaded = fieldsFromServer(template.data.fields);
|
||||
for (const type of FIELD_TYPES) loaded[type] = fitToBadge(loaded[type], badgeW, badgeH);
|
||||
setFieldsState(loaded);
|
||||
}, [template.data, badgeW, badgeH]);
|
||||
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]: fitToBadge({ ...prev[type], ...patch }, badgeW, badgeH) }
|
||||
: prev,
|
||||
);
|
||||
// Un champ qu'on vient d'activer passe au premier plan, sélectionné.
|
||||
if (patch.enabled) setSelected(type);
|
||||
};
|
||||
|
||||
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,
|
||||
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
|
||||
};
|
||||
},
|
||||
);
|
||||
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, Math.max(0, badgeW - field.width_mm)),
|
||||
y_mm: clamp(d.startYMm + dyMm, 0, Math.max(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;
|
||||
const maxW = badgeW - field.x_mm;
|
||||
const maxH = badgeH - field.y_mm;
|
||||
onChange({
|
||||
width_mm: clamp(d.startWMm + dwMm, Math.min(10, maxW), maxW),
|
||||
height_mm: clamp(d.startHMm + dhMm, Math.min(5, maxH), maxH),
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
// 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,
|
||||
}}
|
||||
>
|
||||
{field.field_type === 'POSITIONS' ? (
|
||||
<PositionsTablePreview field={field} scale={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>
|
||||
{tab === 'customize' ? (
|
||||
<BadgeCustomizeTab tabBar={tabBar} />
|
||||
) : (
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
{field.field_type === 'FREE_TEXT' && field.text?.trim()
|
||||
? field.text
|
||||
: 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aperçu du champ « postes » : le jour en en-tête, puis une ligne
|
||||
* horaire / poste par créneau, comme dans le PDF
|
||||
* (`backend/app/badges/pdf.py:_draw_table`), avec des lignes d'exemple. */
|
||||
function PositionsTablePreview({ field, scale }: { field: FieldState; scale: number }) {
|
||||
const fontSize = field.font_size * (scale / 2.83); // pt -> px approx. à l'échelle du badge
|
||||
const sections = [1, 2].map((n) => ({
|
||||
title: t(`badges.field.positions.sample${n}.day`),
|
||||
rows: t(`badges.field.positions.sample${n}.rows`)
|
||||
.split('\n')
|
||||
.map((row) => row.split('|')),
|
||||
}));
|
||||
const cell = 'border px-[0.8mm] py-[0.5mm] text-left align-top';
|
||||
const border = { borderColor: field.color };
|
||||
return (
|
||||
<table
|
||||
className="pointer-events-none w-full border-collapse font-sans leading-tight"
|
||||
style={{ fontSize, color: field.color }}
|
||||
>
|
||||
{sections.map((section) => (
|
||||
<tbody key={section.title}>
|
||||
<tr>
|
||||
<th colSpan={2} className={cell} style={border}>
|
||||
{section.title}
|
||||
</th>
|
||||
</tr>
|
||||
{section.rows.map(([time, position]) => (
|
||||
<tr key={time}>
|
||||
<td className={cx(cell, 'w-px whitespace-nowrap')} style={border}>
|
||||
{time}
|
||||
</td>
|
||||
<td className={cell} style={border}>
|
||||
{position}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
))}
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{field.enabled && type === 'FREE_TEXT' ? (
|
||||
<input
|
||||
type="text"
|
||||
value={field.text ?? ''}
|
||||
onChange={(e) => onChange({ text: e.target.value })}
|
||||
placeholder={t('badges.field.freeText.input')}
|
||||
className="ml-[24px] border-2 border-ink bg-cream px-[8px] py-[5px] font-display text-[12px] font-bold"
|
||||
/>
|
||||
) : 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' ? (
|
||||
<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 className="flex flex-wrap items-center gap-2 border-b-[3px] border-ink bg-sand px-3 py-2 sm:px-4">
|
||||
{tabBar}
|
||||
</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}
|
||||
<BadgeGenerateTab />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -734,6 +100,18 @@ function BadgeGenerateTab() {
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
{/* Un bénévole peut demander plusieurs exemplaires (fiche,
|
||||
« Nombre de badges ») : le total imprimé peut dépasser le
|
||||
nombre de bénévoles. */}
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
{t('badges.generate.total', {
|
||||
count: (mode === 'pick' ? sorted.filter((v) => picked.has(v.id)) : sorted).reduce(
|
||||
(sum, v) => sum + v.badge_count,
|
||||
0,
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
|
||||
{mode === 'pick' ? (
|
||||
<div className="max-h-[320px] overflow-auto border-2 border-ink">
|
||||
{sorted.map((v) => (
|
||||
@@ -748,6 +126,9 @@ function BadgeGenerateTab() {
|
||||
className="h-[14px] w-[14px] accent-ink"
|
||||
/>
|
||||
{v.first_name} {v.last_name}
|
||||
{v.badge_count > 1 ? (
|
||||
<span className="ml-auto opacity-60">× {v.badge_count}</span>
|
||||
) : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
import type { BadgeAlign } from '@/api/schema';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
import { contrastRatio, FONT_STEP_PT, MAX_FONT_PT, MIN_CONTRAST, MIN_FONT_PT, MM_PER_PT } from './layout';
|
||||
import { FACE_LABEL_KEY, FIELD_LABEL_KEY, fitToBadge, fmt, round1, type FieldState } from './model';
|
||||
|
||||
const TEXT_COLORS: Array<{ hex: string; key: string }> = [
|
||||
{ hex: '#FFFFFF', key: 'badges.color.white' },
|
||||
{ hex: '#14110E', key: 'badges.color.black' },
|
||||
{ hex: '#FFD23F', key: 'badges.color.yellow' },
|
||||
{ hex: '#FF5C39', key: 'badges.color.coral' },
|
||||
];
|
||||
|
||||
const BG_COLORS: Array<{ hex: string | null; key: string }> = [
|
||||
{ hex: null, key: 'badges.color.none' },
|
||||
{ hex: '#FFFFFF', key: 'badges.color.white' },
|
||||
{ hex: '#14110E', key: 'badges.color.black' },
|
||||
{ hex: '#FFD23F', key: 'badges.color.yellow' },
|
||||
];
|
||||
|
||||
const ALIGNS: Array<{ value: BadgeAlign; key: string; items: string }> = [
|
||||
{ value: 'LEFT', key: 'badges.align.left', items: 'items-start' },
|
||||
{ value: 'CENTER', key: 'badges.align.center', items: 'items-center' },
|
||||
{ value: 'RIGHT', key: 'badges.align.right', items: 'items-end' },
|
||||
];
|
||||
|
||||
function sameColor(a: string | null, b: string | null): boolean {
|
||||
return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
|
||||
}
|
||||
|
||||
function Section({ title, aside, children }: { title: string; aside?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-[6px] flex items-baseline">
|
||||
<span className="font-display text-[9.5px] font-extrabold uppercase tracking-widest">{title}</span>
|
||||
{aside ? <span className="ml-auto font-mono text-[9px] uppercase opacity-60">{aside}</span> : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Segment({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cx(
|
||||
'cursor-pointer border-0 transition-colors',
|
||||
active ? 'bg-ink text-yellow' : 'bg-cream text-ink hover:bg-yellow-soft',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Swatch({
|
||||
hex,
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
hex: string | null;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className="flex cursor-pointer flex-col items-center gap-1 border-0 bg-transparent p-0"
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'flex h-[30px] w-full items-center justify-center border-2 border-ink font-display text-[12px] font-black',
|
||||
hex === null && 'bg-hatch-sand',
|
||||
hex === '#14110E' ? 'text-yellow' : 'text-ink',
|
||||
)}
|
||||
style={{
|
||||
background: hex ?? undefined,
|
||||
boxShadow: active ? '0 0 0 3px #2A6BFF' : undefined,
|
||||
}}
|
||||
>
|
||||
{active ? '✓' : ''}
|
||||
</span>
|
||||
<span className="font-display text-[9.5px] font-semibold">{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Saisie d'une coordonnée en mm, appliquée à la validation (sortie du
|
||||
* champ ou Entrée) pour ne pas bousculer la saisie en cours. */
|
||||
function MmInput({ label, value, onCommit }: { label: string; value: number; onCommit: (v: number) => void }) {
|
||||
const [text, setText] = useState(fmt(value));
|
||||
useEffect(() => setText(fmt(value)), [value]);
|
||||
const commit = () => {
|
||||
const n = Number(text.replace(',', '.'));
|
||||
if (Number.isFinite(n) && round1(n) !== round1(value)) onCommit(Math.max(0, n));
|
||||
else setText(fmt(value));
|
||||
};
|
||||
return (
|
||||
<label className="flex items-stretch border-2 border-ink bg-cream">
|
||||
<span className="flex w-[30px] flex-none items-center justify-center border-r-2 border-ink bg-sand font-display text-[11px] font-extrabold">
|
||||
{label}
|
||||
</span>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commit();
|
||||
}}
|
||||
className="min-w-0 flex-1 border-0 bg-transparent px-[7px] py-2 font-mono text-[13px] font-semibold"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function BadgeInspector({
|
||||
field,
|
||||
badgeW,
|
||||
badgeH,
|
||||
effectiveFontSize,
|
||||
photoCount,
|
||||
volunteerCount,
|
||||
onChange,
|
||||
onHide,
|
||||
}: {
|
||||
field: FieldState;
|
||||
badgeW: number;
|
||||
badgeH: number;
|
||||
/** Taille réellement utilisée sur le badge affiché (réduction auto). */
|
||||
effectiveFontSize: number | null;
|
||||
photoCount: number;
|
||||
volunteerCount: number;
|
||||
onChange: (patch: Partial<FieldState>) => void;
|
||||
onHide: () => void;
|
||||
}) {
|
||||
const isPhoto = field.field_type === 'PHOTO';
|
||||
const lowContrast = Boolean(field.bg_color) && contrastRatio(field.color, field.bg_color as string) < MIN_CONTRAST;
|
||||
const reduced = effectiveFontSize !== null && effectiveFontSize < field.font_size;
|
||||
const setGeometry = (patch: Partial<FieldState>) =>
|
||||
onChange(fitToBadge({ ...field, ...patch }, badgeW, badgeH));
|
||||
const fontSize = (delta: number) =>
|
||||
onChange({
|
||||
font_size: Math.min(MAX_FONT_PT, Math.max(MIN_FONT_PT, round1(field.font_size + delta))),
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-ink px-3 py-[10px] text-cream">
|
||||
<div className="font-mono text-[9.5px] uppercase text-yellow">
|
||||
{t('badges.inspector.kicker', { face: t(FACE_LABEL_KEY[field.face]) })}
|
||||
</div>
|
||||
<div className="mt-[5px] font-display text-[17px] font-black leading-tight tracking-tight">
|
||||
{t(FIELD_LABEL_KEY[field.field_type])}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[15px] p-3">
|
||||
{field.field_type === 'FREE_TEXT' ? (
|
||||
<label className="block">
|
||||
<span className="mb-[6px] block font-display text-[9.5px] font-extrabold uppercase tracking-widest">
|
||||
{t('badges.inspector.content')}
|
||||
</span>
|
||||
<textarea
|
||||
value={field.text ?? ''}
|
||||
onChange={(e) => onChange({ text: e.target.value })}
|
||||
rows={2}
|
||||
placeholder={t('badges.field.freeText.input')}
|
||||
className="w-full resize-y border-2 border-ink bg-cream p-2 font-display text-[13px] font-semibold leading-snug"
|
||||
/>
|
||||
<span className="mt-1 block font-mono text-[9px] leading-normal opacity-60">
|
||||
{t('badges.inspector.contentHelp')}
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{!isPhoto ? (
|
||||
<>
|
||||
<Section
|
||||
title={t('badges.inspector.fontSize')}
|
||||
aside={
|
||||
reduced
|
||||
? t('badges.inspector.reducedHere', { size: fmt(effectiveFontSize as number) })
|
||||
: t('badges.inspector.capHeight', { mm: fmt(field.font_size * MM_PER_PT) })
|
||||
}
|
||||
>
|
||||
<div className="flex items-stretch border-2 border-ink">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fontSize(-FONT_STEP_PT)}
|
||||
aria-label={t('badges.inspector.smaller')}
|
||||
className="h-10 w-11 cursor-pointer border-0 border-r-2 border-ink bg-cream font-display text-[16px] font-black hover:bg-yellow"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="flex flex-1 items-center justify-center font-mono text-[17px] font-bold">
|
||||
{t('badges.pt', { size: fmt(field.font_size) })}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fontSize(FONT_STEP_PT)}
|
||||
aria-label={t('badges.inspector.larger')}
|
||||
className="h-10 w-11 cursor-pointer border-0 border-l-2 border-ink bg-cream font-display text-[16px] font-black hover:bg-yellow"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={field.auto_fit}
|
||||
onClick={() => onChange({ auto_fit: !field.auto_fit })}
|
||||
className="mt-[7px] flex w-full cursor-pointer items-center gap-[9px] border-0 bg-transparent px-0 py-[3px] text-left"
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'relative h-5 w-[34px] flex-none border-2 border-ink',
|
||||
field.auto_fit ? 'bg-yellow' : 'bg-sand-dark',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="absolute top-[1px] h-[14px] w-[14px] bg-ink"
|
||||
style={{ left: field.auto_fit ? 15 : 1 }}
|
||||
/>
|
||||
</span>
|
||||
<span className="font-display text-[11.5px] font-bold leading-snug">
|
||||
{t('badges.inspector.autoFit')}
|
||||
</span>
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
<Section title={t('badges.inspector.align')}>
|
||||
<div className="grid grid-cols-3 border-2 border-ink">
|
||||
{ALIGNS.map((a, i) => (
|
||||
<Segment
|
||||
key={a.value}
|
||||
active={field.align === a.value}
|
||||
onClick={() => onChange({ align: a.value })}
|
||||
className={cx(
|
||||
'flex h-[46px] flex-col items-center justify-center gap-1',
|
||||
i < 2 && 'border-r-2 border-ink',
|
||||
)}
|
||||
>
|
||||
<span className={cx('flex w-[18px] flex-col gap-[2px]', a.items)}>
|
||||
<span className="h-[2px] w-[18px] bg-current" />
|
||||
<span className="h-[2px] w-[11px] bg-current" />
|
||||
<span className="h-[2px] w-[15px] bg-current" />
|
||||
</span>
|
||||
<span className="font-display text-[9.5px] font-bold">{t(a.key)}</span>
|
||||
</Segment>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('badges.inspector.weight')}>
|
||||
<div className="grid grid-cols-2 border-2 border-ink">
|
||||
<Segment
|
||||
active={!field.bold}
|
||||
onClick={() => onChange({ bold: false })}
|
||||
className="h-[38px] border-r-2 border-ink font-display text-[13px] font-medium"
|
||||
>
|
||||
{t('badges.inspector.regular')}
|
||||
</Segment>
|
||||
<Segment
|
||||
active={field.bold}
|
||||
onClick={() => onChange({ bold: true })}
|
||||
className="h-[38px] font-display text-[13px] font-black"
|
||||
>
|
||||
{t('badges.inspector.bold')}
|
||||
</Segment>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('badges.inspector.textColor')}>
|
||||
<div className="grid grid-cols-5 gap-[6px]">
|
||||
{TEXT_COLORS.map((c) => (
|
||||
<Swatch
|
||||
key={c.hex}
|
||||
hex={c.hex}
|
||||
label={t(c.key)}
|
||||
active={sameColor(field.color, c.hex)}
|
||||
onClick={() => onChange({ color: c.hex })}
|
||||
/>
|
||||
))}
|
||||
<label className="flex cursor-pointer flex-col items-center gap-1">
|
||||
<input
|
||||
type="color"
|
||||
value={field.color}
|
||||
onChange={(e) => onChange({ color: e.target.value })}
|
||||
className={cx(
|
||||
'h-[30px] w-full cursor-pointer border-2 border-ink bg-cream p-0',
|
||||
!TEXT_COLORS.some((c) => sameColor(c.hex, field.color)) &&
|
||||
'shadow-[0_0_0_3px_#2A6BFF]',
|
||||
)}
|
||||
/>
|
||||
<span className="font-display text-[9.5px] font-semibold">
|
||||
{t('badges.color.custom')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title={t('badges.inspector.background')}>
|
||||
<div className="grid grid-cols-4 gap-[6px]">
|
||||
{BG_COLORS.map((c) => (
|
||||
<Swatch
|
||||
key={c.key}
|
||||
hex={c.hex}
|
||||
label={t(c.key)}
|
||||
active={sameColor(field.bg_color, c.hex)}
|
||||
onClick={() => onChange({ bg_color: c.hex })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{lowContrast ? (
|
||||
<div className="mt-[7px] border-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
|
||||
{t('badges.inspector.lowContrast')}
|
||||
</div>
|
||||
) : null}
|
||||
</Section>
|
||||
</>
|
||||
) : (
|
||||
<Section title={t('badges.inspector.photoShape')}>
|
||||
<div className="grid grid-cols-2 border-2 border-ink">
|
||||
<Segment
|
||||
active={field.photo_shape === 'RECT'}
|
||||
onClick={() => onChange({ photo_shape: 'RECT' })}
|
||||
className="h-10 border-r-2 border-ink font-display text-[12px] font-extrabold"
|
||||
>
|
||||
{t('badges.inspector.photoRect')}
|
||||
</Segment>
|
||||
<Segment
|
||||
active={field.photo_shape === 'OVAL'}
|
||||
onClick={() => onChange({ photo_shape: 'OVAL' })}
|
||||
className="h-10 font-display text-[12px] font-extrabold"
|
||||
>
|
||||
{t('badges.inspector.photoOval')}
|
||||
</Segment>
|
||||
</div>
|
||||
<div className="mt-[7px] font-mono text-[9.5px] leading-relaxed opacity-65">
|
||||
{t('badges.inspector.photoStat', { count: photoCount, total: volunteerCount })}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<div className="border-t-2 border-ink pt-3">
|
||||
<div className="mb-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-widest">
|
||||
{t('badges.inspector.geometry')}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-[7px]">
|
||||
<MmInput label={t('badges.inspector.x')} value={field.x_mm} onCommit={(v) => setGeometry({ x_mm: v })} />
|
||||
<MmInput label={t('badges.inspector.y')} value={field.y_mm} onCommit={(v) => setGeometry({ y_mm: v })} />
|
||||
<MmInput
|
||||
label={t('badges.inspector.w')}
|
||||
value={field.width_mm}
|
||||
onCommit={(v) => setGeometry({ width_mm: Math.max(1, v) })}
|
||||
/>
|
||||
<MmInput
|
||||
label={t('badges.inspector.h')}
|
||||
value={field.height_mm}
|
||||
onCommit={(v) => setGeometry({ height_mm: Math.max(1, v) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-[7px] grid grid-cols-2 gap-[7px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGeometry({ x_mm: (badgeW - field.width_mm) / 2 })}
|
||||
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft"
|
||||
>
|
||||
{t('badges.inspector.center')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGeometry({ x_mm: 0, width_mm: badgeW })}
|
||||
className="cursor-pointer border-2 border-ink bg-cream p-2 font-display text-[10.5px] font-bold hover:bg-yellow-soft"
|
||||
>
|
||||
{t('badges.inspector.fullWidth')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onHide}
|
||||
className="cursor-pointer border-2 border-ink bg-paper p-[10px] font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-orange"
|
||||
>
|
||||
{t('badges.inspector.hide')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Métriques de la police Helvetica (et Helvetica-Bold) des 14 polices
|
||||
// standard PDF, telles que ReportLab les utilise pour `stringWidth`
|
||||
// (`backend/app/badges/pdf.py:wrap_text`) : largeur de chaque caractère du
|
||||
// codage WinAnsi, en millièmes de la taille de police. Recopiées des fichiers
|
||||
// `reportlab/pdfbase/_fontdata_widths_helvetica*.py` — mesurer avec ces mêmes
|
||||
// chiffres (plutôt qu'avec la police que le navigateur trouve sous le nom
|
||||
// « Helvetica ») fait tomber les retours à la ligne de l'aperçu au même
|
||||
// endroit que dans le PDF, et marche aussi hors navigateur (smoke).
|
||||
|
||||
const CHARS = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ ¡¢£¤¥¦§¨©ª«¬®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿ";
|
||||
const REGULAR = [278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556,1015,667,667,722,722,667,611,778,722,278,500,667,556,833,722,778,667,778,722,667,611,722,667,944,667,667,611,278,278,278,469,556,333,556,556,500,556,556,278,556,556,222,222,500,222,833,556,556,556,556,333,500,278,556,500,722,500,500,500,334,260,334,584,350,556,222,556,333,1000,556,556,333,1000,667,333,1000,611,222,222,333,333,350,556,1000,333,1000,500,333,944,500,667,278,333,556,556,556,556,260,556,333,737,370,556,584,333,737,333,400,584,333,333,333,556,537,278,333,333,365,556,834,834,834,611,667,667,667,667,667,667,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,500,556,556,556,556,278,278,278,278,556,556,556,556,556,556,556,584,611,556,556,556,556,500,556,500];
|
||||
const BOLD = [278,333,474,556,556,889,722,238,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,333,333,584,584,584,611,975,722,722,722,722,667,611,778,722,278,556,722,611,833,722,778,667,778,722,667,611,722,667,944,667,667,611,333,278,333,584,556,333,556,611,556,611,556,333,611,611,278,278,556,278,889,611,611,611,611,389,556,333,611,556,778,556,556,500,389,280,389,584,350,556,278,556,500,1000,556,556,333,1000,667,333,1000,611,278,278,500,500,350,556,1000,333,1000,556,333,944,500,667,278,333,556,556,556,556,280,556,333,737,370,556,584,333,737,333,400,584,333,333,333,611,556,278,333,333,365,556,834,834,834,611,722,722,722,722,722,722,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,556,556,556,556,556,278,278,278,278,611,611,611,611,611,611,611,584,611,611,611,611,611,556,611,556];
|
||||
|
||||
/** Largeur (millièmes d'em) d'un caractère hors WinAnsi : ReportLab le
|
||||
* remplacerait par une police de substitution, on prend une largeur moyenne. */
|
||||
const FALLBACK_WIDTH = 556;
|
||||
|
||||
const regularWidths = new Map<string, number>();
|
||||
const boldWidths = new Map<string, number>();
|
||||
for (let i = 0; i < CHARS.length; i++) {
|
||||
regularWidths.set(CHARS[i], REGULAR[i]);
|
||||
boldWidths.set(CHARS[i], BOLD[i]);
|
||||
}
|
||||
|
||||
/** Largeur de `text` en points, à la taille `size` (pt) — équivalent de
|
||||
* `reportlab.pdfbase.pdfmetrics.stringWidth(text, "Helvetica[-Bold]", size)`. */
|
||||
export function stringWidthPt(text: string, bold: boolean, size: number): number {
|
||||
const widths = bold ? boldWidths : regularWidths;
|
||||
let total = 0;
|
||||
for (const ch of text) total += widths.get(ch) ?? FALLBACK_WIDTH;
|
||||
return (total * size) / 1000;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Contrôle avant génération : passe chaque champ affiché sur TOUS les
|
||||
// bénévoles (estimation de mise en page, voir `layout.ts`) et relève ce qui
|
||||
// risque de mal s'imprimer.
|
||||
|
||||
import type { BadgeFace, BadgeFieldType } from '@/api/schema';
|
||||
|
||||
import { contrastRatio, MIN_CONTRAST } from './layout';
|
||||
import { renderField, SAFE_MARGIN_MM, type BadgeData, type FieldRef, type FieldState } from './model';
|
||||
|
||||
export interface FieldStats {
|
||||
/** Bénévoles dont le texte a été réduit automatiquement (sans être coupé). */
|
||||
reduced: number;
|
||||
/** Bénévoles dont le texte reste coupé. */
|
||||
truncated: number;
|
||||
/** Plus petite taille atteinte par la réduction automatique. */
|
||||
minPt: number;
|
||||
/** Index (dans `BadgeData.volunteers`) d'un exemple de chaque cas. */
|
||||
reducedExample: number | null;
|
||||
truncatedExample: number | null;
|
||||
}
|
||||
|
||||
export type IssueKind = 'truncated' | 'reduced' | 'margin' | 'contrast' | 'noPhoto';
|
||||
|
||||
export interface BadgeIssue {
|
||||
kind: IssueKind;
|
||||
critical: boolean;
|
||||
ref: FieldRef;
|
||||
count: number;
|
||||
minPt: number;
|
||||
/** Bénévole à afficher pour « Voir un exemple » (null : pas d'exemple). */
|
||||
exampleIndex: number | null;
|
||||
/** « Activer la réduction auto » proposé (texte coupé, réduction coupée). */
|
||||
canEnableAutoFit: boolean;
|
||||
}
|
||||
|
||||
export function statsKey(face: BadgeFace, type: BadgeFieldType): string {
|
||||
return `${face}:${type}`;
|
||||
}
|
||||
|
||||
export function computeIssues(
|
||||
fields: FieldState[],
|
||||
data: BadgeData,
|
||||
badgeW: number,
|
||||
badgeH: number,
|
||||
): { issues: BadgeIssue[]; stats: Map<string, FieldStats> } {
|
||||
const stats = new Map<string, FieldStats>();
|
||||
const issues: BadgeIssue[] = [];
|
||||
|
||||
for (const field of fields) {
|
||||
if (!field.enabled) continue;
|
||||
const ref = { face: field.face, type: field.field_type };
|
||||
const base = { ref, count: 0, minPt: field.font_size, exampleIndex: null, canEnableAutoFit: false };
|
||||
|
||||
if (field.field_type !== 'PHOTO') {
|
||||
const s: FieldStats = {
|
||||
reduced: 0,
|
||||
truncated: 0,
|
||||
minPt: field.font_size,
|
||||
reducedExample: null,
|
||||
truncatedExample: null,
|
||||
};
|
||||
data.volunteers.forEach((volunteer, index) => {
|
||||
const rendered = renderField(field, volunteer, data);
|
||||
if (rendered.kind === 'photo') return;
|
||||
const { truncated, reduced, fontSize } = rendered.layout;
|
||||
if (truncated) {
|
||||
s.truncated += 1;
|
||||
if (s.truncatedExample === null) s.truncatedExample = index;
|
||||
} else if (reduced) {
|
||||
s.reduced += 1;
|
||||
if (fontSize < s.minPt) {
|
||||
s.minPt = fontSize;
|
||||
s.reducedExample = index;
|
||||
}
|
||||
}
|
||||
});
|
||||
stats.set(statsKey(field.face, field.field_type), s);
|
||||
if (s.truncated) {
|
||||
issues.push({
|
||||
...base,
|
||||
kind: 'truncated',
|
||||
critical: true,
|
||||
count: s.truncated,
|
||||
exampleIndex: s.truncatedExample,
|
||||
canEnableAutoFit: !field.auto_fit,
|
||||
});
|
||||
} else if (s.reduced) {
|
||||
issues.push({
|
||||
...base,
|
||||
kind: 'reduced',
|
||||
critical: false,
|
||||
count: s.reduced,
|
||||
minPt: s.minPt,
|
||||
exampleIndex: s.reducedExample,
|
||||
});
|
||||
}
|
||||
if (field.bg_color && contrastRatio(field.color, field.bg_color) < MIN_CONTRAST) {
|
||||
issues.push({ ...base, kind: 'contrast', critical: true });
|
||||
}
|
||||
} else {
|
||||
const missing = data.volunteers.findIndex((v) => !v.has_photo);
|
||||
if (missing >= 0) {
|
||||
issues.push({
|
||||
...base,
|
||||
kind: 'noPhoto',
|
||||
critical: false,
|
||||
count: data.volunteers.filter((v) => !v.has_photo).length,
|
||||
exampleIndex: missing,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Un bandeau coloré (fond de zone) peut volontairement aller jusqu'au
|
||||
// bord : seul un champ sans fond qui touche la marge est signalé.
|
||||
const bleed = field.bg_color !== null && field.field_type !== 'PHOTO';
|
||||
const m = SAFE_MARGIN_MM - 1e-6;
|
||||
const touchesEdge =
|
||||
field.x_mm < m ||
|
||||
field.y_mm < m ||
|
||||
field.x_mm + field.width_mm > badgeW - m ||
|
||||
field.y_mm + field.height_mm > badgeH - m;
|
||||
if (touchesEdge && !bleed) issues.push({ ...base, kind: 'margin', critical: false });
|
||||
}
|
||||
|
||||
issues.sort((a, b) => Number(b.critical) - Number(a.critical));
|
||||
return { issues, stats };
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// Estimation de la mise en page d'un champ du badge, côté navigateur — même
|
||||
// algorithme que la génération PDF (`backend/app/badges/pdf.py` : `wrap_text`,
|
||||
// `_draw_table`, mêmes métriques Helvetica via `stringWidthPt`). Sert à
|
||||
// l'aperçu (retours à la ligne, réduction automatique, lignes coupées) et au
|
||||
// contrôle avant génération. Ce n'est qu'une estimation : le PDF reste la
|
||||
// référence.
|
||||
|
||||
import { stringWidthPt } from './helveticaMetrics';
|
||||
|
||||
/** 1 pt = 25,4 / 72 mm. */
|
||||
export const MM_PER_PT = 25.4 / 72;
|
||||
/** Interligne, en multiple de la taille de police (`_LINE_HEIGHT_FACTOR`). */
|
||||
export const LINE_HEIGHT = 1.25;
|
||||
/** Position de la première ligne de base sous le haut du cadre, en multiple
|
||||
* de la taille de police (`font_size * 0.85` dans le PDF). */
|
||||
export const FIRST_BASELINE = 0.85;
|
||||
export const MIN_FONT_PT = 4;
|
||||
export const MAX_FONT_PT = 30;
|
||||
export const FONT_STEP_PT = 0.5;
|
||||
/** Marges intérieures des cellules du tableau des créneaux (mm). */
|
||||
export const CELL_PAD_X_MM = 0.8;
|
||||
export const CELL_PAD_Y_MM = 0.5;
|
||||
|
||||
/** Découpe `text` en lignes de largeur au plus `maxWidthPt` — portage de
|
||||
* `wrap_text` : les `\n` restent des retours à la ligne, puis passage à la
|
||||
* ligne entre deux mots, ou au milieu d'un mot qui seul ne tient pas. */
|
||||
export function wrapText(text: string, bold: boolean, size: number, maxWidthPt: number): string[] {
|
||||
const fits = (candidate: string) => stringWidthPt(candidate, bold, size) <= maxWidthPt;
|
||||
const lines: string[] = [];
|
||||
for (const paragraph of text.split('\n')) {
|
||||
let current = '';
|
||||
for (const word of paragraph.split(' ')) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (fits(candidate)) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if (current) lines.push(current.trimEnd());
|
||||
current = word;
|
||||
while (current && !fits(current)) {
|
||||
let cut = current.length - 1;
|
||||
while (cut > 1 && !fits(current.slice(0, cut))) cut -= 1;
|
||||
lines.push(current.slice(0, cut));
|
||||
current = current.slice(cut);
|
||||
}
|
||||
}
|
||||
lines.push(current.trimEnd());
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Nombre de lignes qui tiennent dans `heightMm` : une ligne est dessinée
|
||||
* tant que sa ligne de base reste dans le cadre (règle du PDF). */
|
||||
export function linesThatFit(size: number, heightMm: number): number {
|
||||
const sizeMm = size * MM_PER_PT;
|
||||
if (FIRST_BASELINE * sizeMm > heightMm) return 0;
|
||||
return Math.floor((heightMm - FIRST_BASELINE * sizeMm) / (LINE_HEIGHT * sizeMm) + 1e-9) + 1;
|
||||
}
|
||||
|
||||
export interface TextLayout {
|
||||
/** Taille réellement utilisée (réduite si `reduced`). */
|
||||
fontSize: number;
|
||||
/** Toutes les lignes après retour à la ligne. */
|
||||
lines: string[];
|
||||
/** Lignes imprimées (les suivantes débordent du cadre). */
|
||||
visible: string[];
|
||||
reduced: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
interface FitOptions {
|
||||
fontSize: number;
|
||||
autoFit: boolean;
|
||||
}
|
||||
|
||||
/** Réduction automatique commune au texte et au tableau : à la taille
|
||||
* demandée si tout tient ; sinon, si `autoFit`, par pas de 0,5 pt jusqu'à
|
||||
* 4 pt ; le texte reste coupé s'il ne tient toujours pas. */
|
||||
function withAutoFit<L>(
|
||||
{ fontSize, autoFit }: FitOptions,
|
||||
layoutAt: (size: number) => L,
|
||||
isComplete: (layout: L) => boolean,
|
||||
): { layout: L; fontSize: number; reduced: boolean; truncated: boolean } {
|
||||
let size = fontSize;
|
||||
let layout = layoutAt(size);
|
||||
if (isComplete(layout)) return { layout, fontSize: size, reduced: false, truncated: false };
|
||||
if (!autoFit) return { layout, fontSize: size, reduced: false, truncated: true };
|
||||
while (size - FONT_STEP_PT >= MIN_FONT_PT - 1e-9) {
|
||||
size = Math.round((size - FONT_STEP_PT) * 10) / 10;
|
||||
layout = layoutAt(size);
|
||||
if (isComplete(layout)) return { layout, fontSize: size, reduced: true, truncated: false };
|
||||
}
|
||||
return { layout, fontSize: size, reduced: size < fontSize, truncated: true };
|
||||
}
|
||||
|
||||
export function layoutText(
|
||||
text: string,
|
||||
box: { width_mm: number; height_mm: number; bold: boolean; font_size: number; auto_fit: boolean },
|
||||
): TextLayout {
|
||||
const result = withAutoFit(
|
||||
{ fontSize: box.font_size, autoFit: box.auto_fit },
|
||||
(size) => {
|
||||
const lines = text ? wrapText(text, box.bold, size, box.width_mm / MM_PER_PT) : [];
|
||||
return { lines, capacity: linesThatFit(size, box.height_mm) };
|
||||
},
|
||||
({ lines, capacity }) => lines.length <= capacity,
|
||||
);
|
||||
const { lines, capacity } = result.layout;
|
||||
return {
|
||||
fontSize: result.fontSize,
|
||||
lines,
|
||||
visible: lines.slice(0, capacity),
|
||||
reduced: result.reduced,
|
||||
truncated: result.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Tableau des créneaux (`POSITIONS`) //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export interface TableSection {
|
||||
title: string;
|
||||
rows: string[][];
|
||||
}
|
||||
|
||||
export interface TableRowLayout {
|
||||
/** Lignes de texte de chaque cellule (une seule cellule pour un en-tête). */
|
||||
cells: string[][];
|
||||
isTitle: boolean;
|
||||
heightMm: number;
|
||||
}
|
||||
|
||||
export interface TableLayout {
|
||||
fontSize: number;
|
||||
/** Largeur de chaque colonne (mm). */
|
||||
widthsMm: number[];
|
||||
/** Lignes du tableau qui tiennent dans le cadre. */
|
||||
rows: TableRowLayout[];
|
||||
reduced: boolean;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
/** Portage de `_column_widths` : une colonne courte garde sa largeur
|
||||
* naturelle, les plus longues se partagent le reste. */
|
||||
function columnWidths(natural: number[], available: number): number[] {
|
||||
const widths = natural.map(() => 0);
|
||||
const pending = natural.map((_, i) => i).sort((a, b) => natural[a] - natural[b]);
|
||||
let remaining = available;
|
||||
while (pending.length) {
|
||||
const share = remaining / pending.length;
|
||||
if (natural[pending[0]] > share) {
|
||||
for (const i of pending) widths[i] = share;
|
||||
return widths;
|
||||
}
|
||||
const i = pending.shift() as number;
|
||||
widths[i] = natural[i];
|
||||
remaining -= natural[i];
|
||||
}
|
||||
widths[widths.length - 1] += remaining;
|
||||
return widths;
|
||||
}
|
||||
|
||||
export function layoutTable(
|
||||
sections: TableSection[],
|
||||
box: { width_mm: number; height_mm: number; font_size: number; auto_fit: boolean },
|
||||
): TableLayout {
|
||||
const total = sections.reduce((n, s) => n + 1 + s.rows.length, 0);
|
||||
const result = withAutoFit(
|
||||
{ fontSize: box.font_size, autoFit: box.auto_fit },
|
||||
(size) => {
|
||||
const body = sections.flatMap((s) => s.rows);
|
||||
const columns = body.length ? Math.max(...body.map((r) => r.length)) : 1;
|
||||
const natural = body.length
|
||||
? Array.from({ length: columns }, (_, col) =>
|
||||
Math.max(...body.map((r) => stringWidthPt(r[col] ?? '', false, size) * MM_PER_PT)) +
|
||||
2 * CELL_PAD_X_MM,
|
||||
)
|
||||
: [0];
|
||||
const widthsMm = columnWidths(natural, box.width_mm);
|
||||
const fullWidth = widthsMm.reduce((a, b) => a + b, 0);
|
||||
const lineMm = size * MM_PER_PT * LINE_HEIGHT;
|
||||
const rows: TableRowLayout[] = [];
|
||||
let used = 0;
|
||||
outer: for (const section of sections) {
|
||||
const entries: Array<{ cells: string[]; isTitle: boolean }> = [
|
||||
{ cells: [section.title], isTitle: true },
|
||||
...section.rows.map((cells) => ({ cells, isTitle: false })),
|
||||
];
|
||||
for (const entry of entries) {
|
||||
const widths = entry.isTitle ? [fullWidth] : widthsMm;
|
||||
const cells = entry.cells.map((text, col) =>
|
||||
wrapText(
|
||||
text,
|
||||
entry.isTitle,
|
||||
size,
|
||||
Math.max(1, (widths[col] - 2 * CELL_PAD_X_MM) / MM_PER_PT),
|
||||
),
|
||||
);
|
||||
const heightMm = Math.max(...cells.map((c) => c.length)) * lineMm + 2 * CELL_PAD_Y_MM;
|
||||
if (used + heightMm > box.height_mm + 0.01 * MM_PER_PT) break outer;
|
||||
rows.push({ cells, isTitle: entry.isTitle, heightMm });
|
||||
used += heightMm;
|
||||
}
|
||||
}
|
||||
return { widthsMm, rows };
|
||||
},
|
||||
(layout) => layout.rows.length >= total,
|
||||
);
|
||||
return {
|
||||
fontSize: result.fontSize,
|
||||
widthsMm: result.layout.widthsMm,
|
||||
rows: result.layout.rows,
|
||||
reduced: result.reduced,
|
||||
truncated: result.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Contraste //
|
||||
// --------------------------------------------------------------------------- //
|
||||
function luminance(hex: string): number {
|
||||
const n = parseInt(hex.slice(1, 7), 16);
|
||||
const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((c) => {
|
||||
const x = c / 255;
|
||||
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
/** Rapport de contraste WCAG entre deux couleurs `#rrggbb`. */
|
||||
export function contrastRatio(a: string, b: string): number {
|
||||
const la = luminance(a);
|
||||
const lb = luminance(b);
|
||||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||||
}
|
||||
|
||||
/** En dessous, le texte risque d'être illisible à l'impression. */
|
||||
export const MIN_CONTRAST = 3;
|
||||
@@ -0,0 +1,268 @@
|
||||
// État de l'éditeur de badges et contenu réel de chaque champ pour un
|
||||
// bénévole donné — mêmes règles que la génération PDF
|
||||
// (`backend/app/api/badges.py:_field_text`, `_positions_table`).
|
||||
|
||||
import type {
|
||||
BadgeFace,
|
||||
BadgeFieldIn,
|
||||
BadgeFieldRead,
|
||||
BadgeFieldType,
|
||||
PositionRead,
|
||||
ShiftRead,
|
||||
VolunteerRead,
|
||||
} from '@/api/schema';
|
||||
import { t } from '@/i18n';
|
||||
import { categoryLabel, festivalDay } from '@/lib/planning';
|
||||
import { longDayLabel, timePart } from '@/lib/time';
|
||||
|
||||
import { layoutTable, layoutText, type TableLayout, type TableSection, type TextLayout } from './layout';
|
||||
|
||||
export const DEFAULT_BADGE_W_MM = 85.6;
|
||||
export const DEFAULT_BADGE_H_MM = 54;
|
||||
/** Marge de découpe (mm) : un champ qui la touche risque d'être rogné. */
|
||||
export const SAFE_MARGIN_MM = 1.5;
|
||||
|
||||
export const FACES: BadgeFace[] = ['FRONT', 'BACK'];
|
||||
|
||||
export const FIELD_TYPES: BadgeFieldType[] = [
|
||||
'PHOTO',
|
||||
'FULL_NAME',
|
||||
'POSITIONS',
|
||||
'CATEGORY',
|
||||
'ROLE',
|
||||
'FREE_TEXT',
|
||||
];
|
||||
|
||||
export 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',
|
||||
};
|
||||
|
||||
export const FACE_LABEL_KEY: Record<BadgeFace, string> = {
|
||||
FRONT: 'badges.face.front',
|
||||
BACK: 'badges.face.back',
|
||||
};
|
||||
|
||||
/** Formats prédéfinis (mm). */
|
||||
export const BADGE_PRESETS: Array<{ key: string; width: number; height: number }> = [
|
||||
{ key: 'badges.preset.cr80Portrait', width: 54, height: 85.6 },
|
||||
{ key: 'badges.preset.cr80Landscape', width: 85.6, height: 54 },
|
||||
{ key: 'badges.preset.a7', width: 74, height: 105 },
|
||||
];
|
||||
|
||||
export type FieldState = BadgeFieldIn & { enabled: boolean };
|
||||
|
||||
export interface FieldRef {
|
||||
face: BadgeFace;
|
||||
type: BadgeFieldType;
|
||||
}
|
||||
|
||||
export function sameRef(a: FieldRef | null, b: FieldRef | null): boolean {
|
||||
return Boolean(a && b && a.face === b.face && a.type === b.type);
|
||||
}
|
||||
|
||||
type FieldDefaults = Omit<BadgeFieldIn, 'field_type' | 'face'>;
|
||||
|
||||
const COMMON = { bg_color: null, auto_fit: true, photo_shape: 'RECT' } as const;
|
||||
|
||||
// Position/taille par défaut à l'activation d'un champ — empilés
|
||||
// verticalement pour ne pas se superposer (repères pour un badge CR80
|
||||
// paysage, ramenés dans le badge par `fitToBadge` pour un autre format).
|
||||
const FIELD_DEFAULTS: Record<BadgeFieldType, FieldDefaults> = {
|
||||
FULL_NAME: {
|
||||
...COMMON, x_mm: 5, y_mm: 4, width_mm: 50, height_mm: 11,
|
||||
font_size: 11, align: 'LEFT', bold: true, color: '#14110E',
|
||||
},
|
||||
POSITIONS: {
|
||||
...COMMON, x_mm: 5, y_mm: 16, width_mm: 52, height_mm: 22,
|
||||
font_size: 6, align: 'LEFT', bold: false, color: '#14110E',
|
||||
},
|
||||
CATEGORY: {
|
||||
...COMMON, x_mm: 5, y_mm: 39, width_mm: 70, height_mm: 5,
|
||||
font_size: 7, align: 'LEFT', bold: true, color: '#14110E',
|
||||
},
|
||||
ROLE: {
|
||||
...COMMON, x_mm: 5, y_mm: 45, width_mm: 30, height_mm: 5,
|
||||
font_size: 7, align: 'CENTER', bold: true, color: '#14110E', bg_color: '#FFD23F',
|
||||
},
|
||||
// Format portrait d'identité, en haut à droite. Taille de police/couleur
|
||||
// sans effet : la photo est une image.
|
||||
PHOTO: {
|
||||
...COMMON, x_mm: 60, y_mm: 4, width_mm: 21, height_mm: 27,
|
||||
font_size: 8, align: 'CENTER', bold: false, color: '#14110E',
|
||||
},
|
||||
FREE_TEXT: {
|
||||
...COMMON, x_mm: 40, y_mm: 45, width_mm: 40, height_mm: 5,
|
||||
font_size: 7, align: 'CENTER', bold: true, color: '#FFFFFF', bg_color: '#14110E',
|
||||
},
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
/** Ramène un champ à l'intérieur du badge (réduit s'il est plus grand,
|
||||
* décalé s'il dépasse). Sur un badge plus étroit que le CR80 paysage des
|
||||
* positions par défaut, un champ activé pouvait tomber hors du badge —
|
||||
* invisible, et les bornes négatives du glisser-déposer le bloquaient (bug
|
||||
* corrigé une fois : photo impossible à déplacer ni redimensionner). */
|
||||
export function fitToBadge<F extends BadgeFieldIn>(field: F, badgeW: number, badgeH: number): F {
|
||||
const width_mm = Math.min(field.width_mm, badgeW);
|
||||
const height_mm = Math.min(field.height_mm, badgeH);
|
||||
return {
|
||||
...field,
|
||||
width_mm,
|
||||
height_mm,
|
||||
x_mm: clamp(field.x_mm, 0, badgeW - width_mm),
|
||||
y_mm: clamp(field.y_mm, 0, badgeH - height_mm),
|
||||
};
|
||||
}
|
||||
|
||||
/** Les 12 champs possibles (6 types × 2 faces), activés ou non, dans
|
||||
* l'ordre d'affichage. */
|
||||
export function fieldsFromServer(
|
||||
saved: BadgeFieldRead[],
|
||||
badgeW: number,
|
||||
badgeH: number,
|
||||
): FieldState[] {
|
||||
const out: FieldState[] = [];
|
||||
for (const face of FACES) {
|
||||
for (const type of FIELD_TYPES) {
|
||||
const existing = saved.find((f) => (f.face ?? 'FRONT') === face && f.field_type === type);
|
||||
const field: FieldState = existing
|
||||
? {
|
||||
field_type: type,
|
||||
face,
|
||||
x_mm: existing.x_mm,
|
||||
y_mm: existing.y_mm,
|
||||
width_mm: existing.width_mm,
|
||||
height_mm: existing.height_mm,
|
||||
font_size: existing.font_size,
|
||||
align: existing.align,
|
||||
bold: existing.bold,
|
||||
color: existing.color,
|
||||
bg_color: existing.bg_color ?? null,
|
||||
auto_fit: existing.auto_fit ?? true,
|
||||
photo_shape: existing.photo_shape ?? 'RECT',
|
||||
text: existing.text ?? null,
|
||||
enabled: true,
|
||||
}
|
||||
: { field_type: type, face, ...FIELD_DEFAULTS[type], text: null, enabled: false };
|
||||
out.push(fitToBadge(field, badgeW, badgeH));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Corps du `PUT .../badges/template/fields` : les champs activés des deux
|
||||
* faces. */
|
||||
export function toPayload(fields: FieldState[]): BadgeFieldIn[] {
|
||||
return fields
|
||||
.filter((f) => f.enabled)
|
||||
.map((f) => ({
|
||||
field_type: f.field_type,
|
||||
face: f.face,
|
||||
x_mm: round1(f.x_mm),
|
||||
y_mm: round1(f.y_mm),
|
||||
width_mm: round1(f.width_mm),
|
||||
height_mm: round1(f.height_mm),
|
||||
font_size: f.font_size,
|
||||
align: f.align,
|
||||
bold: f.bold,
|
||||
color: f.color,
|
||||
bg_color: f.bg_color,
|
||||
auto_fit: f.auto_fit,
|
||||
photo_shape: f.photo_shape,
|
||||
text: f.field_type === 'FREE_TEXT' ? f.text?.trim() || null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
export function round1(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
/** Nombre français à une décimale au plus (« 8,5 »). */
|
||||
export function fmt(value: number): string {
|
||||
return String(round1(value)).replace('.', ',');
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------- //
|
||||
// Contenu réel d'un champ pour un bénévole //
|
||||
// --------------------------------------------------------------------------- //
|
||||
export interface BadgeData {
|
||||
/** Dans l'ordre d'impression : nom puis prénom (comme le PDF). */
|
||||
volunteers: VolunteerRead[];
|
||||
/** Créneaux affectés de chaque bénévole, dans l'ordre chronologique. */
|
||||
assignments: Map<string, Array<{ position: PositionRead; shift: ShiftRead }>>;
|
||||
/** Bénévoles dont le compte est référent (`LEAD`). */
|
||||
leadVolunteerIds: Set<string>;
|
||||
/** Début de « journée de festival » (`HH:MM`). */
|
||||
dayBoundary: string;
|
||||
}
|
||||
|
||||
export function initials(volunteer: VolunteerRead): string {
|
||||
return `${volunteer.first_name.trim().charAt(0)}${volunteer.last_name.trim().charAt(0)}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function fullNameText(volunteer: VolunteerRead): string {
|
||||
return `${volunteer.first_name}\n${volunteer.last_name}`;
|
||||
}
|
||||
|
||||
/** Texte d'un champ (hors `POSITIONS`, un tableau, et `PHOTO`). */
|
||||
export function fieldText(field: FieldState, volunteer: VolunteerRead, data: BadgeData): string {
|
||||
switch (field.field_type) {
|
||||
case 'FULL_NAME':
|
||||
return fullNameText(volunteer);
|
||||
case 'FREE_TEXT':
|
||||
// Texte commun saisi dans l'éditeur, remplacé pour un bénévole par le
|
||||
// « Texte du badge » de sa fiche s'il en a un.
|
||||
return volunteer.badge_text || field.text || '';
|
||||
case 'CATEGORY': {
|
||||
const rows = data.assignments.get(volunteer.id) ?? [];
|
||||
return [...new Set(rows.map((r) => categoryLabel(r.position.category)))].sort().join(', ');
|
||||
}
|
||||
case 'ROLE':
|
||||
return data.leadVolunteerIds.has(volunteer.id) ? t('role.lead') : t('role.volunteer');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Créneaux regroupés par jour de festival : le jour en en-tête, puis une
|
||||
* ligne horaire / poste par créneau. */
|
||||
export function positionsTable(volunteer: VolunteerRead, data: BadgeData): TableSection[] {
|
||||
const byDay = new Map<string, string[][]>();
|
||||
for (const { position, shift } of data.assignments.get(volunteer.id) ?? []) {
|
||||
const day = festivalDay(shift.starts_at, data.dayBoundary);
|
||||
const rows = byDay.get(day) ?? [];
|
||||
rows.push([`${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`, position.name]);
|
||||
byDay.set(day, rows);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([day, rows]) => ({ title: longDayLabel(day), rows }));
|
||||
}
|
||||
|
||||
export type FieldRender =
|
||||
| { kind: 'text'; layout: TextLayout }
|
||||
| { kind: 'table'; layout: TableLayout }
|
||||
| { kind: 'photo' };
|
||||
|
||||
/** Mise en page estimée d'un champ pour un bénévole. */
|
||||
export function renderField(field: FieldState, volunteer: VolunteerRead, data: BadgeData): FieldRender {
|
||||
if (field.field_type === 'PHOTO') return { kind: 'photo' };
|
||||
if (field.field_type === 'POSITIONS') {
|
||||
return { kind: 'table', layout: layoutTable(positionsTable(volunteer, data), field) };
|
||||
}
|
||||
return { kind: 'text', layout: layoutText(fieldText(field, volunteer, data), field) };
|
||||
}
|
||||
|
||||
/** Champs dont le texte est centré verticalement dans le cadre (bandeaux) ;
|
||||
* les autres partent du haut. */
|
||||
export function isVerticallyCentered(type: BadgeFieldType): boolean {
|
||||
return type === 'FREE_TEXT' || type === 'ROLE';
|
||||
}
|
||||
@@ -169,6 +169,43 @@ const FEE_STATUS_LABEL_KEY: Record<NonNullable<VolunteerRead['fee_payment_status
|
||||
EXPIRED: 'config.vol.feeStatus.expired',
|
||||
};
|
||||
|
||||
/**
|
||||
* Nombre d'exemplaires du badge imprimés sur les planches (1 à 100). Saisie
|
||||
* libre gardée en local : n'enregistre qu'un entier valide, et revient à la
|
||||
* dernière valeur enregistrée si le champ est quitté vide ou hors bornes.
|
||||
*/
|
||||
function BadgeCountInput({ value, onChange }: { value: number; onChange: (count: number) => void }) {
|
||||
const [draft, setDraft] = useState(String(value));
|
||||
const parse = (raw: string) => {
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n >= 1 && n <= 100 ? n : null;
|
||||
};
|
||||
return (
|
||||
<label className="block">
|
||||
<FieldLabel>
|
||||
{t('config.vol.field.badgeCount')}
|
||||
<HelpTip text={t('config.vol.field.badgeCount.help')} className="ml-[6px]" />
|
||||
</FieldLabel>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={draft}
|
||||
onChange={(e) => {
|
||||
setDraft(e.target.value);
|
||||
const n = parse(e.target.value);
|
||||
if (n !== null && n !== value) onChange(n);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (parse(draft) === null) setDraft(String(value));
|
||||
}}
|
||||
className="w-[90px] border-2 border-ink bg-cream px-[9px] py-[7px] font-mono text-[13px] font-bold"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Photo du bénévole (badge, écrans bénévole/référent, feuilles de poste) :
|
||||
* celle envoyée au formulaire public, ou ajoutée ici par la coordination
|
||||
@@ -960,6 +997,11 @@ export function ConfigVolunteers() {
|
||||
onChange={(v) => patch({ badge_text: v || null })}
|
||||
/>
|
||||
</label>
|
||||
<BadgeCountInput
|
||||
key={`${selected.id}-${selected.badge_count}`}
|
||||
value={selected.badge_count}
|
||||
onChange={(badge_count) => patch({ badge_count })}
|
||||
/>
|
||||
<VolunteerPhotoSection key={selected.id} volunteer={selected} />
|
||||
|
||||
<VolunteerAccessSection volunteerId={selected.id} email={selected.email} />
|
||||
|
||||
Reference in New Issue
Block a user