diff --git a/src/components/planning/TeamSection.tsx b/src/components/planning/TeamSection.tsx index c5e7894..0170e39 100644 --- a/src/components/planning/TeamSection.tsx +++ b/src/components/planning/TeamSection.tsx @@ -7,6 +7,7 @@ import { WeekGrid } from '@/components/planning/WeekGrid'; import { Badge } from '@/components/ui/Badge'; import { Button } from '@/components/ui/Button'; import { PersistentForm } from '@/components/ui/PersistentForm'; +import { cx } from '@/lib/cx'; import { formatMinutes } from '@/domain/counters/week'; import { displayName, @@ -205,6 +206,8 @@ export function TeamSection({ rows={section.rows} localDate={dates[target.dayIndex] ?? ''} dayLabel={days[target.dayIndex] ?? ''} + dates={dates} + days={days} target={target} onClose={() => setTarget(null)} /> @@ -350,6 +353,8 @@ function ShiftComposer({ rows, localDate, dayLabel, + dates, + days, target, onClose, }: { @@ -359,6 +364,8 @@ function ShiftComposer({ rows: BoardRow[]; localDate: string; dayLabel: string; + dates: string[]; + days: string[]; target: Target; onClose: () => void; }) { @@ -450,20 +457,51 @@ function ShiftComposer({ /> + + + + + {/* L'étiquette est modifiable à la correction aussi : posée de travers, + elle se corrigeait en supprimant le créneau pour le refaire — ce qui + perdait sa validation et son historique. */} + + + + + + + + {editing ? null : ( - - - + )}
@@ -484,6 +522,67 @@ function ShiftComposer({ ); } +/** + * Répétition du créneau sur plusieurs jours. + * + * Le jour ouvert est coché et **verrouillé** : on ne crée pas « ailleurs qu'ici + * » depuis la case d'un jour donné, et le décocher laisserait un formulaire qui + * ne produit rien tout en paraissant valide. + * + * La création est tout ou rien côté serveur : un jour refusé — période close, + * chevauchement — annule les autres. Une répétition à demi appliquée laisserait + * un planning que personne n'a voulu. + */ +function RepeatDays({ + dates, + days, + selected, +}: { + dates: string[]; + days: string[]; + selected: string; +}) { + return ( +
+ + Répéter sur + +
+ {dates.map((date, index) => { + const locked = date === selected; + return ( + + ); + })} +
+
+ ); +} + function Field({ label, htmlFor, diff --git a/src/domain/planning/board.ts b/src/domain/planning/board.ts index 657bee4..f6905fe 100644 --- a/src/domain/planning/board.ts +++ b/src/domain/planning/board.ts @@ -18,7 +18,10 @@ export interface BoardShiftInput { startAt: Date; endAt: Date; breakMinutes: number; + mealCount: number; poste: PosteCode; + /** Étiquette telle qu'enregistrée, pour rouvrir le créneau sur la sienne. */ + labelId: string | null; isValidated: boolean; note: string | null; } @@ -30,6 +33,8 @@ export interface BoardShift { time: string; minutes: number; breakMinutes: number; + mealCount: number; + labelId: string | null; state: ShiftState; note: string | null; } @@ -139,6 +144,8 @@ export function buildRows( time: `${zonedClock(shift.startAt, timeZone)}–${zonedClock(shift.endAt, timeZone)}`, minutes, breakMinutes: shift.breakMinutes, + mealCount: shift.mealCount, + labelId: shift.labelId, state: shiftState(shift.isValidated, isPublished, shift.membershipId), note: shift.note, }; diff --git a/src/server/planning/actions.ts b/src/server/planning/actions.ts index 02eaddb..60d23d0 100644 --- a/src/server/planning/actions.ts +++ b/src/server/planning/actions.ts @@ -46,14 +46,27 @@ class ValidationError extends Error {} const HOUR = /^([01]\d|2[0-3]):([0-5]\d)$/; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + const shiftInput = z.object({ teamId: z.string().min(1), week: z.string().min(1), membershipId: z.string().optional(), - localDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date invalide'), + /** + * Jours à créer, un créneau par jour. + * + * Un même horaire se répète d'un jour à l'autre bien plus souvent qu'il ne + * varie : ressaisir cinq fois « 9 h–17 h, pause 30 » est le geste le plus + * répété de la semaine, et le plus exposé à la faute de frappe. + */ + localDates: z + .array(z.string().regex(ISO_DATE, 'Date invalide')) + .min(1, 'Sélectionnez au moins un jour') + .max(7), start: z.string().regex(HOUR, 'Heure de début invalide'), end: z.string().regex(HOUR, 'Heure de fin invalide'), breakMinutes: z.coerce.number().int().min(0).max(600).default(0), + mealCount: z.coerce.number().int().min(0).max(5).default(0), labelId: z.string().optional(), note: z.string().trim().max(500).optional(), }); @@ -99,14 +112,23 @@ export async function createShiftAction( _previous: PlanningActionState, formData: FormData, ): Promise { + // Les cases de répétition portent le même nom : la case du jour ouvert est + // cochée d'office, si bien qu'un envoi sans répétition renvoie exactement une + // date. Le repli sur `localDate` couvre le formulaire d'un client qui aurait + // désactivé JavaScript. + const checked = formData.getAll('localDates').map(String).filter(Boolean); + const single = String(formData.get('localDate') ?? ''); + const localDates = checked.length > 0 ? [...new Set(checked)] : single ? [single] : []; + const parsed = shiftInput.safeParse({ teamId: formData.get('teamId'), week: formData.get('week'), membershipId: formData.get('membershipId') || undefined, - localDate: formData.get('localDate'), + localDates, start: formData.get('start'), end: formData.get('end'), breakMinutes: formData.get('breakMinutes') || 0, + mealCount: formData.get('mealCount') || 0, labelId: formData.get('labelId') || undefined, note: formData.get('note') || undefined, }); @@ -122,37 +144,11 @@ export async function createShiftAction( await mutate('planning.create', async (db, actor) => { const { team, location } = await loadTeamContext(db, parsed.data.teamId); - const startAt = zonedInstant( - parsed.data.localDate, - parsed.data.start, - location.timezone, - ); - let endAt = zonedInstant( - parsed.data.localDate, - parsed.data.end, - location.timezone, - ); - // Fin avant début = créneau de nuit : il finit le lendemain. Le refuser - // interdirait de planifier un inventaire 22 h–02 h. - if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000); - // Un mois transmis au cabinet ne se modifie pas par inadvertance : le - // contrôle passe **avant** l'écriture. - await assertPeriodOpen(db, location.id, [parsed.data.localDate]); - - const worked = shiftMinutes(startAt, endAt, parsed.data.breakMinutes); - if (worked <= 0) { - throw new ValidationError( - 'La pause dépasse la durée du créneau — rien ne serait travaillé.', - ); - } - - const { from, to } = weekBounds(week, location.timezone); - if (startAt < from || startAt >= to) { - throw new ValidationError( - "Ce créneau ne commence pas dans la semaine affichée.", - ); - } + // contrôle passe **avant** toute écriture, et porte sur tous les jours + // demandés — répéter sur cinq jours ne doit pas en glisser un dans une + // période close. + await assertPeriodOpen(db, location.id, parsed.data.localDates); const schedule = await ensureSchedule( db, @@ -166,37 +162,76 @@ export async function createShiftAction( assertMayEditPublished(actor); } - if (parsed.data.membershipId) { - await assertNoOverlap(db, parsed.data.membershipId, startAt, endAt, null); + const { from, to } = weekBounds(week, location.timezone); + + // La répétition est **tout ou rien**. Un jour refusé annule la + // transaction entière : une répétition à demi appliquée laisserait un + // planning que personne n'a voulu, et qu'il faudrait défaire à la main + // pour le refaire. + for (const localDate of parsed.data.localDates) { + const startAt = zonedInstant( + localDate, + parsed.data.start, + location.timezone, + ); + let endAt = zonedInstant(localDate, parsed.data.end, location.timezone); + // Fin avant début = créneau de nuit : il finit le lendemain. Le refuser + // interdirait de planifier un inventaire 22 h–02 h. + if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000); + + const worked = shiftMinutes(startAt, endAt, parsed.data.breakMinutes); + if (worked <= 0) { + throw new ValidationError( + 'La pause dépasse la durée du créneau — rien ne serait travaillé.', + ); + } + + if (startAt < from || startAt >= to) { + throw new ValidationError( + 'Un des jours demandés ne commence pas dans la semaine affichée.', + ); + } + + if (parsed.data.membershipId) { + await assertNoOverlap( + db, + parsed.data.membershipId, + startAt, + endAt, + null, + ); + } + + const created = await db.shift.create({ + data: { + weeklyScheduleId: schedule.id, + membershipId: parsed.data.membershipId ?? null, + localDate: new Date(`${localDate}T00:00:00Z`), + startAt, + endAt, + breakMinutes: parsed.data.breakMinutes, + mealCount: parsed.data.mealCount, + labelId: parsed.data.labelId ?? null, + note: parsed.data.note ?? null, + } as never, + }); + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'planning.shift.create', + entityType: 'Shift', + entityId: created.id, + after: { + teamId: team.id, + membershipId: created.membershipId, + startAt: startAt.toISOString(), + endAt: endAt.toISOString(), + minutes: worked, + mealCount: created.mealCount, + }, + }); } - const created = await db.shift.create({ - data: { - weeklyScheduleId: schedule.id, - membershipId: parsed.data.membershipId ?? null, - localDate: new Date(`${parsed.data.localDate}T00:00:00Z`), - startAt, - endAt, - breakMinutes: parsed.data.breakMinutes, - labelId: parsed.data.labelId ?? null, - note: parsed.data.note ?? null, - } as never, - }); - - await recordAudit(db, { - actorMembershipId: actor.membershipId, - action: 'planning.shift.create', - entityType: 'Shift', - entityId: created.id, - after: { - teamId: team.id, - membershipId: created.membershipId, - startAt: startAt.toISOString(), - endAt: endAt.toISOString(), - minutes: worked, - }, - }); - await assertNoBlocking(db, schedule.id); }); } catch (error) { @@ -214,6 +249,12 @@ const moveInput = z.object({ start: z.string().regex(HOUR, 'Heure de début invalide'), end: z.string().regex(HOUR, 'Heure de fin invalide'), breakMinutes: z.coerce.number().int().min(0).max(600).default(0), + mealCount: z.coerce.number().int().min(0).max(5).default(0), + // Modifiables à la correction comme à la création : une étiquette posée de + // travers se corrigeait jusqu'ici en supprimant le créneau pour le refaire, + // ce qui perdait sa validation et son historique. + labelId: z.string().optional(), + note: z.string().trim().max(500).optional(), }); export async function updateShiftAction( @@ -227,6 +268,9 @@ export async function updateShiftAction( start: formData.get('start'), end: formData.get('end'), breakMinutes: formData.get('breakMinutes') || 0, + mealCount: formData.get('mealCount') || 0, + labelId: formData.get('labelId') || undefined, + note: formData.get('note') || undefined, }); if (!parsed.success) { @@ -287,6 +331,9 @@ export async function updateShiftAction( startAt, endAt, breakMinutes: parsed.data.breakMinutes, + mealCount: parsed.data.mealCount, + labelId: parsed.data.labelId ?? null, + note: parsed.data.note ?? null, version: { increment: 1 }, }, }); diff --git a/src/server/planning/queries.ts b/src/server/planning/queries.ts index 38dcd66..5611c0f 100644 --- a/src/server/planning/queries.ts +++ b/src/server/planning/queries.ts @@ -177,6 +177,7 @@ export async function getWeekBoard( startAt: true, endAt: true, breakMinutes: true, + mealCount: true, isValidated: true, note: true, labelId: true, @@ -240,6 +241,8 @@ export async function getWeekBoard( startAt: shift.startAt, endAt: shift.endAt, breakMinutes: shift.breakMinutes, + mealCount: shift.mealCount, + labelId: shift.labelId, poste: posteOf(paletteById.get(shift.labelId ?? '')), isValidated: shift.isValidated, note: shift.note, diff --git a/tests/unit/planning-board.test.ts b/tests/unit/planning-board.test.ts index d89fd22..3a61028 100644 --- a/tests/unit/planning-board.test.ts +++ b/tests/unit/planning-board.test.ts @@ -33,7 +33,9 @@ const shift = ( startAt: zonedInstant(date, start, TZ), endAt: zonedInstant(date, end, TZ), breakMinutes: 0, + mealCount: 0, poste: 'vte', + labelId: null, isValidated: false, note: null, ...over, @@ -89,7 +91,9 @@ describe('buildRows', () => { startAt, endAt, breakMinutes: 0, + mealCount: 0, poste: 'inv', + labelId: null, isValidated: false, note: null, }, diff --git a/tests/unit/planning-presence.test.ts b/tests/unit/planning-presence.test.ts index 42bd45b..53e5157 100644 --- a/tests/unit/planning-presence.test.ts +++ b/tests/unit/planning-presence.test.ts @@ -35,6 +35,8 @@ const shift: BoardRow['days'][number][number] = { time: '09:00–17:00', minutes: 480, breakMinutes: 30, + mealCount: 0, + labelId: null, state: 'published', note: null, };