Files
benevoles/frontend/src/pages/GrillePage.tsx
T
AdamThomasandClaude Sonnet 5 8b9c0ac498 Corrige la grille des besoins : postes manquants au-delà du premier
Chaque cellule de la grille (label de poste, en-têtes, créneaux, cases
vides) recevait sa position via l'ordre d'émission des enfants, en
comptant sur le fait que chaque ligne totalise exactement le nombre de
tranches de 30 min d'un jour pour que l'auto-flow CSS reste aligné. Un
poste dont les créneaux ne tombaient pas exactement sur ce total (deux
créneaux adjacents mais non alignés sur 30 min, courant depuis la
création de créneaux à la main) décalait tous les postes suivants —
leur nom de poste n'apparaissait plus sur la bonne ligne.

Chaque cellule reçoit maintenant sa position de grille explicitement
(gridColumn/gridRow calculés), immunisé contre ce genre de décalage :
l'anomalie d'un poste reste locale à sa propre ligne.

Nouveau test de non-régression (scripts/smoke.tsx:checkGridLayout) :
inspecte les gridRow réellement rendus plutôt que la simple absence de
crash — un décalage CSS n'est pas détectable par un rendu SSR ordinaire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 19:04:38 +02:00

480 lines
18 KiB
TypeScript

import { useMemo, useState } from 'react';
import {
useAssignments,
useDayParts,
useGeneratePositionSheets,
useMatrix,
useRunSolver,
useVolunteers,
} from '@/api/hooks';
import { ApiError } from '@/api';
import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
import { Chip, HelpTip } from '@/components/ui';
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
import {
buildTimeGrid,
categoryLabel,
columnWidthPx,
dayBoundaryFromDayParts,
placeInTimeGrid,
type TimeGridDay,
} from '@/lib/planning';
import { hourRangeLabel, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
export function GrillePage() {
const matrix = useMatrix();
const dayParts = useDayParts();
const runSolver = useRunSolver();
const generateSheets = useGeneratePositionSheets();
const assignments = useAssignments();
const volunteers = useVolunteers();
const [dayFilter, setDayFilter] = useState<'all' | string>('all');
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const days = useMemo(
() =>
matrix.data
? buildTimeGrid(
matrix.data.positions.flatMap((p) => p.shifts),
dayBoundary,
)
: [],
[matrix.data, dayBoundary],
);
// Noms des bénévoles affectés, par créneau — pour l'aperçu au survol d'une
// case (voir `PositionRow`). Pas bloquant : peut rester vide un instant le
// temps que ces requêtes (déjà utilisées par le panneau d'affectation)
// reviennent du cache.
const volunteerNamesByShift = useMemo(() => {
const volunteersById = new Map((volunteers.data ?? []).map((v) => [v.id, v]));
const map = new Map<string, string[]>();
for (const a of assignments.data ?? []) {
const v = volunteersById.get(a.volunteer_id);
if (!v) continue;
const list = map.get(a.shift_id) ?? [];
list.push(`${v.first_name} ${v.last_name}`);
map.set(a.shift_id, list);
}
for (const list of map.values()) list.sort((a, b) => a.localeCompare(b, 'fr'));
return map;
}, [assignments.data, volunteers.data]);
if (matrix.isLoading) return <Loading />;
if (matrix.error) return <ErrorNote error={matrix.error} />;
if (!matrix.data) return null;
const hasShifts = matrix.data.positions.some((p) => p.shifts.length > 0);
if (!hasShifts) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<EmptyNote>{t('grille.empty')}</EmptyNote>
</div>
);
}
const categories = [...new Set(matrix.data.positions.map((p) => p.category))].sort();
const shownDays = dayFilter === 'all' ? days : days.filter((d) => d.date === dayFilter);
const shownPositions =
categoryFilter === 'all'
? matrix.data.positions
: matrix.data.positions.filter((p) => p.category === categoryFilter);
const gridTemplateColumns = [
'230px',
...shownDays.flatMap((day) => {
const width = columnWidthPx(day.slotLabels.length);
return day.slotLabels.map(() => `${width}px`);
}),
].join(' ');
// Ligne de grille (1-based) où commence chaque jour, colonne 1 réservée au
// nom du poste — chaque cellule reçoit sa position explicitement
// (`gridColumn`/`gridRow`) plutôt que de compter sur l'ordre d'émission
// des enfants : un poste dont les créneaux ne totalisent pas exactement le
// nombre de tranches d'un jour (créneaux adjacents non alignés sur 30 min,
// par ex.) ne doit jamais décaler les lignes suivantes.
const dayColumnStarts: number[] = [];
{
let col = 2;
for (const day of shownDays) {
dayColumnStarts.push(col);
col += day.slotLabels.length;
}
}
const selected = selectedShiftId
? matrix.data.positions
.flatMap((p) => p.shifts.map((s) => ({ position: p, shift: s })))
.find((entry) => entry.shift.shift_id === selectedShiftId) ?? null
: null;
const downloadPositionSheets = async () => {
try {
const blob = await generateSheets.mutateAsync({
position_ids: shownPositions.map((p) => p.id),
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'feuilles-de-poste.pdf';
a.click();
URL.revokeObjectURL(url);
} catch {
// L'erreur est déjà exposée via generateSheets.isError / .error.
}
};
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]">
<div className="flex items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
{t('filter.all')}
</Chip>
{days.map((day) => (
<Chip key={day.date} active={dayFilter === day.date} onClick={() => setDayFilter(day.date)}>
{shortDayLabel(`${day.date}T12:00`)}
</Chip>
))}
</div>
<div className="flex flex-wrap items-center gap-[6px]">
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.position')}</span>
<Chip active={categoryFilter === 'all'} onClick={() => setCategoryFilter('all')}>
{t('filter.all')}
</Chip>
{categories.map((category) => (
<Chip
key={category}
active={categoryFilter === category}
onClick={() => setCategoryFilter(category)}
>
{categoryLabel(category).toUpperCase()}
</Chip>
))}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => runSolver.mutate()}
disabled={runSolver.isPending}
className="cursor-pointer border-2 border-ink bg-yellow px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wide shadow-brutal-sm hover:bg-green hover:text-white disabled:opacity-60"
>
{runSolver.isPending ? t('grille.solver.running') : t('grille.solver.run')}
</button>
{runSolver.data ? (
<span className="font-mono text-[9px] opacity-70">
{t('grille.solver.result', { count: runSolver.data.suggestions.length })}
</span>
) : null}
{runSolver.isError ? (
<span className="font-mono text-[9px] font-bold text-orange-dark">
{t('error.generic')}
</span>
) : null}
<button
type="button"
onClick={downloadPositionSheets}
disabled={generateSheets.isPending}
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wide shadow-brutal-sm hover:bg-yellow disabled:opacity-60"
>
{generateSheets.isPending ? t('grille.sheets.generating') : t('grille.sheets.generate')}
</button>
{generateSheets.isError ? (
<span className="font-mono text-[9px] font-bold text-orange-dark">
{generateSheets.error instanceof ApiError
? t(generateSheets.error.code)
: t('error.generic')}
</span>
) : null}
</div>
<div className="ml-auto flex 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-hatch-orange" glyph="▲">
{t('grille.legend.critical')}
</LegendItem>
<LegendItem swatch="bg-yellow-soft" glyph="◧">
{t('grille.legend.incomplete')}
</LegendItem>
<LegendItem swatch="bg-paper" glyph="✓">
{t('grille.legend.complete')}
</LegendItem>
<LegendItem swatch="bg-hatch-mauve" glyph="▼">
{t('grille.legend.overstaffed')}
</LegendItem>
<LegendItem swatch="bg-sand-dark" glyph="·">
{t('grille.legend.undefined')}
</LegendItem>
</div>
</div>
<div className="flex min-h-0 flex-1 items-stretch">
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
<div
style={{ gridColumn: '1 / span 1', gridRow: 1 }}
className="sticky left-0 top-0 z-[6] 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('grille.positionsCount', { count: matrix.data.positions.length })}
<HelpTip text={t('grille.footnote')} side="bottom" />
</div>
{shownDays.map((day, di) => (
<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: `${dayColumnStarts[di]} / span ${day.slotLabels.length}`,
gridRow: 1,
}}
>
<span className="font-display text-[12px] font-black tracking-tight">
{shortDayLabel(`${day.date}T12:00`)}
</span>
</div>
))}
<div
style={{ gridColumn: '1 / span 1', gridRow: 2 }}
className="sticky left-0 top-[27px] z-[6] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60"
>
{t('grille.assignedRequired')}
</div>
{shownDays.map((day, di) =>
day.slotLabels.map((label, i) => (
<div
key={`${day.date}|${i}`}
style={{ gridColumn: `${dayColumnStarts[di] + i} / span 1`, gridRow: 2 }}
className="sticky top-[27px] z-[3] overflow-hidden text-ellipsis whitespace-nowrap border-b-[3px] border-r border-ink bg-sand px-[2px] py-[5px] text-center font-mono text-[9.5px]"
>
{label}
</div>
)),
)}
{shownPositions.map((position, posIndex) => (
<PositionRow
key={position.id}
position={position}
days={shownDays}
dayColumnStarts={dayColumnStarts}
rowIndex={3 + posIndex}
dayBoundary={dayBoundary}
selectedShiftId={selectedShiftId}
onSelect={setSelectedShiftId}
volunteerNamesByShift={volunteerNamesByShift}
/>
))}
</div>
</div>
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
{selected ? (
<AssignmentPanel
key={selected.shift.shift_id}
positionId={selected.position.id}
positionName={selected.position.name}
positionLocation={selected.position.location}
positionCategory={selected.position.category}
shift={selected.shift}
onClose={() => setSelectedShiftId(null)}
/>
) : (
<div className="flex flex-col gap-3 px-[18px] py-[26px]">
<div className="h-11 w-11 border-[3px] border-ink bg-yellow shadow-brutal-md" />
<div className="font-display text-[14px] font-extrabold leading-tight">
{t('grille.panel.empty.title')}
</div>
<div className="font-mono text-[10.5px] leading-relaxed opacity-65">
{t('grille.panel.empty.body')}
</div>
</div>
)}
</aside>
</div>
</div>
);
}
function LegendItem({
swatch,
glyph,
children,
}: {
swatch: string;
glyph: string;
children: React.ReactNode;
}) {
return (
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
<span
className={cx(
'flex h-4 w-[22px] items-center justify-center border-[1.5px] border-ink font-mono text-[9px] font-bold',
swatch,
)}
>
{glyph}
</span>
{children}
</span>
);
}
function PositionRow({
position,
days,
dayColumnStarts,
rowIndex,
dayBoundary,
selectedShiftId,
onSelect,
volunteerNamesByShift,
}: {
position: MatrixPosition;
days: TimeGridDay[];
dayColumnStarts: number[];
rowIndex: number;
dayBoundary: string;
selectedShiftId: string | null;
onSelect: (id: string) => void;
volunteerNamesByShift: Map<string, string[]>;
}) {
const placements = placeInTimeGrid(days, position.shifts, dayBoundary);
return (
<>
<div
style={{ gridColumn: '1 / span 1', gridRow: rowIndex }}
className="sticky left-0 z-[2] flex h-9 flex-col justify-center gap-[1px] border-b border-r-[3px] border-ink bg-cream px-2"
>
<span className="truncate font-display text-[12px] font-bold tracking-tight">
{position.name}
</span>
<span className="truncate font-mono text-[8.5px] opacity-55">{position.location}</span>
</div>
{days.map((day, di) => {
const base = dayColumnStarts[di];
const dayPlacements = placements.get(day.date) ?? [];
const cells: React.ReactNode[] = [];
let cursor = 0;
for (const { item: shift, columnStart, columnSpan } of dayPlacements) {
// `columnStart` peut retomber avant `cursor` : deux créneaux du
// même poste adjacents mais non alignés sur 30 min s'arrondissent
// chacun à la tranche englobante et peuvent se toucher/chevaucher
// légèrement à l'affichage — on raccourcit plutôt que de laisser
// deux cellules revendiquer la même colonne (chaque cellule ayant
// sa position de grille explicite, ce repli reste local à ce
// poste : il ne décale jamais les lignes suivantes).
const start = Math.min(Math.max(cursor, columnStart), day.slotLabels.length);
for (let i = cursor; i < start; i += 1) {
cells.push(
<div
key={`${day.date}|empty|${i}`}
style={{ gridColumn: `${base + i} / span 1`, gridRow: rowIndex }}
className="h-9 border-b border-r border-ink"
/>,
);
}
const end = Math.min(Math.max(start + 1, columnStart + columnSpan), day.slotLabels.length);
if (start < day.slotLabels.length) {
cells.push(
<ShiftCell
key={shift.shift_id}
shift={shift}
gridColumn={`${base + start} / span ${end - start}`}
gridRow={rowIndex}
selected={selectedShiftId === shift.shift_id}
onSelect={onSelect}
names={volunteerNamesByShift.get(shift.shift_id) ?? []}
/>,
);
}
cursor = Math.max(cursor, end);
}
for (let i = cursor; i < day.slotLabels.length; i += 1) {
cells.push(
<div
key={`${day.date}|empty|${i}`}
style={{ gridColumn: `${base + i} / span 1`, gridRow: rowIndex }}
className="h-9 border-b border-r border-ink"
/>,
);
}
return cells;
})}
</>
);
}
function ShiftCell({
shift,
gridColumn,
gridRow,
selected,
onSelect,
names,
}: {
shift: MatrixShift;
gridColumn: string;
gridRow: number;
selected: boolean;
onSelect: (id: string) => void;
names: string[];
}) {
const assigned = shift.assigned;
const undefinedNeed = shift.min_staff === 0 && shift.ideal_staff === 0;
const isCrit = !undefinedNeed && assigned < shift.min_staff;
const isOver = !undefinedNeed && assigned > shift.ideal_staff;
const isInc = !undefinedNeed && !isCrit && !isOver && assigned < shift.ideal_staff;
const isOk = !undefinedNeed && !isCrit && !isOver && !isInc;
const glyph = undefinedNeed ? '·' : isCrit ? '▲' : isOver ? '▼' : isInc ? '◧' : '✓';
return (
<div
onClick={() => onSelect(shift.shift_id)}
style={{ gridColumn, gridRow }}
className={cx(
'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
undefinedNeed && 'bg-sand-dark hover:bg-sand',
isCrit && 'bg-hatch-orange',
isOver && 'bg-hatch-mauve hover:bg-mauve-hover',
isInc && 'bg-yellow-soft hover:bg-yellow',
isOk && 'bg-paper hover:bg-green/10',
)}
>
<span className="font-mono text-[13px] font-bold">{assigned}</span>
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
<span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}>{glyph}</span>
<div className="pointer-events-none absolute left-1/2 top-full z-20 mt-[4px] w-max max-w-[220px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] text-left font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100">
<div className="mb-[1px] font-mono text-[9px] font-bold text-cream opacity-80">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</div>
<div className="mb-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide text-yellow">
{t('grille.cellPreview.staffing', {
assigned,
ideal: shift.ideal_staff,
min: shift.min_staff,
})}
</div>
{names.length > 0 ? (
names.map((name, i) => <div key={i}>{name}</div>)
) : (
<div className="opacity-70">{t('grille.panel.noneAssigned')}</div>
)}
</div>
{selected ? (
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
) : null}
</div>
);
}