Étape 62 : les fiches de poste intégraient la photo d'origine de chaque bénévole pour chaque créneau (CPU à 300 %, puis erreur) — photos réduites à la vignette une fois par PDF ; badges : photo recadrée une fois par génération et décodée à taille réduite. Étape 61 : sans photo, le cadre de la photo reste vide sur le badge. Étape 60 : vue par bénévole en tranches de 30 min, affectations étirées sur toute leur durée, postes simultanés empilés, ligne de l'heure actuelle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
478 lines
15 KiB
TypeScript
478 lines
15 KiB
TypeScript
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,
|
||
isVerticallyCentered,
|
||
renderField,
|
||
SAFE_MARGIN_MM,
|
||
stackOrder,
|
||
type BadgeData,
|
||
type FieldState,
|
||
} from './model';
|
||
|
||
/** 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);
|
||
// Du dessous vers le dessus, comme le PDF (`z_order`).
|
||
const shown = stackOrder(fields, face);
|
||
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, i) => (
|
||
<FieldBox
|
||
key={field.field_type}
|
||
field={field}
|
||
layer={i + 1}
|
||
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,
|
||
layer,
|
||
data,
|
||
volunteer,
|
||
photoUrl,
|
||
badgeW,
|
||
badgeH,
|
||
scale,
|
||
showGuides,
|
||
selected,
|
||
onSelect,
|
||
onChange,
|
||
}: {
|
||
field: FieldState;
|
||
/** Rang dans la pile de la face (`z-index`). */
|
||
layer: number;
|
||
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;
|
||
|
||
const geometry = {
|
||
left: field.x_mm * scale,
|
||
top: field.y_mm * scale,
|
||
width: field.width_mm * scale,
|
||
height: field.height_mm * scale,
|
||
};
|
||
const handlers = {
|
||
onPointerDown: start('move'),
|
||
onPointerMove: move,
|
||
onPointerUp: end,
|
||
onPointerCancel: end,
|
||
};
|
||
|
||
return (
|
||
<>
|
||
{/* Contenu à son rang dans la pile, comme sur le badge imprimé. */}
|
||
<div
|
||
{...(selected ? {} : handlers)}
|
||
className={cx('absolute touch-none', selected ? 'pointer-events-none' : 'cursor-move')}
|
||
style={{
|
||
...geometry,
|
||
outline: selected
|
||
? 'none'
|
||
: truncated
|
||
? '2px dashed #FF5C39'
|
||
: showGuides
|
||
? '1px dashed rgba(20,17,14,.45)'
|
||
: 'none',
|
||
zIndex: layer,
|
||
}}
|
||
>
|
||
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
|
||
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} />
|
||
) : rendered?.kind === 'table' ? (
|
||
<TableContent field={field} layout={rendered.layout} scale={scale} />
|
||
) : rendered?.kind === 'text' ? (
|
||
<TextContent field={field} layout={rendered.layout} scale={scale} />
|
||
) : null}
|
||
|
||
{truncated && !selected ? <TruncatedTag /> : null}
|
||
</div>
|
||
|
||
{/* Cadre de sélection par-dessus toute la pile : un champ recouvert
|
||
(ex. la photo sous un texte) reste saisissable, sans que l'aperçu
|
||
change l'ordre d'impression. */}
|
||
{selected ? (
|
||
<div
|
||
{...handlers}
|
||
className="absolute z-[15] cursor-move touch-none"
|
||
style={{ ...geometry, outline: '2px solid #2A6BFF' }}
|
||
>
|
||
{truncated ? <TruncatedTag /> : null}
|
||
<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]"
|
||
/>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function TruncatedTag() {
|
||
return (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
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,
|
||
}: {
|
||
field: FieldState;
|
||
volunteer: VolunteerRead | null;
|
||
photoUrl: string | null;
|
||
}) {
|
||
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 }}
|
||
/>
|
||
);
|
||
}
|
||
// Sans photo, rien n'est imprimé : juste un repère pointillé dans
|
||
// l'éditeur (pas sur le badge) pour savoir où est le cadre.
|
||
return (
|
||
<div
|
||
className="pointer-events-none flex h-full w-full items-center justify-center border border-dashed border-ink/40 text-center font-mono text-[9px] leading-tight text-ink/50"
|
||
style={{ borderRadius: radius }}
|
||
>
|
||
{t('badges.preview.noPhoto')}
|
||
</div>
|
||
);
|
||
}
|