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
@@ -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