Files
benevoles/frontend/src/pages/config/ConfigGrid.tsx
T
AdamThomasandClaude Sonnet 5 f58ec00e30 Créneaux types : paramétrage + génération auto des créneaux
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
2026-09-10 20:57:51 +02:00

375 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}