Fiches de poste rapides, badges sans initiales, vue par bénévole sur l'axe des temps
É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:
co-authored by
Claude Opus 5.5
parent
c57c70569c
commit
c6ea7378c7
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user