Édition éditable (dates) + robustesse de l'écran Postes & créneaux

Back-end :
- GET /editions/{id} (tout compte de l'édition) et PATCH /editions/{id}
  (COORDINATION) : name, year, start_date, end_date, retention_days.
  Refus si end_date < start_date (edition.dates_inconsistent). Tests.

Front-end :
- api : EditionRead/Update, editions.get/update (http + mock), hooks
  useEdition / useUpdateEdition.
- ConfigGrid : le bouton « ajouter un poste » sélectionne le poste créé et
  affiche l'état (pending) + les erreurs (ErrorNote) — avant, un échec ou
  même un succès passaient inaperçus. Renommage du poste + choix de la
  catégorie + suppression dans l'en-tête. Une colonne par jour de l'édition
  (même vide) → on peut ajouter un créneau n'importe quel jour.
- ConfigRules : formulaire d'édition (nom + dates) → c'est là qu'on ajoute
  ou retire des jours ; compteur de jours ; retour d'enregistrement.
- i18n mis à jour.

144 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
This commit is contained in:
AdamThomas
2026-09-10 20:02:50 +02:00
co-authored by Claude Sonnet 5
parent 9521034207
commit e0a0c56307
14 changed files with 517 additions and 115 deletions
+199 -98
View File
@@ -3,19 +3,47 @@ import { useMemo, useState } from 'react';
import {
useCreatePosition,
useCreateShift,
useDeletePosition,
useDeleteShift,
useEdition,
usePositions,
useShifts,
useUpdatePosition,
useUpdateShift,
} from '@/api/hooks';
import type { ShiftRead } from '@/api/schema';
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 { datePart, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
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();
@@ -39,23 +67,31 @@ export function ConfigGrid() {
return { min: Math.round(min), ideal: Math.round(ideal), count: (allShifts.data ?? []).length };
}, [allShifts.data]);
if (positions.isLoading) return <Loading />;
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="p-6">
<button
type="button"
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
className="cursor-pointer border-[3px] border-ink bg-yellow px-4 py-3 font-display text-[12px] font-extrabold uppercase shadow-brutal"
>
{t('config.grid.addPosition')}
</button>
<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 byDay = groupByDay(positionShifts.data ?? []);
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">
@@ -87,99 +123,97 @@ export function ConfigGrid() {
})}
<button
type="button"
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
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={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"
>
{t('config.grid.addPosition')}
{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">
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
<div>
<div className="font-display text-[24px] font-black tracking-tight">{position.name}</div>
<div className="font-mono text-[10.5px] opacity-65">
{categoryLabel(position.category)}
{position.location ? ` · ${position.location}` : ''}
</div>
</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>
<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))]">
{byDay.map(([day, shifts]) => (
<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>
{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>
))}
<button
type="button"
onClick={() => {
const start = `${day}T12:00:00`;
const end = `${day}T17:00:00`;
createShift.mutate({
position_id: position.id,
starts_at: start,
ends_at: end,
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"
>
{t('config.grid.addShift')}
</button>
</div>
))}
{byDay.length === 0 ? (
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.grid.noShifts')}
</div>
) : null}
{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">
@@ -190,13 +224,80 @@ export function ConfigGrid() {
);
}
function groupByDay(shifts: ShiftRead[]): Array<[string, ShiftRead[]]> {
function PositionHeader({
position,
totals,
}: {
position: PositionRead;
totals: { min: number; ideal: number };
}) {
const updatePosition = useUpdatePosition();
const deletePosition = useDeletePosition();
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"
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>
{updatePosition.isError ? <ErrorNote error={updatePosition.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 = datePart(s.starts_at);
const day = s.starts_at.slice(0, 10);
map.set(day, [...(map.get(day) ?? []), s]);
}
return [...map.entries()];
return map;
}
function MiniStat({ value, label, accent = false }: { value: string; label: string; accent?: boolean }) {