Fiches de poste rapides, badges sans initiales, vue par bénévole sur l'axe des temps
ci-cd / build-backend (push) Successful in 34s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 11s

É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>
This commit is contained in:
AdamThomas
2026-10-28 19:01:48 +01:00
co-authored by Claude Opus 5.5
parent c57c70569c
commit c6ea7378c7
13 changed files with 424 additions and 296 deletions
+8 -3
View File
@@ -106,6 +106,10 @@
"volunteers.filter.all": "TOUS",
"volunteers.filter.unassigned": "SANS AFFECTATION",
"volunteers.legend.assigned": "AFFECTATION",
"volunteers.legend.overlap": "DEUX POSTES EN MÊME TEMPS",
"volunteers.legend.now": "MAINTENANT",
"volunteers.bar.title": "{position} · {time}",
"volunteers.bar.overlapTitle": "{position} · {time} — en même temps qu'un autre poste",
"volunteers.count": "{count} BÉNÉVOLES",
"volunteers.totalHours": "TOTAL H",
"volunteers.sort.label": "TRIER PAR",
@@ -494,6 +498,7 @@
"badges.preview.zoomIn": "Agrandir l'aperçu",
"badges.preview.zoomOut": "Réduire l'aperçu",
"badges.preview.zoom": "{percent} %",
"badges.preview.noPhoto": "sans photo",
"badges.preview.truncated": "▲ TEXTE COUPÉ",
"badges.preview.dims": "{width} × {height} mm",
"badges.preview.reducedTo": " · réduit à {size} pt",
@@ -526,7 +531,7 @@
"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.photoStat": "{count} photo(s) reçue(s) sur {total}. Sans photo, le cadre reste vide.",
"badges.inspector.geometry": "Position et taille (mm)",
"badges.inspector.x": "X",
"badges.inspector.y": "Y",
@@ -560,8 +565,8 @@
"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.",
"badges.issue.noPhoto.one": "{count} bénévole n'a pas de photo : le cadre restera vide sur son badge.",
"badges.issue.noPhoto.other": "{count} bénévoles n'ont pas de photo : le cadre restera vide sur leur badge.",
"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.",
+2 -82
View File
@@ -2,7 +2,6 @@ import type {
ArtistRead,
AssignmentRead,
DayPartRead,
MatrixResponse,
PositionCategory,
ShiftRead,
Violation,
@@ -108,22 +107,6 @@ export function candidateChanceScore(params: {
return score;
}
/** Colonne de la grille : un créneau distinct (date, heure de début, heure de
* fin) — pas seulement une heure de début : deux créneaux qui démarrent à la
* même heure mais durent différemment ont chacun leur colonne. */
export interface LatticeColumn {
key: string; // `YYYY-MM-DD|HH:MM|HH:MM`
date: string;
time: string;
endTime: string;
label: string; // « 17:00 → 22:00 »
}
export interface LatticeDay {
date: string;
columns: LatticeColumn[];
}
/** Frontière du jour (« journée » au sens § 3 du CLAUDE.md) : début du plus
* long creux laissé par les créneaux types sur le cadran de 24 h (l'heure où
* le dernier de la journée se termine) — même règle que
@@ -169,68 +152,6 @@ export function dayBoundaryFromDayParts(
return clockLabel(bestStart);
}
/** Construit le treillis de colonnes à partir de créneaux (grille alignée).
* `dayBoundary` (`HH:MM`, défaut minuit) regroupe et trie les colonnes par
* « journée de festival » plutôt que par date calendaire brute : un créneau
* commençant avant cette heure (ex. 02:00, suite d'une soirée qui a passé
* minuit) est rattaché au jour précédent et trié après 23:00, pas avant
* 19:00 — voir CLAUDE.md § 3. */
export function buildLattice(
starts: Array<{ starts_at: string; ends_at: string }>,
dayBoundary = '00:00',
): {
days: LatticeDay[];
columns: LatticeColumn[];
} {
const boundaryMinutes = minutesOfDay(dayBoundary);
const seen = new Map<string, LatticeColumn & { festivalDay: string; sortMinutes: number }>();
for (const s of starts) {
const date = datePart(s.starts_at);
const time = timePart(s.starts_at);
const endTime = timePart(s.ends_at);
const key = `${date}|${time}|${endTime}`;
if (!seen.has(key)) {
const minutes = minutesOfDay(time);
const festivalDay = minutes < boundaryMinutes ? addDays(date, -1) : date;
const sortMinutes = (minutes - boundaryMinutes + 24 * 60) % (24 * 60);
seen.set(key, { key, date, time, endTime, label: `${time}–${endTime}`, festivalDay, sortMinutes });
}
}
const ordered = [...seen.values()].sort(
(a, b) => a.festivalDay.localeCompare(b.festivalDay) || a.sortMinutes - b.sortMinutes,
);
const columns: LatticeColumn[] = ordered.map(({ key, date, time, endTime, label }) => ({
key,
date,
time,
endTime,
label,
}));
const days: LatticeDay[] = [];
for (const column of ordered) {
let day = days.find((d) => d.date === column.festivalDay);
if (!day) {
day = { date: column.festivalDay, columns: [] };
days.push(day);
}
day.columns.push({
key: column.key,
date: column.date,
time: column.time,
endTime: column.endTime,
label: column.label,
});
}
return { days, columns };
}
export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) {
return buildLattice(
matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at, ends_at: s.ends_at }))),
dayBoundary,
);
}
/**
* Largeur (px) des tranches de 30 min d'une journée, en fonction de leur
* nombre : une journée courte (peu de tranches) reste lisible en large,
@@ -247,9 +168,8 @@ export function columnWidthPx(slotsInDay: number): number {
// --------------------------------------------------------------------------- //
// Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, //
// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés //
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. //
// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », //
// une colonne par créneau distinct plutôt qu'un axe temporel continu.) //
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. Même axe //
// pour la « Vue par bénévole » (`VolunteerViewPage.tsx`). //
// --------------------------------------------------------------------------- //
const TIME_GRID_SLOT_MINUTES = 30;
+248 -113
View File
@@ -1,11 +1,21 @@
import { useMemo, useState } from 'react';
import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
import type { VolunteerRead } from '@/api/schema';
import { Chip, HelpTip } from '@/components/ui';
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer } from '@/lib/planning';
import { datePart, shortDayLabel, timePart } from '@/lib/time';
import {
buildTimeGrid,
columnWidthPx,
dayBoundaryFromDayParts,
festivalDay,
hoursByVolunteer,
placeInTimeGrid,
type TimeGridDay,
} from '@/lib/planning';
import { clockLabel } from '@/lib/slotTimeline';
import { minutesOfDay, naiveMinutes, shortDayLabel, timePart } from '@/lib/time';
import { useNowMinutes } from '@/lib/useNowMinutes';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
@@ -14,15 +24,47 @@ type Filter = 'all' | 'unassigned';
* total d'heures affectées, croissant ou décroissant. */
type Sort = 'name' | 'hoursAsc' | 'hoursDesc';
const NAME_W = 230;
const TOTAL_W = 84;
const SLOT_MINUTES = 30;
/** Hauteur d'une ligne de poste dans la rangée d'un bénévole : deux postes
* en même temps ⇒ deux lignes, la rangée s'agrandit. */
const LANE_H = 26;
const ROW_PAD = 3;
interface Bar {
assignmentId: string;
positionName: string;
start: number;
end: number;
/** Position horizontale (px) dans la piste des créneaux. */
left: number;
width: number;
lane: number;
/** Chevauche un autre créneau du même bénévole. */
overlap: boolean;
label: string;
}
/**
* Vue par bénévole (COORDINATION) : un vrai axe des temps, comme la grille
* des besoins (une colonne = 30 min, bornée par jour de festival à la plage
* utilisée — `buildTimeGrid`). Chaque affectation s'étire sur toute la
* durée de son créneau, avec le nom du poste ; deux postes tenus en même
* temps s'empilent sur deux lignes (la rangée s'agrandit) et sont signalés.
* Ligne rouge verticale à l'heure actuelle quand elle tombe dans la grille.
*/
export function VolunteerViewPage() {
const volunteers = useVolunteers();
const assignments = useAssignments();
const shifts = useShifts();
const positions = usePositions();
const dayParts = useDayParts();
const now = useNowMinutes();
const [filter, setFilter] = useState<Filter>('all');
const [sort, setSort] = useState<Sort>('name');
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data],
@@ -31,60 +73,85 @@ export function VolunteerViewPage() {
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data],
);
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const lattice = useMemo(
() => buildLattice(shifts.data ?? [], dayBoundary),
[shifts.data, dayBoundary],
);
const hours = useMemo(
() => hoursByVolunteer(assignments.data ?? [], shiftsById),
[assignments.data, shiftsById],
);
// Axe des temps : jours de festival, largeur de tranche par jour et
// décalage (px) de chaque jour dans la piste.
const axis = useMemo(() => {
const days = buildTimeGrid(shifts.data ?? [], dayBoundary);
let offset = 0;
const layout = days.map((day) => {
const slotW = columnWidthPx(day.slotLabels.length);
const entry = { day, slotW, offset, width: slotW * day.slotLabels.length };
offset += entry.width;
return entry;
});
return { days, layout, width: offset };
}, [shifts.data, dayBoundary]);
const rows = useMemo(() => {
const all = assignments.data ?? [];
const byVolunteer = new Map<string, AssignmentRead[]>();
for (const a of all) {
const list = byVolunteer.get(a.volunteer_id) ?? [];
list.push(a);
byVolunteer.set(a.volunteer_id, list);
const byVolunteer = new Map<string, Array<{ id: string; shiftId: string }>>();
for (const a of assignments.data ?? []) {
byVolunteer.set(a.volunteer_id, [...(byVolunteer.get(a.volunteer_id) ?? []), { id: a.id, shiftId: a.shift_id }]);
}
const layoutByDay = new Map(axis.layout.map((l) => [l.day.date, l]));
const built = (volunteers.data ?? [])
.filter((v) => {
const own = byVolunteer.get(v.id) ?? [];
if (filter === 'unassigned') return own.length === 0;
return true;
})
.filter((v) => filter === 'all' || !(byVolunteer.get(v.id)?.length))
.map((v) => {
const own = byVolunteer.get(v.id) ?? [];
const cellByKey = new Map<string, AssignmentRead & { shift: ShiftRead }>();
for (const a of own) {
const shift = shiftsById.get(a.shift_id);
if (shift)
cellByKey.set(
`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`,
{ ...a, shift },
);
const items = (byVolunteer.get(v.id) ?? []).flatMap(({ id, shiftId }) => {
const shift = shiftsById.get(shiftId);
return shift ? [{ id, shift, starts_at: shift.starts_at, ends_at: shift.ends_at }] : [];
});
const bars: Bar[] = [];
for (const [day, placements] of placeInTimeGrid(axis.days, items, dayBoundary)) {
const l = layoutByDay.get(day);
if (!l) continue;
for (const p of placements) {
const { shift } = p.item;
bars.push({
assignmentId: p.item.id,
positionName: positionsById.get(shift.position_id)?.name ?? '?',
start: naiveMinutes(shift.starts_at),
end: naiveMinutes(shift.ends_at),
left: l.offset + p.columnStart * l.slotW,
width: p.columnSpan * l.slotW,
lane: 0,
overlap: false,
label: `${timePart(shift.starts_at)}–${timePart(shift.ends_at)}`,
});
}
}
// Lignes : un créneau prend la première ligne libre à son début.
bars.sort((a, b) => a.start - b.start || a.end - b.end);
const laneEnds: number[] = [];
for (const bar of bars) {
let lane = laneEnds.findIndex((end) => end <= bar.start);
if (lane === -1) lane = laneEnds.length;
laneEnds[lane] = bar.end;
bar.lane = lane;
bar.overlap = bars.some((o) => o !== bar && o.start < bar.end && bar.start < o.end);
}
return {
volunteer: v,
cellByKey,
bars,
lanes: Math.max(1, laneEnds.length),
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
empty: own.length === 0,
empty: items.length === 0,
};
});
if (sort === 'name') return built;
const direction = sort === 'hoursAsc' ? 1 : -1;
// `sort` est stable : à total égal, l'ordre par nom est conservé.
return built.sort((a, b) => direction * (a.total - b.total));
}, [volunteers.data, assignments.data, shiftsById, hours, filter, sort]);
}, [volunteers.data, assignments.data, shiftsById, positionsById, hours, axis, dayBoundary, filter, sort]);
if (
volunteers.isLoading ||
shifts.isLoading ||
assignments.isLoading ||
positions.isLoading
)
// Position (px) de l'heure actuelle dans la piste, `null` hors grille.
const nowX = useMemo(() => nowPosition(axis.layout, now, dayBoundary), [axis.layout, now, dayBoundary]);
if (volunteers.isLoading || shifts.isLoading || assignments.isLoading || positions.isLoading)
return <Loading />;
const error = volunteers.error ?? shifts.error ?? assignments.error ?? positions.error;
if (error) return <ErrorNote error={error} />;
@@ -97,9 +164,6 @@ export function VolunteerViewPage() {
);
}
const gridTemplateColumns = `230px repeat(${lattice.columns.length}, 62px) 84px`;
const abbr = (positionId: string) => (positionsById.get(positionId)?.name ?? positionId).slice(0, 4).toUpperCase();
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
@@ -124,9 +188,11 @@ export function VolunteerViewPage() {
{t('volunteers.sort.hoursDesc')}
</Chip>
</div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
<div className="ml-auto flex flex-wrap items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
<LegendItem swatch="bg-blue border-ink">{t('volunteers.legend.assigned')}</LegendItem>
<LegendItem swatch="bg-yellow border-ink">{t('volunteers.legend.overlap')}</LegendItem>
<LegendItem swatch="bg-orange border-orange !w-[3px]">{t('volunteers.legend.now')}</LegendItem>
</div>
</div>
@@ -134,62 +200,99 @@ export function VolunteerViewPage() {
{rows.length === 0 ? (
<EmptyNote>{t('volunteers.empty')}</EmptyNote>
) : (
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
<div className="sticky left-0 top-0 z-[7] flex items-center gap-[6px] border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('volunteers.count', { count: rows.length })}
<HelpTip text={t('volunteers.footnote')} side="bottom" />
</div>
{lattice.days.map((day) => (
<div
key={day.date}
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
style={{ gridColumn: `span ${day.columns.length}` }}
>
<span className="font-display text-[12px] font-black">
{shortDayLabel(`${day.date}T12:00`)}
</span>
<div className="w-max border-b-[3px] border-ink">
{/* En-tête : jours, puis tranches de 30 min. */}
<div className="sticky top-0 z-[7] flex h-[27px]">
<div
className="sticky left-0 z-[8] flex flex-none items-center gap-[6px] border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 font-display text-[10px] font-extrabold uppercase tracking-wider text-cream"
style={{ width: NAME_W }}
>
{t('volunteers.count', { count: rows.length })}
<HelpTip text={t('volunteers.footnote')} side="bottom" />
</div>
{axis.layout.map(({ day, width }) => (
<div
key={day.date}
className="flex flex-none items-center border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] text-cream"
style={{ width }}
>
<span className="font-display text-[12px] font-black">{shortDayLabel(`${day.date}T12:00`)}</span>
</div>
))}
<button
type="button"
onClick={() => setSort(sort === 'hoursDesc' ? 'hoursAsc' : 'hoursDesc')}
title={t('volunteers.sort.toggle')}
className="sticky right-0 z-[8] flex flex-none cursor-pointer items-center gap-1 border-b-2 border-l-[3px] border-ink bg-yellow px-2 font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-yellow-soft"
style={{ width: TOTAL_W }}
>
{t('volunteers.totalHours')}
{sort === 'hoursAsc' ? ' ↑' : sort === 'hoursDesc' ? ' ↓' : null}
</button>
</div>
))}
<button
type="button"
onClick={() => setSort(sort === 'hoursDesc' ? 'hoursAsc' : 'hoursDesc')}
title={t('volunteers.sort.toggle')}
className="sticky right-0 top-0 z-[7] flex cursor-pointer items-center gap-1 border-b-2 border-l-[3px] border-ink bg-yellow px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-yellow-soft"
>
{t('volunteers.totalHours')}
{sort === 'hoursAsc' ? ' ↑' : sort === 'hoursDesc' ? ' ↓' : null}
</button>
<div className="sticky left-0 top-[27px] z-[7] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px]">
{t('volunteers.name')}
</div>
{lattice.columns.map((column) => (
<div
key={column.key}
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
>
{column.time}
<div className="sticky top-[27px] z-[6] flex">
<div
className="sticky left-0 z-[8] flex flex-none items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px]"
style={{ width: NAME_W }}
>
{t('volunteers.name')}
</div>
<div className="relative flex flex-none border-b-[3px] border-ink bg-sand" style={{ width: axis.width }}>
{axis.layout.map(({ day, slotW }) =>
day.slotLabels.map((label, i) => (
<div
key={`${day.date}-${i}`}
className={cx(
'flex-none overflow-hidden whitespace-nowrap border-r border-ink py-[5px] text-center font-mono text-[9.5px]',
i === day.slotLabels.length - 1 && 'border-r-[3px]',
)}
style={{ width: slotW }}
>
{label}
</div>
)),
)}
{nowX !== null ? (
<span
className="absolute top-1/2 z-[1] -translate-x-1/2 -translate-y-1/2 bg-orange px-[5px] py-[2px] font-mono text-[9.5px] font-bold text-white"
style={{ left: nowX }}
>
{clockLabel(now)}
</span>
) : null}
</div>
<div
className="sticky right-0 z-[8] flex flex-none items-center justify-center border-b-[3px] border-l-[3px] border-ink bg-yellow-soft text-center font-mono text-[8.5px]"
style={{ width: TOTAL_W }}
>
{t('volunteers.cumulative')}
</div>
</div>
))}
<div className="sticky right-0 top-[27px] z-[7] flex items-center justify-center border-b-[3px] border-l-[3px] border-ink bg-yellow-soft text-center font-mono text-[8.5px]">
{t('volunteers.cumulative')}
</div>
{rows.map((row) => (
<VolunteerRow
key={row.volunteer.id}
row={row}
columns={lattice.columns}
abbr={abbr}
/>
))}
</div>
{rows.map((row) => (
<VolunteerRow key={row.volunteer.id} row={row} layout={axis.layout} width={axis.width} nowX={nowX} />
))}
</div>
)}
</div>
</div>
);
}
function nowPosition(
layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>,
now: number,
dayBoundary: string,
): number | null {
const iso = new Date(now * 60_000).toISOString().slice(0, 16);
const entry = layout.find((l) => l.day.date === festivalDay(iso, dayBoundary));
if (!entry) return null;
const boundary = minutesOfDay(dayBoundary);
const offset = ((minutesOfDay(timePart(iso)) - boundary + 1440) % 1440) - entry.day.startOffset;
if (offset < 0 || offset > entry.day.slotLabels.length * SLOT_MINUTES) return null;
return entry.offset + (offset / SLOT_MINUTES) * entry.slotW;
}
function LegendItem({ swatch, children }: { swatch: string; children: React.ReactNode }) {
return (
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
@@ -199,25 +302,24 @@ function LegendItem({ swatch, children }: { swatch: string; children: React.Reac
);
}
type Row = {
volunteer: VolunteerRead;
cellByKey: Map<string, AssignmentRead & { shift: ShiftRead }>;
total: number;
empty: boolean;
};
function VolunteerRow({
row,
columns,
abbr,
layout,
width,
nowX,
}: {
row: Row;
columns: { key: string }[];
abbr: (positionId: string) => string;
row: { volunteer: VolunteerRead; bars: Bar[]; lanes: number; total: number; empty: boolean };
layout: Array<{ day: TimeGridDay; slotW: number; offset: number; width: number }>;
width: number;
nowX: number | null;
}) {
const height = row.lanes * LANE_H + ROW_PAD * 2;
return (
<>
<div className="sticky left-0 z-[2] flex h-8 items-center gap-[6px] border-b border-r-[3px] border-ink bg-cream px-2">
<div className="flex" style={{ height }}>
<div
className="sticky left-0 z-[4] flex flex-none items-center gap-[6px] border-b border-r-[3px] border-ink bg-cream px-2"
style={{ width: NAME_W }}
>
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
{row.volunteer.first_name} {row.volunteer.last_name}
</span>
@@ -227,21 +329,54 @@ function VolunteerRow({
</span>
) : null}
</div>
{columns.map((column) => {
const cell = row.cellByKey.get(column.key);
if (!cell) return <div key={column.key} className="h-8 border-b border-r border-ink bg-sand" />;
return (
<div className="relative flex-none border-b border-ink bg-sand" style={{ width }}>
{/* Tranches de 30 min, filet épais entre deux jours. */}
{layout.map((l) => (
<span
key={l.day.date}
className="absolute inset-y-0 border-r-[3px] border-ink"
style={{
left: l.offset,
width: l.width,
backgroundImage: `repeating-linear-gradient(90deg, transparent 0 ${l.slotW - 1}px, rgba(20,17,14,.35) ${l.slotW - 1}px ${l.slotW}px)`,
}}
aria-hidden="true"
/>
))}
{row.bars.map((bar) => (
<div
key={column.key}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold"
key={bar.assignmentId}
title={
bar.overlap
? t('volunteers.bar.overlapTitle', { position: bar.positionName, time: bar.label })
: t('volunteers.bar.title', { position: bar.positionName, time: bar.label })
}
className={cx(
'absolute z-[2] flex items-center overflow-hidden border-2 border-ink px-[5px] font-display text-[10px] font-extrabold leading-none',
bar.overlap ? 'bg-yellow' : 'bg-blue',
)}
style={{
left: bar.left + 1,
width: Math.max(4, bar.width - 2),
top: ROW_PAD + bar.lane * LANE_H,
height: LANE_H - 3,
}}
>
{abbr(cell.shift.position_id)}
<span className="truncate">{bar.positionName}</span>
</div>
);
})}
<div className="sticky right-0 z-[2] flex items-center justify-center border-b border-l-[3px] border-ink bg-paper font-mono text-[13px] font-bold">
))}
{nowX !== null ? (
<span className="absolute inset-y-0 z-[3] w-[2px] -translate-x-1/2 bg-orange" style={{ left: nowX }} aria-hidden="true" />
) : null}
</div>
<div
className="sticky right-0 z-[4] flex flex-none items-center justify-center border-b border-l-[3px] border-ink bg-paper font-mono text-[13px] font-bold"
style={{ width: TOTAL_W }}
>
{row.total}
</div>
</>
</div>
);
}
+6 -16
View File
@@ -16,7 +16,6 @@ import {
import {
FACE_LABEL_KEY,
fmt,
initials,
isVerticallyCentered,
renderField,
SAFE_MARGIN_MM,
@@ -25,8 +24,6 @@ import {
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). */
@@ -301,7 +298,7 @@ function FieldBox({
}}
>
{rendered?.kind === 'photo' || (!volunteer && field.field_type === 'PHOTO') ? (
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} scale={scale} />
<PhotoContent field={field} volunteer={volunteer} photoUrl={photoUrl} />
) : rendered?.kind === 'table' ? (
<TableContent field={field} layout={rendered.layout} scale={scale} />
) : rendered?.kind === 'text' ? (
@@ -450,12 +447,10 @@ 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) {
@@ -469,19 +464,14 @@ function PhotoContent({
/>
);
}
// 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 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,
}}
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 }}
>
{volunteer ? initials(volunteer) : null}
{t('badges.preview.noPhoto')}
</div>
);
}
-4
View File
@@ -258,10 +258,6 @@ export interface BadgeData {
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}`;
}