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(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(); 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 ; if (matrix.error) return ; if (!matrix.data) return null; const hasShifts = matrix.data.positions.some((p) => p.shifts.length > 0); if (!hasShifts) { return (
{t('grille.empty')}
); } 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 (
{t('grille.filter.day')} setDayFilter('all')}> {t('filter.all')} {days.map((day) => ( setDayFilter(day.date)}> {shortDayLabel(`${day.date}T12:00`)} ))}
{t('grille.filter.position')} setCategoryFilter('all')}> {t('filter.all')} {categories.map((category) => ( setCategoryFilter(category)} > {categoryLabel(category).toUpperCase()} ))}
{runSolver.data ? ( {t('grille.solver.result', { count: runSolver.data.suggestions.length })} ) : null} {runSolver.isError ? ( {t('error.generic')} ) : null} {generateSheets.isError ? ( {generateSheets.error instanceof ApiError ? t(generateSheets.error.code) : t('error.generic')} ) : null}
{t('legend.title')} {t('grille.legend.critical')} {t('grille.legend.incomplete')} {t('grille.legend.complete')} {t('grille.legend.overstaffed')} {t('grille.legend.undefined')}
{t('grille.positionsCount', { count: matrix.data.positions.length })}
{shownDays.map((day, di) => (
{shortDayLabel(`${day.date}T12:00`)}
))}
{t('grille.assignedRequired')}
{shownDays.map((day, di) => day.slotLabels.map((label, i) => (
{label}
)), )} {shownPositions.map((position, posIndex) => ( ))}
); } function LegendItem({ swatch, glyph, children, }: { swatch: string; glyph: string; children: React.ReactNode; }) { return ( {glyph} {children} ); } 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; }) { const placements = placeInTimeGrid(days, position.shifts, dayBoundary); return ( <>
{position.name} {position.location}
{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(
, ); } const end = Math.min(Math.max(start + 1, columnStart + columnSpan), day.slotLabels.length); if (start < day.slotLabels.length) { cells.push( , ); } cursor = Math.max(cursor, end); } for (let i = cursor; i < day.slotLabels.length; i += 1) { cells.push(
, ); } 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 (
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', )} > {assigned} /{shift.ideal_staff} {glyph}
{hourRangeLabel(shift.starts_at, shift.ends_at)}
{t('grille.cellPreview.staffing', { assigned, ideal: shift.ideal_staff, min: shift.min_staff, })}
{names.length > 0 ? ( names.map((name, i) =>
{name}
) ) : (
{t('grille.panel.noneAssigned')}
)}
{selected ? ( ) : null}
); }