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
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
Reference in New Issue
Block a user