Créneaux à la main + grille des besoins en axe des temps (30 min)
Un poste ne génère plus de créneaux à sa création : la coordination crée elle-même ceux dont elle a besoin, avec ses propres horaires de début et de fin (ConfigGrid.tsx). La génération depuis les créneaux types reste disponible en option (bouton « Générer les créneaux »). La grille des besoins devient un vrai axe des temps : une colonne = 30 min, bornée par jour à la plage réellement utilisée, avec les cases fusionnées pour correspondre aux horaires de chaque créneau (buildTimeGrid / placeInTimeGrid). La « Vue bénévoles » garde son ancien modèle (une colonne par créneau distinct), plus adapté à son tableau bénévole × créneau. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f1d88302a4
commit
15109acfe5
@@ -14,13 +14,14 @@ import { Chip, HelpTip } from '@/components/ui';
|
||||
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
|
||||
import { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
|
||||
import {
|
||||
buildTimeGrid,
|
||||
categoryLabel,
|
||||
columnWidthPx,
|
||||
dayBoundaryFromDayParts,
|
||||
latticeFromMatrix,
|
||||
type LatticeColumn,
|
||||
placeInTimeGrid,
|
||||
type TimeGridDay,
|
||||
} from '@/lib/planning';
|
||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||
import { hourRangeLabel, shortDayLabel } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
@@ -36,8 +37,14 @@ export function GrillePage() {
|
||||
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
|
||||
|
||||
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||
const lattice = useMemo(
|
||||
() => (matrix.data ? latticeFromMatrix(matrix.data, dayBoundary) : { days: [], columns: [] }),
|
||||
const days = useMemo(
|
||||
() =>
|
||||
matrix.data
|
||||
? buildTimeGrid(
|
||||
matrix.data.positions.flatMap((p) => p.shifts),
|
||||
dayBoundary,
|
||||
)
|
||||
: [],
|
||||
[matrix.data, dayBoundary],
|
||||
);
|
||||
|
||||
@@ -73,8 +80,7 @@ export function GrillePage() {
|
||||
}
|
||||
|
||||
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 shownDays = dayFilter === 'all' ? days : days.filter((d) => d.date === dayFilter);
|
||||
const shownPositions =
|
||||
categoryFilter === 'all'
|
||||
? matrix.data.positions
|
||||
@@ -83,8 +89,8 @@ export function GrillePage() {
|
||||
const gridTemplateColumns = [
|
||||
'230px',
|
||||
...shownDays.flatMap((day) => {
|
||||
const width = columnWidthPx(day.columns.length);
|
||||
return day.columns.map(() => `${width}px`);
|
||||
const width = columnWidthPx(day.slotLabels.length);
|
||||
return day.slotLabels.map(() => `${width}px`);
|
||||
}),
|
||||
].join(' ');
|
||||
|
||||
@@ -118,7 +124,7 @@ export function GrillePage() {
|
||||
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
|
||||
{t('filter.all')}
|
||||
</Chip>
|
||||
{lattice.days.map((day) => (
|
||||
{days.map((day) => (
|
||||
<Chip key={day.date} active={dayFilter === day.date} onClick={() => setDayFilter(day.date)}>
|
||||
{shortDayLabel(`${day.date}T12:00`)}
|
||||
</Chip>
|
||||
@@ -208,7 +214,7 @@ export function GrillePage() {
|
||||
<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}` }}
|
||||
style={{ gridColumn: `span ${day.slotLabels.length}` }}
|
||||
>
|
||||
<span className="font-display text-[12px] font-black tracking-tight">
|
||||
{shortDayLabel(`${day.date}T12:00`)}
|
||||
@@ -219,20 +225,23 @@ export function GrillePage() {
|
||||
<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] 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]"
|
||||
>
|
||||
{column.label}
|
||||
</div>
|
||||
))}
|
||||
{shownDays.map((day) =>
|
||||
day.slotLabels.map((label, i) => (
|
||||
<div
|
||||
key={`${day.date}|${i}`}
|
||||
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) => (
|
||||
<PositionRow
|
||||
key={position.id}
|
||||
position={position}
|
||||
columns={shownColumns}
|
||||
days={shownDays}
|
||||
dayBoundary={dayBoundary}
|
||||
selectedShiftId={selectedShiftId}
|
||||
onSelect={setSelectedShiftId}
|
||||
volunteerNamesByShift={volunteerNamesByShift}
|
||||
@@ -295,24 +304,20 @@ function LegendItem({
|
||||
|
||||
function PositionRow({
|
||||
position,
|
||||
columns,
|
||||
days,
|
||||
dayBoundary,
|
||||
selectedShiftId,
|
||||
onSelect,
|
||||
volunteerNamesByShift,
|
||||
}: {
|
||||
position: MatrixPosition;
|
||||
columns: LatticeColumn[];
|
||||
days: TimeGridDay[];
|
||||
dayBoundary: string;
|
||||
selectedShiftId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
volunteerNamesByShift: Map<string, string[]>;
|
||||
}) {
|
||||
const byColumn = new Map<string, MatrixShift>();
|
||||
for (const shift of position.shifts) {
|
||||
byColumn.set(
|
||||
`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`,
|
||||
shift,
|
||||
);
|
||||
}
|
||||
const placements = placeInTimeGrid(days, position.shifts, dayBoundary);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -322,66 +327,92 @@ function PositionRow({
|
||||
</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" />;
|
||||
{days.map((day) => {
|
||||
const dayPlacements = placements.get(day.date) ?? [];
|
||||
const cells: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
for (const { item: shift, columnStart, columnSpan } of dayPlacements) {
|
||||
for (let i = cursor; i < Math.max(cursor, columnStart); i += 1) {
|
||||
cells.push(<div key={`${day.date}|empty|${i}`} className="h-9 border-b border-r border-ink" />);
|
||||
}
|
||||
cells.push(
|
||||
<ShiftCell
|
||||
key={shift.shift_id}
|
||||
shift={shift}
|
||||
span={columnSpan}
|
||||
selected={selectedShiftId === shift.shift_id}
|
||||
onSelect={onSelect}
|
||||
names={volunteerNamesByShift.get(shift.shift_id) ?? []}
|
||||
/>,
|
||||
);
|
||||
cursor = Math.max(cursor, columnStart + columnSpan);
|
||||
}
|
||||
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;
|
||||
const names = volunteerNamesByShift.get(shift.shift_id) ?? [];
|
||||
return (
|
||||
<div
|
||||
key={column.key}
|
||||
onClick={() => onSelect(shift.shift_id)}
|
||||
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-[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>
|
||||
);
|
||||
for (let i = cursor; i < day.slotLabels.length; i += 1) {
|
||||
cells.push(<div key={`${day.date}|empty|${i}`} className="h-9 border-b border-r border-ink" />);
|
||||
}
|
||||
return cells;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ShiftCell({
|
||||
shift,
|
||||
span,
|
||||
selected,
|
||||
onSelect,
|
||||
names,
|
||||
}: {
|
||||
shift: MatrixShift;
|
||||
span: 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: `span ${span}` }}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user