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
+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))) {