Files
benevoles/frontend/src/pages/badges/issues.ts
T
AdamThomasandClaude Opus 5.5 ced0855d47
ci-cd / build-backend (push) Successful in 30s
ci-cd / deploy (push) Successful in 13s
ci-cd / build-frontend (push) Successful in 22s
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>
2026-10-27 19:20:42 +01:00

128 lines
4.2 KiB
TypeScript

// 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 };
}