- components/state : composant EmptyNote (encadré pointillés). - GrillePage : si aucun créneau, message au lieu de la grille vide. - VolunteerViewPage : si aucun bénévole, message ; si un filtre ne renvoie rien, message à la place du tableau (avant, l'en-tête collant restait affiché). typecheck + lint + build + smoke OK. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
|
|
import { useMatrix } from '@/api/hooks';
|
|
import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
|
|
import { Chip } from '@/components/ui';
|
|
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
|
import { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
|
|
import { categoryLabel, latticeFromMatrix, type LatticeColumn } from '@/lib/planning';
|
|
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
|
import { t } from '@/i18n';
|
|
import { cx } from '@/lib/cx';
|
|
|
|
export function GrillePage() {
|
|
const matrix = useMatrix();
|
|
const [dayFilter, setDayFilter] = useState<'all' | string>('all');
|
|
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
|
|
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
|
|
|
|
const lattice = useMemo(
|
|
() => (matrix.data ? latticeFromMatrix(matrix.data) : { days: [], columns: [] }),
|
|
[matrix.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' ? lattice.days : lattice.days.filter((d) => d.date === dayFilter);
|
|
const shownColumns = shownDays.flatMap((d) => d.columns);
|
|
const shownPositions =
|
|
categoryFilter === 'all'
|
|
? matrix.data.positions
|
|
: matrix.data.positions.filter((p) => p.category === categoryFilter);
|
|
|
|
const gridTemplateColumns = `230px repeat(${shownColumns.length}, 66px)`;
|
|
|
|
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;
|
|
|
|
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>
|
|
{lattice.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="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-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 className="sticky left-0 top-0 z-[6] flex items-center 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 })}
|
|
</div>
|
|
{shownDays.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 tracking-tight">
|
|
{shortDayLabel(`${day.date}T12:00`)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
|
|
<div 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>
|
|
{shownColumns.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>
|
|
))}
|
|
|
|
{shownPositions.map((position) => (
|
|
<PositionRow
|
|
key={position.id}
|
|
position={position}
|
|
columns={shownColumns}
|
|
selectedShiftId={selectedShiftId}
|
|
onSelect={setSelectedShiftId}
|
|
/>
|
|
))}
|
|
</div>
|
|
<div className="px-[14px] py-[10px] font-mono text-[9.5px] leading-relaxed opacity-60">
|
|
{t('grille.footnote')}
|
|
</div>
|
|
</div>
|
|
|
|
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
|
{selected ? (
|
|
<AssignmentPanel
|
|
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,
|
|
columns,
|
|
selectedShiftId,
|
|
onSelect,
|
|
}: {
|
|
position: MatrixPosition;
|
|
columns: LatticeColumn[];
|
|
selectedShiftId: string | null;
|
|
onSelect: (id: string) => void;
|
|
}) {
|
|
const byColumn = new Map<string, MatrixShift>();
|
|
for (const shift of position.shifts) {
|
|
byColumn.set(`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}`, shift);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<div 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>
|
|
{columns.map((column) => {
|
|
const shift = byColumn.get(column.key);
|
|
if (!shift) {
|
|
return <div key={column.key} className="h-9 border-b border-r border-ink bg-hatch-sand" />;
|
|
}
|
|
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 selected = selectedShiftId === shift.shift_id;
|
|
return (
|
|
<div
|
|
key={column.key}
|
|
onClick={() => onSelect(shift.shift_id)}
|
|
className={cx(
|
|
'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-mauve hover:bg-mauve-hover',
|
|
isInc && 'bg-yellow-soft hover:bg-yellow',
|
|
isOk && 'bg-paper hover:bg-green/10',
|
|
)}
|
|
title={`${assigned} affecté(s) · min ${shift.min_staff} · idéal ${shift.ideal_staff}`}
|
|
>
|
|
<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>
|
|
{selected ? (
|
|
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|