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:
AdamThomas
2026-09-22 18:44:20 +02:00
co-authored by Claude Sonnet 5
parent f1d88302a4
commit 15109acfe5
9 changed files with 474 additions and 179 deletions
+53
View File
@@ -18,6 +18,7 @@ import type { AccountRead } from '@/api/schema';
import { queryKeys } from '@/api/hooks';
import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context';
import { routes } from '@/app/router';
import { buildTimeGrid, placeInTimeGrid } from '@/lib/planning';
import { BadgesPage } from '@/pages/BadgesPage';
import { ConfigPage } from '@/pages/ConfigPage';
import { FormBuilderPage } from '@/pages/FormBuilderPage';
@@ -181,6 +182,57 @@ async function checkPositionSheets() {
);
}
/** Créneaux (mode mock) : un poste créé ne génère plus aucun créneau ; la
* création manuelle à horaires libres fonctionne toujours ; « générer les
* créneaux » (créneaux types) reste disponible en option ; un créneau à
* horaires non alignés (19:15–22:45) se place bien dans la grille demi-heure
* (`buildTimeGrid`/`placeInTimeGrid`), arrondi à la tranche englobante. */
async function checkShiftCreation() {
const position = await api.positions.create(e, { name: 'Smoke poste', category: 'OTHER' });
const shiftsAfterCreate = await api.shifts.list(e, { positionId: position.id });
const edition = await api.editions.get(e);
const shift = await api.shifts.create(e, {
position_id: position.id,
starts_at: `${edition.start_date}T19:15:00`,
ends_at: `${edition.start_date}T22:45:00`,
min_staff: 1,
ideal_staff: 2,
});
const generated = await api.positions.generateShifts(e, position.id);
const grid = buildTimeGrid([shift]);
const day = grid[0];
const placement = day ? placeInTimeGrid(grid, [shift]).get(day.date)?.[0] : undefined;
console.log(
JSON.stringify(
{
shiftsAfterPositionCreate: shiftsAfterCreate.length,
manualShiftTimes: `${shift.starts_at}–${shift.ends_at}`,
generateShiftsStillOptIn: generated.created,
gridSlotCount: day?.slotLabels.length,
gridFirstSlot: day?.slotLabels[0],
placementSpan: placement?.columnSpan,
},
null,
2,
),
);
if (shiftsAfterCreate.length !== 0) {
throw new Error('création de poste : ne devrait générer aucun créneau automatiquement');
}
if (generated.created <= 0) {
throw new Error('generate-shifts : devrait rester disponible en option');
}
// 19:15–22:45 (3 h 30) arrondi aux tranches de 30 min englobantes
// (19:00–23:00) = 8 tranches.
if (day?.slotLabels[0] !== '19:00' || placement?.columnSpan !== 8) {
throw new Error('grille demi-heure : placement inattendu pour un créneau non aligné');
}
}
async function prefetchCore(client: QueryClient) {
await Promise.all([
client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }),
@@ -250,6 +302,7 @@ function renderPublic(path: string) {
await checkAuth().catch((err) => fail('checkAuth', err));
await checkPublicForm().catch((err) => fail('checkPublicForm', err));
await checkPositionSheets().catch((err) => fail('checkPositionSheets', err));
await checkShiftCreation().catch((err) => fail('checkShiftCreation', err));
renderPublic('/connexion');
renderPublic('/connexion/coordination');
+2 -1
View File
@@ -1229,7 +1229,8 @@ export function createMockApi(): PlanningApi {
updated_at: now,
};
state.positions.push(position);
generateMissingShifts(position.id);
// Plus de génération automatique : la coordination ajoute les
// créneaux à la main, ou via `generateShifts` (opt-in) ci-dessous.
return delay(position);
},
update: (_edition, id, body) => {
+4 -1
View File
@@ -157,7 +157,10 @@
"config.grid.deletePositionConfirm": "Supprimer ce poste et tous ses créneaux ?",
"config.grid.generateShifts": "GÉNÉRER LES CRÉNEAUX",
"config.grid.generated": "{count} créneau(x) ajouté(s).",
"config.grid.note": "Effectifs, créneaux, nom et catégorie du poste sont enregistrés immédiatement via l'API. Pour ajouter ou retirer des jours, modifiez les dates de l'édition dans l'onglet « Règles & édition ».",
"config.grid.shiftStart": "Début du créneau",
"config.grid.shiftEnd": "Fin du créneau",
"config.grid.addShift": "+ AJOUTER CE CRÉNEAU",
"config.grid.note": "Un poste ne crée aucun créneau à sa création : ajoutez-les à la main (heure de début et de fin) sur chaque jour, ou utilisez « Générer les créneaux » pour repartir des créneaux types de l'édition. Effectifs, créneaux, nom et catégorie du poste sont enregistrés immédiatement via l'API. Pour ajouter ou retirer des jours, modifiez les dates de l'édition dans l'onglet « Règles & édition ».",
"config.vol.importTitle": "IMPORTER DES BÉNÉVOLES",
"config.vol.importHint": "Trois façons d'ajouter des bénévoles : saisie manuelle, import d'un fichier CSV / Excel, ou (à venir) le formulaire d'inscription en ligne.",
"config.vol.profiles": "PROFILS ENREGISTRÉS",
+119 -8
View File
@@ -263,15 +263,126 @@ export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string)
}
/**
* Largeur (px) des colonnes d'une journée, en fonction du nombre de créneaux
* distincts qu'elle compte : peu de créneaux ⇒ colonnes larges plutôt que
* resserrées ; beaucoup de créneaux ⇒ largeur compacte (défilement horizontal).
* Largeur (px) des tranches de 30 min d'une journée, en fonction de leur
* nombre : une journée courte (peu de tranches) reste lisible en large,
* une journée dense (beaucoup de tranches) se resserre jusqu'à un plancher
* (défilement horizontal) plutôt que d'écraser le reste de la grille.
*/
export function columnWidthPx(columnsInDay: number): number {
if (columnsInDay <= 2) return 160;
if (columnsInDay <= 4) return 120;
if (columnsInDay <= 6) return 92;
return 66;
export function columnWidthPx(slotsInDay: number): number {
if (slotsInDay <= 4) return 110;
if (slotsInDay <= 8) return 80;
if (slotsInDay <= 16) return 56;
return 42;
}
// --------------------------------------------------------------------------- //
// Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, //
// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés //
// dans cette grille fixe en fusionnant les cases qu'ils couvrent. //
// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », //
// une colonne par créneau distinct plutôt qu'un axe temporel continu.) //
// --------------------------------------------------------------------------- //
const TIME_GRID_SLOT_MINUTES = 30;
export interface TimeGridDay {
date: string; // jour de festival
/** Étiquette d'horloge (`HH:MM`) du début de chaque tranche de 30 min. */
slotLabels: string[];
/** Minutes depuis `dayBoundary` du début de la 1re tranche (multiple de 30). */
startOffset: number;
}
export interface TimeGridPlacement<T> {
item: T;
columnStart: number; // index de tranche dans le jour (0-based)
columnSpan: number; // nombre de tranches fusionnées (>= 1)
}
const clockLabel = (totalMinutes: number): string => {
const wrapped = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60);
const h = Math.floor(wrapped / 60);
const m = wrapped % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
};
/** Jour de festival (§ 3 CLAUDE.md) d'un datetime naïf, selon `dayBoundary`. */
function festivalDayOf(iso: string, boundaryMinutes: number): string {
const date = datePart(iso);
const clock = minutesOfDay(timePart(iso));
return clock < boundaryMinutes ? addDays(date, -1) : date;
}
/**
* Construit, pour chaque jour de festival couvert par `shifts`, l'axe des
* tranches de 30 min à afficher — bornée à la plage réellement utilisée ce
* jour-là (du début du créneau le plus matinal à la fin du plus tardif,
* arrondi au multiple de 30 inférieur/supérieur), pas les 24 h complètes.
*/
export function buildTimeGrid(
shifts: Array<{ starts_at: string; ends_at: string }>,
dayBoundary = '00:00',
): TimeGridDay[] {
const boundaryMinutes = minutesOfDay(dayBoundary);
const bounds = new Map<string, { min: number; max: number }>();
for (const s of shifts) {
const festivalDay = festivalDayOf(s.starts_at, boundaryMinutes);
const startClock = minutesOfDay(timePart(s.starts_at));
const startOffset = (startClock - boundaryMinutes + 24 * 60) % (24 * 60);
const duration = naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at);
const endOffset = startOffset + duration;
const entry = bounds.get(festivalDay);
if (entry) {
entry.min = Math.min(entry.min, startOffset);
entry.max = Math.max(entry.max, endOffset);
} else {
bounds.set(festivalDay, { min: startOffset, max: endOffset });
}
}
return [...bounds.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, { min, max }]) => {
const startOffset = Math.floor(min / TIME_GRID_SLOT_MINUTES) * TIME_GRID_SLOT_MINUTES;
const endOffset = Math.ceil(max / TIME_GRID_SLOT_MINUTES) * TIME_GRID_SLOT_MINUTES;
const slotCount = Math.max(1, (endOffset - startOffset) / TIME_GRID_SLOT_MINUTES);
const slotLabels = Array.from({ length: slotCount }, (_, i) =>
clockLabel(boundaryMinutes + startOffset + i * TIME_GRID_SLOT_MINUTES),
);
return { date, slotLabels, startOffset };
});
}
/**
* Replace des éléments datés (`starts_at`/`ends_at`) dans la grille de
* `days` (voir `buildTimeGrid`) : tranche de départ + nombre de tranches
* fusionnées, groupés et triés par jour. Suppose une seule ligne par
* appelant (ex. un poste) : deux éléments qui se chevaucheraient dans le
* temps ne sont pas gérés (cas non prévu par la grille des besoins).
*/
export function placeInTimeGrid<T extends { starts_at: string; ends_at: string }>(
days: TimeGridDay[],
items: T[],
dayBoundary = '00:00',
): Map<string, TimeGridPlacement<T>[]> {
const boundaryMinutes = minutesOfDay(dayBoundary);
const byDay = new Map(days.map((d) => [d.date, d]));
const placements = new Map<string, TimeGridPlacement<T>[]>();
for (const item of items) {
const festivalDay = festivalDayOf(item.starts_at, boundaryMinutes);
const day = byDay.get(festivalDay);
if (!day) continue;
const startClock = minutesOfDay(timePart(item.starts_at));
const startOffset = (startClock - boundaryMinutes + 24 * 60) % (24 * 60);
const duration = naiveMinutes(item.ends_at) - naiveMinutes(item.starts_at);
const columnStart = Math.floor((startOffset - day.startOffset) / TIME_GRID_SLOT_MINUTES);
const columnEnd = Math.ceil(
(startOffset + duration - day.startOffset) / TIME_GRID_SLOT_MINUTES,
);
const list = placements.get(festivalDay) ?? [];
list.push({ item, columnStart, columnSpan: Math.max(1, columnEnd - columnStart) });
placements.set(festivalDay, list);
}
for (const list of placements.values()) list.sort((a, b) => a.columnStart - b.columnStart);
return placements;
}
/** Heures cumulées par bénévole. */
+118 -87
View File
@@ -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>
);
}
+117 -55
View File
@@ -2,6 +2,7 @@ import { useMemo, useState } from 'react';
import {
useCreatePosition,
useCreateShift,
useDeletePosition,
useDeleteShift,
useEdition,
@@ -15,7 +16,7 @@ import type { PositionCategory, PositionRead, ShiftRead } from '@/api/schema';
import { ErrorNote, Loading } from '@/components/state';
import { Btn, HelpTip } from '@/components/ui';
import { categoryLabel } from '@/lib/planning';
import { hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
import { addDays, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
@@ -143,60 +144,16 @@ export function ConfigGrid() {
{updateShift.isError ? <ErrorNote error={updateShift.error} /> : null}
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]">
{days.map((day) => {
const shifts = shiftsByDay.get(day) ?? [];
return (
<div key={day} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
{longDayLabel(`${day}T12:00`)}
</div>
{shifts.map((shift) => (
<div
key={shift.id}
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
>
<span className="w-[92px] flex-none font-mono text-[11px]">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</span>
<Stepper
label={t('config.grid.min')}
value={shift.min_staff}
onChange={(delta) =>
updateShift.mutate({
id: shift.id,
body: { min_staff: Math.max(0, shift.min_staff + delta) },
})
}
/>
<Stepper
label={t('config.grid.ideal')}
accent
value={shift.ideal_staff}
onChange={(delta) =>
updateShift.mutate({
id: shift.id,
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
})
}
/>
<button
type="button"
onClick={() => deleteShift.mutate(shift.id)}
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
aria-label={t('action.remove')}
>
✕
</button>
</div>
))}
{shifts.length === 0 ? (
<div className="px-[9px] py-[10px] font-mono text-[10px] opacity-55">
{t('config.grid.emptyDay')}
</div>
) : null}
</div>
);
})}
{days.map((day) => (
<DayCard
key={day}
day={day}
positionId={position.id}
shifts={shiftsByDay.get(day) ?? []}
updateShift={updateShift}
deleteShift={deleteShift}
/>
))}
</div>
</div>
</div>
@@ -285,6 +242,111 @@ function PositionHeader({
);
}
function DayCard({
day,
positionId,
shifts,
updateShift,
deleteShift,
}: {
day: string;
positionId: string;
shifts: ShiftRead[];
updateShift: ReturnType<typeof useUpdateShift>;
deleteShift: ReturnType<typeof useDeleteShift>;
}) {
const createShift = useCreateShift();
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const addShift = () => {
if (!start || !end) return;
const endDay = end <= start ? addDays(day, 1) : day;
createShift.mutate(
{ position_id: positionId, starts_at: `${day}T${start}:00`, ends_at: `${endDay}T${end}:00` },
{ onSuccess: () => { setStart(''); setEnd(''); } },
);
};
return (
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
{longDayLabel(`${day}T12:00`)}
</div>
{shifts.map((shift) => (
<div
key={shift.id}
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
>
<span className="w-[92px] flex-none font-mono text-[11px]">
{hourRangeLabel(shift.starts_at, shift.ends_at)}
</span>
<Stepper
label={t('config.grid.min')}
value={shift.min_staff}
onChange={(delta) =>
updateShift.mutate({
id: shift.id,
body: { min_staff: Math.max(0, shift.min_staff + delta) },
})
}
/>
<Stepper
label={t('config.grid.ideal')}
accent
value={shift.ideal_staff}
onChange={(delta) =>
updateShift.mutate({
id: shift.id,
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
})
}
/>
<button
type="button"
onClick={() => deleteShift.mutate(shift.id)}
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
aria-label={t('action.remove')}
>
✕
</button>
</div>
))}
{shifts.length === 0 ? (
<div className="px-[9px] py-[10px] font-mono text-[10px] opacity-55">
{t('config.grid.emptyDay')}
</div>
) : null}
<div className="flex flex-wrap items-center gap-[6px] border-t-2 border-ink bg-sand px-[9px] py-[7px]">
<input
type="time"
value={start}
onChange={(e) => setStart(e.target.value)}
aria-label={t('config.grid.shiftStart')}
className="border-2 border-ink bg-paper px-[6px] py-[3px] font-mono text-[11px]"
/>
<span className="font-mono text-[11px] opacity-60">→</span>
<input
type="time"
value={end}
onChange={(e) => setEnd(e.target.value)}
aria-label={t('config.grid.shiftEnd')}
className="border-2 border-ink bg-paper px-[6px] py-[3px] font-mono text-[11px]"
/>
<button
type="button"
disabled={!start || !end || createShift.isPending}
onClick={addShift}
className="ml-auto cursor-pointer border-2 border-ink bg-cream px-[8px] py-[4px] font-display text-[9.5px] font-extrabold uppercase hover:bg-yellow disabled:opacity-50"
>
{t('config.grid.addShift')}
</button>
</div>
{createShift.isError ? <ErrorNote error={createShift.error} /> : null}
</div>
);
}
function groupByDay(shifts: ShiftRead[]): Map<string, ShiftRead[]> {
const map = new Map<string, ShiftRead[]>();
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {