Le paramétrage des créneaux se fait dans « Règles & édition » : la coordination
définit des créneaux types (DayPart). À la création d'un poste, un créneau à
besoin 0/0 est généré pour chaque jour de l'édition et chaque créneau type ;
les coordinateurs ajustent ensuite min/idéal.
Back-end :
- DayPart CRUD (GET/POST/PATCH/DELETE /editions/{id}/day-parts). Code figé
après création (slugifié depuis le libellé si absent), unique par édition.
- Shift.day_part_id (nullable, SET NULL) + migration 0004.
- create_position génère les créneaux ; POST .../positions/{pid}/generate-shifts
recrée les manquants (idempotent, ne touche pas aux besoins déjà réglés).
- 13 tests (day-parts, génération, extension d'édition, ajout de type).
Front-end :
- api/hooks/mock : dayParts CRUD + positions.generateShifts.
- ConfigRules : panneau « Créneaux types » (libellé + horaires éditables,
ajout, suppression).
- ConfigGrid : bouton « Générer les créneaux » sur l'en-tête du poste.
- GrillePage : case « à définir » (gris, glyphe ·) quand min=idéal=0, avec
entrée de légende.
155 tests back OK ; typecheck + lint + build + smoke front OK.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
import { useMemo, useState } from 'react';
|
||
|
||
import {
|
||
useCreatePosition,
|
||
useCreateShift,
|
||
useDeletePosition,
|
||
useDeleteShift,
|
||
useEdition,
|
||
useGenerateShifts,
|
||
usePositions,
|
||
useShifts,
|
||
useUpdatePosition,
|
||
useUpdateShift,
|
||
} from '@/api/hooks';
|
||
import type { PositionCategory, PositionRead, ShiftRead } from '@/api/schema';
|
||
import { ErrorNote, Loading } from '@/components/state';
|
||
import { Btn } from '@/components/ui';
|
||
import { categoryLabel } from '@/lib/planning';
|
||
import { hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
|
||
import { t } from '@/i18n';
|
||
import { cx } from '@/lib/cx';
|
||
|
||
const CATEGORIES: PositionCategory[] = [
|
||
'BAR',
|
||
'TICKETING',
|
||
'WELCOME',
|
||
'PARKING',
|
||
'CLEANING',
|
||
'CATERING',
|
||
'SETUP',
|
||
'STAGE',
|
||
'OTHER',
|
||
];
|
||
|
||
function enumerateDays(start: string, end: string): string[] {
|
||
const out: string[] = [];
|
||
const cursor = new Date(`${start}T00:00:00Z`);
|
||
const last = new Date(`${end}T00:00:00Z`);
|
||
while (cursor <= last) {
|
||
out.push(cursor.toISOString().slice(0, 10));
|
||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function ConfigGrid() {
|
||
const editionQuery = useEdition();
|
||
const positions = usePositions();
|
||
const allShifts = useShifts();
|
||
const createPosition = useCreatePosition();
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
|
||
const position =
|
||
(positions.data ?? []).find((p) => p.id === selectedId) ?? (positions.data ?? [])[0];
|
||
const positionShifts = useShifts(position?.id);
|
||
const updateShift = useUpdateShift();
|
||
const deleteShift = useDeleteShift();
|
||
const createShift = useCreateShift();
|
||
|
||
const totals = useMemo(() => {
|
||
let min = 0;
|
||
let ideal = 0;
|
||
for (const s of allShifts.data ?? []) {
|
||
const h = hoursBetween(s.starts_at, s.ends_at);
|
||
min += s.min_staff * h;
|
||
ideal += s.ideal_staff * h;
|
||
}
|
||
return { min: Math.round(min), ideal: Math.round(ideal), count: (allShifts.data ?? []).length };
|
||
}, [allShifts.data]);
|
||
|
||
const addPosition = () =>
|
||
createPosition.mutate(
|
||
{ name: t('config.grid.newPositionName'), category: 'OTHER' },
|
||
{ onSuccess: (created) => setSelectedId(created.id) },
|
||
);
|
||
|
||
if (positions.isLoading || editionQuery.isLoading) return <Loading />;
|
||
if (positions.error) return <ErrorNote error={positions.error} />;
|
||
if (editionQuery.error) return <ErrorNote error={editionQuery.error} />;
|
||
|
||
if (!position) {
|
||
return (
|
||
<div className="flex flex-col gap-3 p-6">
|
||
<Btn disabled={createPosition.isPending} onClick={addPosition}>
|
||
{createPosition.isPending ? t('app.loading') : t('config.grid.addPosition')}
|
||
</Btn>
|
||
{createPosition.isError ? <ErrorNote error={createPosition.error} /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const shiftsByDay = groupByDay(positionShifts.data ?? []);
|
||
const days = editionQuery.data
|
||
? enumerateDays(editionQuery.data.start_date, editionQuery.data.end_date)
|
||
: [...shiftsByDay.keys()];
|
||
|
||
return (
|
||
<div className="flex min-h-0 flex-1 items-stretch">
|
||
<div className="max-h-[calc(100vh-118px)] w-[250px] flex-none overflow-auto border-r-[3px] border-ink">
|
||
<div className="sticky top-0 z-[2] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
|
||
{t('config.grid.positionsHeader', { count: totals.count })}
|
||
</div>
|
||
{(positions.data ?? []).map((entry) => {
|
||
const active = entry.id === position.id;
|
||
const n = (allShifts.data ?? []).filter((s) => s.position_id === entry.id).length;
|
||
return (
|
||
<button
|
||
key={entry.id}
|
||
type="button"
|
||
onClick={() => setSelectedId(entry.id)}
|
||
className={cx(
|
||
'block w-full cursor-pointer border-b border-ink px-[9px] py-[7px] text-left',
|
||
active ? 'border-l-[6px] bg-yellow' : 'border-l-[6px] border-l-transparent bg-paper hover:bg-sand',
|
||
)}
|
||
>
|
||
<div className="truncate font-display text-[12.5px] font-bold leading-tight">
|
||
{entry.name}
|
||
</div>
|
||
<div className="font-mono text-[9px] opacity-60">
|
||
{categoryLabel(entry.category)} · {t('config.grid.slotsCount', { count: n })}
|
||
</div>
|
||
</button>
|
||
);
|
||
})}
|
||
<button
|
||
type="button"
|
||
disabled={createPosition.isPending}
|
||
onClick={addPosition}
|
||
className="w-full cursor-pointer border-t-[3px] border-ink bg-cream p-3 font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:opacity-50"
|
||
>
|
||
{createPosition.isPending ? t('app.loading') : t('config.grid.addPosition')}
|
||
</button>
|
||
{createPosition.isError ? <ErrorNote error={createPosition.error} /> : null}
|
||
</div>
|
||
|
||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
|
||
<PositionHeader
|
||
key={position.id}
|
||
position={position}
|
||
totals={totals}
|
||
/>
|
||
|
||
{positionShifts.isLoading ? <Loading /> : null}
|
||
{createShift.isError ? <ErrorNote error={createShift.error} /> : null}
|
||
{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}
|
||
<button
|
||
type="button"
|
||
disabled={createShift.isPending}
|
||
onClick={() =>
|
||
createShift.mutate({
|
||
position_id: position.id,
|
||
starts_at: `${day}T12:00:00`,
|
||
ends_at: `${day}T17:00:00`,
|
||
min_staff: 2,
|
||
ideal_staff: 3,
|
||
})
|
||
}
|
||
className="w-full cursor-pointer bg-cream p-2 font-display text-[10px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:opacity-50"
|
||
>
|
||
{t('config.grid.addShift')}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="mt-[14px] max-w-[760px] border-[3px] border-dashed border-ink p-[11px] font-mono text-[10.5px] leading-relaxed opacity-75">
|
||
{t('config.grid.note')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PositionHeader({
|
||
position,
|
||
totals,
|
||
}: {
|
||
position: PositionRead;
|
||
totals: { min: number; ideal: number };
|
||
}) {
|
||
const updatePosition = useUpdatePosition();
|
||
const deletePosition = useDeletePosition();
|
||
const generate = useGenerateShifts();
|
||
const [name, setName] = useState(position.name);
|
||
|
||
const commitName = () => {
|
||
const trimmed = name.trim();
|
||
if (trimmed && trimmed !== position.name) {
|
||
updatePosition.mutate({ id: position.id, body: { name: trimmed } });
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
|
||
<div className="flex flex-col gap-1">
|
||
<input
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
onBlur={commitName}
|
||
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
||
className="border-2 border-ink bg-yellow px-2 py-1 font-display text-[22px] font-black tracking-tight"
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
<select
|
||
value={position.category}
|
||
onChange={(e) =>
|
||
updatePosition.mutate({
|
||
id: position.id,
|
||
body: { category: e.target.value as PositionCategory },
|
||
})
|
||
}
|
||
className="border-2 border-ink bg-paper px-2 py-1 font-mono text-[11px]"
|
||
>
|
||
{CATEGORIES.map((code) => (
|
||
<option key={code} value={code}>
|
||
{categoryLabel(code)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<button
|
||
type="button"
|
||
disabled={generate.isPending}
|
||
onClick={() => generate.mutate(position.id)}
|
||
className="cursor-pointer border-2 border-ink bg-cream px-2 py-1 font-display text-[10px] font-extrabold uppercase hover:bg-yellow disabled:opacity-50"
|
||
>
|
||
{t('config.grid.generateShifts')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
if (window.confirm(t('config.grid.deletePositionConfirm'))) {
|
||
deletePosition.mutate(position.id);
|
||
}
|
||
}}
|
||
className="cursor-pointer border-2 border-ink bg-cream px-2 py-1 font-display text-[10px] font-extrabold uppercase hover:bg-orange"
|
||
>
|
||
{t('config.grid.deletePosition')}
|
||
</button>
|
||
</div>
|
||
{generate.isSuccess ? (
|
||
<div className="font-mono text-[10px] text-green">
|
||
{t('config.grid.generated', { count: generate.data.created })}
|
||
</div>
|
||
) : null}
|
||
{updatePosition.isError ? <ErrorNote error={updatePosition.error} /> : null}
|
||
{generate.isError ? <ErrorNote error={generate.error} /> : null}
|
||
</div>
|
||
<div className="ml-auto flex gap-2">
|
||
<MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} />
|
||
<MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent />
|
||
</div>
|
||
</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))) {
|
||
const day = s.starts_at.slice(0, 10);
|
||
map.set(day, [...(map.get(day) ?? []), s]);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function MiniStat({ value, label, accent = false }: { value: string; label: string; accent?: boolean }) {
|
||
return (
|
||
<div
|
||
className={cx(
|
||
'border-[3px] border-ink px-[11px] py-2 shadow-brutal-md',
|
||
accent ? 'bg-yellow' : 'bg-paper',
|
||
)}
|
||
>
|
||
<div className="font-mono text-[20px] font-bold">{value}</div>
|
||
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||
{label}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Stepper({
|
||
label,
|
||
value,
|
||
accent = false,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: number;
|
||
accent?: boolean;
|
||
onChange: (delta: number) => void;
|
||
}) {
|
||
return (
|
||
<span className="flex items-center gap-1">
|
||
<span className="font-display text-[8.5px] font-extrabold uppercase tracking-wide opacity-60">
|
||
{label}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(-1)}
|
||
className={cx(
|
||
'h-[22px] w-[22px] cursor-pointer border-2 border-ink font-display text-[12px] font-extrabold hover:bg-orange',
|
||
accent ? 'bg-yellow-soft' : 'bg-cream',
|
||
)}
|
||
>
|
||
–
|
||
</button>
|
||
<span className="min-w-[18px] text-center font-mono text-[14px] font-bold">{value}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(1)}
|
||
className={cx(
|
||
'h-[22px] w-[22px] cursor-pointer border-2 border-ink font-display text-[12px] font-extrabold hover:bg-green hover:text-white',
|
||
accent ? 'bg-yellow-soft' : 'bg-cream',
|
||
)}
|
||
>
|
||
+
|
||
</button>
|
||
</span>
|
||
);
|
||
}
|