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
@@ -4,6 +4,9 @@ import type {
|
||||
AccountRead,
|
||||
AssignmentRead,
|
||||
AssignmentValidateResult,
|
||||
DayPartCreate,
|
||||
DayPartRead,
|
||||
DayPartUpdate,
|
||||
EditionUpdate,
|
||||
MatrixResponse,
|
||||
PlanningNotificationRequest,
|
||||
@@ -67,6 +70,57 @@ const notFound = (): never => {
|
||||
|
||||
const touch = () => new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||
|
||||
const addDays = (day: string, n: number): string => {
|
||||
const [y, m, d] = day.split('-').map(Number);
|
||||
return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const slugCode = (label: string): string =>
|
||||
label
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[^A-Z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
|
||||
function shiftWindow(day: string, dp: DayPartRead): [string, string] {
|
||||
const endDay = dp.end_time <= dp.start_time ? addDays(day, 1) : day;
|
||||
return [`${day}T${dp.start_time}`, `${endDay}T${dp.end_time}`];
|
||||
}
|
||||
|
||||
function generateMissingShifts(positionId: string): number {
|
||||
const existing = new Set(
|
||||
state.shifts
|
||||
.filter((s) => s.position_id === positionId)
|
||||
.map((s) => `${s.day_part_id}|${s.starts_at.slice(0, 10)}`),
|
||||
);
|
||||
let created = 0;
|
||||
let day = state.edition.start_date;
|
||||
while (day <= state.edition.end_date) {
|
||||
for (const dp of state.dayParts) {
|
||||
if (existing.has(`${dp.id}|${day}`)) continue;
|
||||
const [starts_at, ends_at] = shiftWindow(day, dp);
|
||||
const now = touch();
|
||||
state.shifts.push({
|
||||
id: `s${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
position_id: positionId,
|
||||
day_part_id: dp.id,
|
||||
starts_at,
|
||||
ends_at,
|
||||
min_staff: 0,
|
||||
ideal_staff: 0,
|
||||
required_skill_codes: [],
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
day = addDays(day, 1);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
function shiftById(id: string): ShiftRead {
|
||||
const shift = state.shifts.find((s) => s.id === id);
|
||||
if (!shift) return notFound();
|
||||
@@ -228,6 +282,50 @@ export function createMockApi(): PlanningApi {
|
||||
return delay(state.edition);
|
||||
},
|
||||
},
|
||||
dayParts: {
|
||||
list: () =>
|
||||
delay([...state.dayParts].sort((a, b) => a.order - b.order || a.start_time.localeCompare(b.start_time))),
|
||||
create: (_edition, body: DayPartCreate) => {
|
||||
const code = (body.code ?? slugCode(body.label)).trim();
|
||||
if (!code) throw new ApiError(422, 'day_part.code_required');
|
||||
if (state.dayParts.some((d) => d.code === code)) {
|
||||
throw new ApiError(409, 'day_part.duplicate_code');
|
||||
}
|
||||
const now = touch();
|
||||
const dp: DayPartRead = {
|
||||
id: `dp_${state.seq++}`,
|
||||
edition_id: state.edition.id,
|
||||
code,
|
||||
label: body.label.trim(),
|
||||
start_time: body.start_time,
|
||||
end_time: body.end_time,
|
||||
order: body.order ?? 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
state.dayParts.push(dp);
|
||||
return delay(dp);
|
||||
},
|
||||
update: (_edition, id, body: DayPartUpdate) => {
|
||||
const dp = state.dayParts.find((d) => d.id === id) ?? notFound();
|
||||
Object.assign(dp, {
|
||||
...('label' in body ? { label: body.label } : {}),
|
||||
...('start_time' in body ? { start_time: body.start_time } : {}),
|
||||
...('end_time' in body ? { end_time: body.end_time } : {}),
|
||||
...('order' in body ? { order: body.order } : {}),
|
||||
updated_at: touch(),
|
||||
});
|
||||
return delay(dp);
|
||||
},
|
||||
remove: (_edition, id) => {
|
||||
state.dayParts = state.dayParts.filter((d) => d.id !== id);
|
||||
state.shifts.forEach((s) => {
|
||||
if (s.day_part_id === id) s.day_part_id = null;
|
||||
});
|
||||
return delay(undefined);
|
||||
},
|
||||
},
|
||||
|
||||
volunteers: {
|
||||
list: () =>
|
||||
delay(
|
||||
@@ -301,6 +399,7 @@ export function createMockApi(): PlanningApi {
|
||||
updated_at: now,
|
||||
};
|
||||
state.positions.push(position);
|
||||
generateMissingShifts(position.id);
|
||||
return delay(position);
|
||||
},
|
||||
update: (_edition, id, body) => {
|
||||
@@ -321,6 +420,10 @@ export function createMockApi(): PlanningApi {
|
||||
state.assignments = state.assignments.filter((a) => !shiftIds.has(a.shift_id));
|
||||
return delay(undefined);
|
||||
},
|
||||
generateShifts: (_edition, id) => {
|
||||
if (!state.positions.some((x) => x.id === id)) return notFound();
|
||||
return delay({ created: generateMissingShifts(id) });
|
||||
},
|
||||
},
|
||||
|
||||
shifts: {
|
||||
@@ -343,6 +446,7 @@ export function createMockApi(): PlanningApi {
|
||||
id: `s${state.seq++}`,
|
||||
edition_id: state.positions[0].edition_id,
|
||||
position_id: body.position_id,
|
||||
day_part_id: null,
|
||||
starts_at: body.starts_at,
|
||||
ends_at: body.ends_at,
|
||||
min_staff: body.min_staff ?? 0,
|
||||
|
||||
Reference in New Issue
Block a user