En-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation et fiche bénévole en tiroir par-dessus sous lg: (plein écran en mobile), colonne des postes de la grille rétrécie, liste des bénévoles réduite en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
515 lines
20 KiB
TypeScript
515 lines
20 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';
|
|
import { useTooltipPosition } from '@/lib/useTooltipPosition';
|
|
|
|
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 = [
|
|
// Colonne des postes rétrécie sur petit écran, pour laisser voir au
|
|
// moins quelques tranches horaires à côté.
|
|
'clamp(132px, 36vw, 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-[10px] border-b-2 border-ink bg-sand px-3 py-[9px] sm:gap-[14px] sm:px-4">
|
|
<div className="flex flex-wrap 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 flex-wrap 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="flex flex-wrap items-center gap-x-3 gap-y-1 border-2 border-ink bg-cream px-[9px] py-[5px] lg:ml-auto">
|
|
<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(100dvh-150px)] min-w-0 flex-1 overflow-auto lg:max-h-[calc(100vh-118px)]">
|
|
<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-[20] 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-[12] 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-[21] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px]"
|
|
>
|
|
{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-[11] 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>
|
|
|
|
{/* Sous `lg:`, pas la place d'un panneau à côté de la grille : il
|
|
s'ouvre par-dessus (plein écran sur mobile, tiroir à droite sur
|
|
tablette) seulement quand un créneau est choisi. */}
|
|
{selected ? (
|
|
<div
|
|
aria-hidden
|
|
onClick={() => setSelectedShiftId(null)}
|
|
className="fixed inset-0 z-40 bg-ink/40 lg:hidden"
|
|
/>
|
|
) : null}
|
|
<aside
|
|
className={cx(
|
|
'overflow-auto border-ink bg-paper',
|
|
'lg:static lg:z-auto lg:block lg:max-h-[calc(100vh-118px)] lg:w-[392px] lg:flex-none lg:border-l-[3px]',
|
|
selected
|
|
? 'fixed inset-y-0 right-0 z-50 block w-full sm:w-[420px] sm:border-l-[3px]'
|
|
: 'hidden',
|
|
)}
|
|
>
|
|
{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-[10] 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 ? '◧' : '✓';
|
|
const { anchorRef, tooltipRef, pos, reposition } = useTooltipPosition<
|
|
HTMLDivElement,
|
|
HTMLDivElement
|
|
>('bottom');
|
|
|
|
return (
|
|
<div
|
|
ref={anchorRef}
|
|
onClick={() => onSelect(shift.shift_id)}
|
|
onMouseEnter={reposition}
|
|
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
|
|
ref={tooltipRef}
|
|
style={{ left: pos.left, top: pos.top }}
|
|
// `fixed` (plutôt qu'`absolute` ancré sur la cellule) : la bulle
|
|
// s'affranchit à la fois du cadrage de la zone de défilement de la
|
|
// grille (`overflow-auto`, qui la découpait) et de la pile locale
|
|
// des en-têtes figés (qui la faisait passer dessous, invisible) —
|
|
// voir `useTooltipPosition`. Recalculée à chaque survol.
|
|
className="pointer-events-none fixed z-30 w-max max-w-[220px] 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>
|
|
);
|
|
}
|