É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:
co-authored by
Claude Sonnet 5
parent
9521034207
commit
e0a0c56307
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user