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
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
cee581b832
commit
f58ec00e30
@@ -6,6 +6,7 @@ import {
|
||||
useDeletePosition,
|
||||
useDeleteShift,
|
||||
useEdition,
|
||||
useGenerateShifts,
|
||||
usePositions,
|
||||
useShifts,
|
||||
useUpdatePosition,
|
||||
@@ -233,6 +234,7 @@ function PositionHeader({
|
||||
}) {
|
||||
const updatePosition = useUpdatePosition();
|
||||
const deletePosition = useDeletePosition();
|
||||
const generate = useGenerateShifts();
|
||||
const [name, setName] = useState(position.name);
|
||||
|
||||
const commitName = () => {
|
||||
@@ -269,6 +271,14 @@ function PositionHeader({
|
||||
</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={() => {
|
||||
@@ -281,7 +291,13 @@ function PositionHeader({
|
||||
{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')} />
|
||||
|
||||
@@ -2,9 +2,13 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { API_MODE } from '@/api';
|
||||
import {
|
||||
useCreateDayPart,
|
||||
useDayParts,
|
||||
useDeleteDayPart,
|
||||
useEdition,
|
||||
usePositions,
|
||||
useShifts,
|
||||
useUpdateDayPart,
|
||||
useUpdateEdition,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
@@ -12,6 +16,8 @@ import { ErrorNote } from '@/components/state';
|
||||
import { Btn, Panel, PanelHeader } from '@/components/ui';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
const hm = (time: string) => time.slice(0, 5); // "HH:MM:SS" -> "HH:MM"
|
||||
|
||||
const RULE_CODES = [
|
||||
'SHIFT_OVERLAP',
|
||||
'OUTSIDE_AVAILABILITY',
|
||||
@@ -42,6 +48,16 @@ export function ConfigRules() {
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.rules.slotsTitle')}</PanelHeader>
|
||||
<div className="flex flex-col gap-2 p-[13px]">
|
||||
<DayPartsForm />
|
||||
<div className="mt-1 border-t-2 border-ink pt-2 font-mono text-[10px] leading-relaxed opacity-70">
|
||||
{t('config.rules.slotsNote')}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.rules.thresholdsTitle')}</PanelHeader>
|
||||
<div className="flex flex-col gap-2 p-[13px]">
|
||||
@@ -152,6 +168,107 @@ function EditionForm() {
|
||||
);
|
||||
}
|
||||
|
||||
function DayPartsForm() {
|
||||
const dayParts = useDayParts();
|
||||
const create = useCreateDayPart();
|
||||
const update = useUpdateDayPart();
|
||||
const remove = useDeleteDayPart();
|
||||
const [label, setLabel] = useState('');
|
||||
const [start, setStart] = useState('09:00');
|
||||
const [end, setEnd] = useState('13:00');
|
||||
|
||||
if (dayParts.isLoading) return <div className="font-mono text-[10px] opacity-60">…</div>;
|
||||
if (dayParts.error) return <ErrorNote error={dayParts.error} />;
|
||||
|
||||
const rows = [...(dayParts.data ?? [])].sort(
|
||||
(a, b) => a.order - b.order || a.start_time.localeCompare(b.start_time),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
{rows.map((dp) => (
|
||||
<div key={dp.id} className="flex items-center gap-1">
|
||||
<input
|
||||
defaultValue={dp.label}
|
||||
onBlur={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v && v !== dp.label) update.mutate({ id: dp.id, body: { label: v } });
|
||||
}}
|
||||
className="min-w-0 flex-1 border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
defaultValue={hm(dp.start_time)}
|
||||
onBlur={(e) =>
|
||||
e.target.value &&
|
||||
update.mutate({ id: dp.id, body: { start_time: `${e.target.value}:00` } })
|
||||
}
|
||||
className="border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
defaultValue={hm(dp.end_time)}
|
||||
onBlur={(e) =>
|
||||
e.target.value &&
|
||||
update.mutate({ id: dp.id, body: { end_time: `${e.target.value}:00` } })
|
||||
}
|
||||
className="border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove.mutate(dp.id)}
|
||||
aria-label={t('action.remove')}
|
||||
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mt-1 flex items-center gap-1 border-t-2 border-ink pt-2">
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('config.rules.slotLabel')}
|
||||
className="min-w-0 flex-1 border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className="border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={end}
|
||||
onChange={(e) => setEnd(e.target.value)}
|
||||
className="border-2 border-ink bg-paper px-1 py-1 font-mono text-[11px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!label.trim() || create.isPending}
|
||||
onClick={() =>
|
||||
create.mutate(
|
||||
{
|
||||
label: label.trim(),
|
||||
start_time: `${start}:00`,
|
||||
end_time: `${end}:00`,
|
||||
order: rows.length,
|
||||
},
|
||||
{ onSuccess: () => setLabel('') },
|
||||
)
|
||||
}
|
||||
className="h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
|
||||
>
|
||||
{t('config.rules.slotAdd')}
|
||||
</button>
|
||||
</div>
|
||||
{create.isError ? <ErrorNote error={create.error} /> : null}
|
||||
{update.isError ? <ErrorNote error={update.error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
|
||||
Reference in New Issue
Block a user