From fab497acdcd8a6e264497a38f19f645230308664 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 11 Aug 2026 14:32:03 +0200 Subject: [PATCH] =?UTF-8?q?D=C3=A9tailler=20les=20pauses=20d'un=20cr=C3=A9?= =?UTF-8?q?neau,=20et=20pr=C3=A9venir=20le=20salari=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un créneau portait un total de pause. Deux pauses de vingt minutes et une coupure de deux heures s'écrivaient toutes deux « 120 », et rien ne permettait de les distinguer — ni à l'écran, ni au contrôle. Or elles ne se planifient pas de la même façon : la première satisfait la pause minimale au-delà de six heures, la seconde interroge l'amplitude de la journée. Chaque pause a désormais sa ligne : durée, début facultatif, libellé, et si elle est rémunérée. Le début reste facultatif à dessein — beaucoup de pauses se prennent « quand c'est calme », et imposer une heure inventerait une précision que le planning n'a pas et qu'un contrôle prendrait pour un engagement. Le créneau garde deux totaux dérivés, et la distinction porte de l'argent. `breakMinutes` reste ce qu'il était, la part non rémunérée déduite du temps de travail : la paie, les compteurs et l'export continuent de le lire sans changer d'un caractère. `paidBreakMinutes` s'ajoute à côté, et la règle de pause minimale regarde la somme des deux — sans quoi un créneau dont la pause de vingt minutes est payée passerait pour un créneau sans pause, et l'employeur le plus généreux serait le seul à recevoir une alerte. La migration reprend les créneaux existants : leur total devient une pause unique, non rémunérée. Laisser la table vide aurait fait disparaître de l'écran des pauses qui comptent pourtant toujours dans les heures payées. En modification, les pauses sont remplacées et non fusionnées : le formulaire porte l'état voulu au complet, et rapprocher ligne à ligne ferait survivre une pause qu'on vient de retirer. L'avis d'affectation part enfin, sous sa propre nature d'envoi. Il est différé à la fin de la transaction : un refus sur le dernier jour d'une répétition annule les autres, et personne ne doit être prévenu d'un créneau qui n'existe pas. La case est décochée par défaut, à l'inverse du produit audité — c'est la publication de la semaine qui prévient, et cette case sert au créneau ajouté après coup, le cas où le salarié ne verrait rien sans elle. Co-Authored-By: Claude Opus 5 --- .../20260811150000_shift_breaks/migration.sql | 61 +++++++ .../migration.sql | 14 ++ prisma/schema.prisma | 37 ++++ src/components/planning/TeamSection.tsx | 162 +++++++++++++++-- src/domain/compliance/rules.ts | 11 +- src/domain/compliance/types.ts | 9 + src/domain/email/message.ts | 37 ++++ src/domain/planning/board.ts | 16 ++ src/domain/planning/breaks.ts | 161 +++++++++++++++++ src/server/compliance/evaluate.ts | 4 + src/server/email/mailer.ts | 1 + src/server/planning/actions.ts | 170 +++++++++++++++++- src/server/planning/queries.ts | 17 ++ tests/unit/compliance-rules.test.ts | 9 +- tests/unit/planning-board.test.ts | 4 + tests/unit/planning-breaks.test.ts | 108 +++++++++++ tests/unit/planning-presence.test.ts | 2 + 17 files changed, 802 insertions(+), 21 deletions(-) create mode 100644 prisma/migrations/20260811150000_shift_breaks/migration.sql create mode 100644 prisma/migrations/20260811150100_shift_assigned_email/migration.sql create mode 100644 src/domain/planning/breaks.ts create mode 100644 tests/unit/planning-breaks.test.ts diff --git a/prisma/migrations/20260811150000_shift_breaks/migration.sql b/prisma/migrations/20260811150000_shift_breaks/migration.sql new file mode 100644 index 0000000..554c934 --- /dev/null +++ b/prisma/migrations/20260811150000_shift_breaks/migration.sql @@ -0,0 +1,61 @@ +-- --------------------------------------------------------------------------- +-- Pauses détaillées d'un créneau — PLAN.md §4.4. +-- +-- `Shift.breakMinutes` garde son sens : la somme des pauses **non rémunérées**, +-- déduite du temps de travail. Tous les calculs de paie et de compteurs +-- continuent de la lire sans changer d'un caractère. +-- +-- `paidBreakMinutes` s'ajoute à côté. Une pause payée n'est pas déduite du +-- temps travaillé mais reste une pause : sans cette colonne, la règle de pause +-- minimale au-delà de six heures verrait zéro et alerterait à tort sur un +-- créneau qui respecte la convention. +-- --------------------------------------------------------------------------- + +ALTER TABLE "Shift" ADD COLUMN "paidBreakMinutes" INTEGER NOT NULL DEFAULT 0; + +CREATE TABLE "ShiftBreak" ( + "id" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "shiftId" TEXT NOT NULL, + "startMinutes" INTEGER, + "durationMinutes" INTEGER NOT NULL, + "isPaid" BOOLEAN NOT NULL DEFAULT false, + "label" TEXT, + "position" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "ShiftBreak_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ShiftBreak_accountId_idx" ON "ShiftBreak"("accountId"); +CREATE INDEX "ShiftBreak_shiftId_idx" ON "ShiftBreak"("shiftId"); + +ALTER TABLE "ShiftBreak" ADD CONSTRAINT "ShiftBreak_shiftId_fkey" + FOREIGN KEY ("shiftId") REFERENCES "Shift"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Reprise des créneaux existants : leur total devient une pause unique, non +-- rémunérée et non située. Laisser la table vide ferait disparaître de l'écran +-- des pauses qui comptent pourtant toujours dans les heures payées. +INSERT INTO "ShiftBreak" ("id", "accountId", "shiftId", "durationMinutes", "isPaid", "position") +SELECT + gen_random_uuid()::text, + "accountId", + "id", + "breakMinutes", + false, + 0 +FROM "Shift" +WHERE "breakMinutes" > 0; + +-- --------------------------------------------------------------------------- +-- Isolation par compte — une pause dit à quelle heure quelqu'un s'absente de +-- son poste. Même défense en profondeur que le créneau qui la porte. +-- --------------------------------------------------------------------------- + +ALTER TABLE "ShiftBreak" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "ShiftBreak" FORCE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation ON "ShiftBreak" + USING ("accountId" = planflow_current_account()); + +CREATE POLICY tenant_insert ON "ShiftBreak" + FOR INSERT WITH CHECK ("accountId" = planflow_current_account()); diff --git a/prisma/migrations/20260811150100_shift_assigned_email/migration.sql b/prisma/migrations/20260811150100_shift_assigned_email/migration.sql new file mode 100644 index 0000000..e7d3513 --- /dev/null +++ b/prisma/migrations/20260811150100_shift_assigned_email/migration.sql @@ -0,0 +1,14 @@ +-- --------------------------------------------------------------------------- +-- Nature d'envoi distincte pour l'affectation d'un créneau. +-- +-- Réutiliser PLANNING_PUBLISHED aurait rendu le journal d'envois illisible : le +-- jour où un salarié affirme n'avoir pas été prévenu, il faut pouvoir +-- distinguer « la semaine a été publiée » de « un créneau a été ajouté après +-- coup ». +-- +-- Dans sa propre migration : PostgreSQL refuse qu'une valeur d'énumération soit +-- ajoutée puis employée dans la même transaction. Les séparer évite d'avoir à +-- s'en souvenir le jour où la migration suivante voudra s'en servir. +-- --------------------------------------------------------------------------- + +ALTER TYPE "EmailKind" ADD VALUE IF NOT EXISTS 'SHIFT_ASSIGNED'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d9f48e3..07448d5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -819,7 +819,13 @@ model Shift { startAt DateTime @db.Timestamptz endAt DateTime @db.Timestamptz + /// Somme des pauses **non rémunérées**, déduite du temps de travail. C'est + /// le total qui compte en paie ; le détail vit dans `ShiftBreak`. breakMinutes Int @default(0) + /// Somme des pauses **rémunérées**. Non déduite du temps travaillé, mais + /// comptée comme repos : la règle de pause minimale regarde les deux, sans + /// quoi une pause payée passerait pour une absence de pause. + paidBreakMinutes Int @default(0) actualStartAt DateTime? @db.Timestamptz actualEndAt DateTime? @db.Timestamptz actualBreakMinutes Int? @@ -833,12 +839,41 @@ model Shift { version Int @default(0) schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade) + breaks ShiftBreak[] @@index([accountId]) @@index([weeklyScheduleId, localDate]) @@index([membershipId, localDate]) } +/// Pause d'un créneau. +/// +/// Plusieurs pauses par créneau, et pas un total unique : une coupure de deux +/// heures en milieu de journée et deux pauses de vingt minutes ne se planifient +/// pas de la même façon et ne se contrôlent pas pareil — la seconde satisfait +/// la pause minimale de six heures, la première interroge l'amplitude. +/// +/// `startMinutes` est facultatif. Beaucoup de pauses se prennent « quand c'est +/// calme » : imposer une heure obligerait à inventer une précision que le +/// planning n'a pas, et qu'un contrôle prendrait pour un engagement. +model ShiftBreak { + id String @id @default(cuid()) + accountId String + shiftId String + /// Minutes depuis le début du créneau. Null = pause non située. + startMinutes Int? + durationMinutes Int + /// Rémunérée : la durée n'est pas déduite du temps de travail effectif. + isPaid Boolean @default(false) + label String? + position Int @default(0) + + shift Shift @relation(fields: [shiftId], references: [id], onDelete: Cascade) + + @@index([accountId]) + @@index([shiftId]) +} + /// Repos de journée — hebdomadaire ou compensateur. Les pauses *dans* un /// créneau sont `Shift.breakMinutes`. model Rest { @@ -1430,6 +1465,8 @@ enum EmailKind { INVITATION PASSWORD_RESET PLANNING_PUBLISHED + /// Créneau ajouté ou déplacé hors d'une publication de semaine. + SHIFT_ASSIGNED TIMEOFF_DECISION LEAVE_NOTICE } diff --git a/src/components/planning/TeamSection.tsx b/src/components/planning/TeamSection.tsx index 0170e39..46d2c0b 100644 --- a/src/components/planning/TeamSection.tsx +++ b/src/components/planning/TeamSection.tsx @@ -444,18 +444,10 @@ function ShiftComposer({ /> - - - + {editing ? null : ( - + <> + + + {/* Décoché par défaut, à l'inverse du produit audité. Un avis par + créneau posé remplit vite une boîte : c'est la publication de la + semaine qui prévient, et cette case sert au créneau ajouté après + coup — le cas où le salarié ne verrait rien sans elle. */} + + )}
@@ -522,6 +525,139 @@ function ShiftComposer({ ); } +/** + * Pauses d'un créneau, une ligne par pause. + * + * Un total unique ne disait pas ce qui composait la journée : deux pauses de + * vingt minutes et une coupure de deux heures s'écrivaient « 120 », et rien ne + * permettait de les distinguer — ni à l'écran, ni au contrôle. + * + * Le début est facultatif. Beaucoup de pauses se prennent « quand c'est + * calme » : imposer une heure obligerait à inventer une précision que le + * planning n'a pas, et qu'un contrôle prendrait pour un engagement. + */ +function BreakRows({ + initial, + fallbackMinutes, +}: { + initial: ReadonlyArray<{ + startMinutes: number | null; + durationMinutes: number; + isPaid: boolean; + label: string | null; + }>; + fallbackMinutes: number; +}) { + // Un créneau enregistré avant le détail des pauses n'a qu'un total : il est + // repris comme une pause unique plutôt que perdu. + const seeded = + initial.length > 0 + ? initial + : fallbackMinutes > 0 + ? [ + { + startMinutes: null, + durationMinutes: fallbackMinutes, + isPaid: false, + label: null, + }, + ] + : []; + + const [rows, setRows] = useState(seeded); + + return ( +
+ + Pauses + + + {rows.length === 0 ? ( +

Aucune pause.

+ ) : null} + + {rows.map((row, index) => ( +
+ + min + + + + + + + + +
+ ))} + +
+ +
+
+ ); +} + /** * Répétition du créneau sur plusieurs jours. * diff --git a/src/domain/compliance/rules.ts b/src/domain/compliance/rules.ts index acf7789..5a1a1c2 100644 --- a/src/domain/compliance/rules.ts +++ b/src/domain/compliance/rules.ts @@ -363,16 +363,21 @@ export const minBreakAfterThreshold: Rule = (context) => { (shift.endAt.getTime() - shift.startAt.getTime()) / MINUTE, ); if (span <= thresholdMinutes) return []; - if (shift.breakMinutes >= minBreakMinutes) return []; + + // Rémunérée ou non, une pause est une pause. Ne compter que la part + // déduite ferait alerter un créneau dont la pause de vingt minutes est + // payée — c'est-à-dire précisément l'employeur le plus généreux. + const restMinutes = shift.breakMinutes + shift.paidBreakMinutes; + if (restMinutes >= minBreakMinutes) return []; return [ violation( 'MIN_BREAK_AFTER_THRESHOLD', - `${shift.breakMinutes} min de pause pour ${hours(span)} de présence : minimum ${minBreakMinutes} min au-delà de ${hours(thresholdMinutes)}.`, + `${restMinutes} min de pause pour ${hours(span)} de présence : minimum ${minBreakMinutes} min au-delà de ${hours(thresholdMinutes)}.`, { localDate: zonedDate(shift.startAt, context.timeZone), context: { - breakMinutes: shift.breakMinutes, + breakMinutes: restMinutes, requiredMinutes: minBreakMinutes, spanMinutes: span, }, diff --git a/src/domain/compliance/types.ts b/src/domain/compliance/types.ts index 88f5db6..6af7ee6 100644 --- a/src/domain/compliance/types.ts +++ b/src/domain/compliance/types.ts @@ -98,7 +98,16 @@ export interface ComplianceShift { id: string; startAt: Date; endAt: Date; + /** Pauses non rémunérées, déduites du temps de travail. */ breakMinutes: number; + /** + * Pauses rémunérées. + * + * Elles ne se déduisent pas du temps travaillé, mais restent du repos : la + * règle de pause minimale les compte. Sans elles, un créneau où la pause de + * vingt minutes est payée passerait pour un créneau sans pause. + */ + paidBreakMinutes: number; /** Faux pour un besoin non couvert : aucune règle de personne ne s'applique. */ assigned: boolean; } diff --git a/src/domain/email/message.ts b/src/domain/email/message.ts index 0951040..9bf3991 100644 --- a/src/domain/email/message.ts +++ b/src/domain/email/message.ts @@ -161,6 +161,43 @@ export function testMessage(to: string, accountName: string): Message { return { to, subject: 'Test d’envoi PlanFlow', text, html }; } +export interface ShiftAssignedInput { + firstName: string; + /** Jour rédigé, « lundi 10 août ». */ + day: string; + start: string; + end: string; + teamName: string; +} + +/** + * Avis d'affectation d'un créneau. + * + * Le message dit le jour, l'horaire et l'équipe, et rien de plus : ni le motif, + * ni le reste du planning. Un avis qui recopierait la semaine entière ferait + * sortir de l'application des données que le destinataire n'a pas demandées, et + * qu'un courriel transfère sans contrôle. + */ +export function shiftAssignedMessage( + to: string, + input: ShiftAssignedInput, +): Message { + const subject = `Nouveau créneau le ${input.day}`; + const { text, html } = render({ + title: subject, + intro: `Bonjour ${input.firstName},`, + body: [ + `Un créneau vous a été ajouté le ${input.day}, de ${input.start} à ${input.end}.`, + `Équipe : ${input.teamName}.`, + 'Votre planning à jour reste consultable dans PlanFlow.', + ], + footer: + 'Avis automatique — en cas d’erreur, adressez-vous à votre responsable.', + }); + + return { to, subject, text, html }; +} + /** * Message d'erreur d'envoi, débarrassé de tout secret. * diff --git a/src/domain/planning/board.ts b/src/domain/planning/board.ts index f6905fe..640c3fd 100644 --- a/src/domain/planning/board.ts +++ b/src/domain/planning/board.ts @@ -12,12 +12,22 @@ import { shiftMinutes } from '@/domain/counters/week'; import { zonedClock, zonedDate } from '@/domain/planning/week'; import type { PosteCode } from '@/lib/design/postes'; +/** Pause telle qu'affichée : le détail suffit à rouvrir le formulaire. */ +export interface ShiftBreakView { + startMinutes: number | null; + durationMinutes: number; + isPaid: boolean; + label: string | null; +} + export interface BoardShiftInput { id: string; membershipId: string | null; startAt: Date; endAt: Date; breakMinutes: number; + paidBreakMinutes: number; + breaks: ShiftBreakView[]; mealCount: number; poste: PosteCode; /** Étiquette telle qu'enregistrée, pour rouvrir le créneau sur la sienne. */ @@ -32,7 +42,11 @@ export interface BoardShift { /** « 09:00–17:00 », en heure locale de l'établissement. */ time: string; minutes: number; + /** Pauses non rémunérées, déduites du temps travaillé. */ breakMinutes: number; + /** Pauses rémunérées : comptées comme repos, pas déduites. */ + paidBreakMinutes: number; + breaks: ShiftBreakView[]; mealCount: number; labelId: string | null; state: ShiftState; @@ -144,6 +158,8 @@ export function buildRows( time: `${zonedClock(shift.startAt, timeZone)}–${zonedClock(shift.endAt, timeZone)}`, minutes, breakMinutes: shift.breakMinutes, + paidBreakMinutes: shift.paidBreakMinutes, + breaks: shift.breaks, mealCount: shift.mealCount, labelId: shift.labelId, state: shiftState(shift.isValidated, isPublished, shift.membershipId), diff --git a/src/domain/planning/breaks.ts b/src/domain/planning/breaks.ts new file mode 100644 index 0000000..e475ed9 --- /dev/null +++ b/src/domain/planning/breaks.ts @@ -0,0 +1,161 @@ +/** + * Pauses d'un créneau — PLAN.md §4.4. + * + * Un créneau porte une **liste** de pauses, pas un total. Deux pauses de vingt + * minutes et une coupure de deux heures ne se planifient pas de la même façon + * et ne se contrôlent pas pareil : la première satisfait la pause minimale au + * delà de six heures, la seconde interroge l'amplitude de la journée. + * + * Le créneau conserve deux totaux dérivés, et c'est délibéré : + * + * - `breakMinutes` — les pauses **non rémunérées**, déduites du temps de + * travail. C'est ce que lisent la paie, les compteurs et l'export, sans + * changer d'un caractère. + * - `paidBreakMinutes` — les pauses **rémunérées**. Non déduites, mais bien des + * pauses : la règle de pause minimale regarde la somme des deux, sans quoi une + * pause payée passerait pour une absence de pause et alerterait à tort. + * + * Les totaux ne sont pas une optimisation : ils sont la forme sous laquelle le + * reste du produit lit déjà l'information. Les recalculer à chaque écriture + * coûte moins qu'un second chemin de lecture qui finirait par diverger. + */ + +export interface BreakInput { + /** Minutes depuis le début du créneau. `null` = pause non située. */ + startMinutes: number | null; + durationMinutes: number; + isPaid: boolean; + label: string | null; +} + +export interface BreakTotals { + /** Déduit du temps de travail. */ + breakMinutes: number; + /** Compté comme repos, pas déduit. */ + paidBreakMinutes: number; +} + +export class BreakError extends Error {} + +const MAX_BREAKS = 6; +const MAX_DURATION = 600; + +/** + * Valide et ordonne une liste de pauses. + * + * Refuse plutôt que de corriger. Une pause de durée nulle vient d'une ligne + * ajoutée puis laissée vide ; l'écarter en silence donnerait un enregistrement + * réussi et une pause disparue, ce qui est le pire des deux mondes — sauf pour + * une ligne **entièrement** vide, qui n'est pas une saisie mais un formulaire + * en cours. + */ +export function normaliseBreaks( + raw: ReadonlyArray>, + shiftMinutes: number, +): BreakInput[] { + const entered = raw.filter( + (entry) => + entry.durationMinutes !== undefined && + entry.durationMinutes !== null && + !Number.isNaN(entry.durationMinutes), + ); + + if (entered.length > MAX_BREAKS) { + throw new BreakError( + `Six pauses au plus par créneau ; ${entered.length} ont été saisies.`, + ); + } + + const breaks = entered.map((entry, index) => { + const duration = Number(entry.durationMinutes); + + if (!Number.isInteger(duration) || duration <= 0) { + throw new BreakError( + `Pause ${index + 1} : la durée doit être un nombre de minutes positif.`, + ); + } + if (duration > MAX_DURATION) { + throw new BreakError( + `Pause ${index + 1} : ${duration} min dépasse le maximum de ${MAX_DURATION} min.`, + ); + } + + const start = + entry.startMinutes === null || entry.startMinutes === undefined + ? null + : Number(entry.startMinutes); + + if (start !== null) { + if (!Number.isInteger(start) || start < 0) { + throw new BreakError( + `Pause ${index + 1} : le début doit être un nombre de minutes positif.`, + ); + } + // Une pause qui déborde du créneau n'est pas une pause : c'est une + // saisie fausse, et la laisser passer fausserait l'amplitude. + if (start + duration > shiftMinutes) { + throw new BreakError( + `Pause ${index + 1} : elle sort du créneau.`, + ); + } + } + + return { + startMinutes: start, + durationMinutes: duration, + isPaid: Boolean(entry.isPaid), + label: entry.label?.trim() ? entry.label.trim() : null, + } satisfies BreakInput; + }); + + assertNoOverlap(breaks); + + // Les pauses situées d'abord, dans l'ordre ; les non situées ensuite. C'est + // l'ordre de lecture d'une journée. + return [...breaks].sort((a, b) => { + if (a.startMinutes === null) return b.startMinutes === null ? 0 : 1; + if (b.startMinutes === null) return -1; + return a.startMinutes - b.startMinutes; + }); +} + +/** + * Deux pauses situées ne peuvent pas se chevaucher. + * + * Elles compteraient deux fois dans le total déduit, et le salarié se verrait + * retirer un temps qu'il n'a pris qu'une fois. + */ +function assertNoOverlap(breaks: BreakInput[]): void { + const placed = breaks + .filter((entry) => entry.startMinutes !== null) + .sort((a, b) => (a.startMinutes as number) - (b.startMinutes as number)); + + for (let index = 1; index < placed.length; index += 1) { + const previous = placed[index - 1] as BreakInput; + const current = placed[index] as BreakInput; + const previousEnd = + (previous.startMinutes as number) + previous.durationMinutes; + if ((current.startMinutes as number) < previousEnd) { + throw new BreakError( + 'Deux pauses se chevauchent : le temps serait déduit deux fois.', + ); + } + } +} + +export function breakTotals(breaks: ReadonlyArray): BreakTotals { + return breaks.reduce( + (totals, entry) => ({ + breakMinutes: + totals.breakMinutes + (entry.isPaid ? 0 : entry.durationMinutes), + paidBreakMinutes: + totals.paidBreakMinutes + (entry.isPaid ? entry.durationMinutes : 0), + }), + { breakMinutes: 0, paidBreakMinutes: 0 }, + ); +} + +/** Durée totale de repos, rémunérée ou non — ce que regarde la règle de pause. */ +export function totalRestMinutes(totals: BreakTotals): number { + return totals.breakMinutes + totals.paidBreakMinutes; +} diff --git a/src/server/compliance/evaluate.ts b/src/server/compliance/evaluate.ts index bb245a3..bec90a0 100644 --- a/src/server/compliance/evaluate.ts +++ b/src/server/compliance/evaluate.ts @@ -67,6 +67,7 @@ interface LoadedShift { startAt: Date; endAt: Date; breakMinutes: number; + paidBreakMinutes: number; } /** @@ -262,6 +263,7 @@ async function buildContexts( startAt: true, endAt: true, breakMinutes: true, + paidBreakMinutes: true, }, }); @@ -277,6 +279,7 @@ async function buildContexts( startAt: true, endAt: true, breakMinutes: true, + paidBreakMinutes: true, }, }); @@ -321,6 +324,7 @@ async function buildContexts( startAt: shift.startAt, endAt: shift.endAt, breakMinutes: shift.breakMinutes, + paidBreakMinutes: shift.paidBreakMinutes, assigned: shift.membershipId !== null, })); diff --git a/src/server/email/mailer.ts b/src/server/email/mailer.ts index 1a5c71d..675b6e2 100644 --- a/src/server/email/mailer.ts +++ b/src/server/email/mailer.ts @@ -37,6 +37,7 @@ export type EmailKind = | 'INVITATION' | 'PASSWORD_RESET' | 'PLANNING_PUBLISHED' + | 'SHIFT_ASSIGNED' | 'TIMEOFF_DECISION' | 'LEAVE_NOTICE'; diff --git a/src/server/planning/actions.ts b/src/server/planning/actions.ts index 60d23d0..cb36ba4 100644 --- a/src/server/planning/actions.ts +++ b/src/server/planning/actions.ts @@ -17,7 +17,15 @@ import { zonedDate, zonedInstant, } from '@/domain/planning/week'; +import { + breakTotals, + normaliseBreaks, + BreakError, + type BreakInput, +} from '@/domain/planning/breaks'; +import { shiftAssignedMessage } from '@/domain/email/message'; import { recordAudit } from '@/server/audit'; +import { sendEmail } from '@/server/email/mailer'; import { evaluateAround, evaluateSchedule } from '@/server/compliance/evaluate'; import { mutate } from '@/server/context'; import { assertPeriodOpen, PeriodLockedError } from '@/server/payroll/periods'; @@ -69,8 +77,35 @@ const shiftInput = z.object({ mealCount: z.coerce.number().int().min(0).max(5).default(0), labelId: z.string().optional(), note: z.string().trim().max(500).optional(), + notify: z.boolean().default(false), }); +/** + * Pauses saisies dans le formulaire. + * + * Trois champs répétés, lus par position : la ligne *n* est faite du n-ième + * `breakDuration`, du n-ième `breakStart` et de la n-ième case `breakPaid`. Une + * case non cochée n'étant pas envoyée, la valeur de `breakPaid` porte l'index + * de sa ligne plutôt qu'un simple « on ». + */ +function readBreaks(formData: FormData): Array> { + const durations = formData.getAll('breakDuration').map(String); + const starts = formData.getAll('breakStart').map(String); + const labels = formData.getAll('breakLabel').map(String); + const paid = new Set(formData.getAll('breakPaid').map(String)); + + return durations.map((duration, index) => { + if (duration.trim() === '') return {}; + const start = starts[index]?.trim(); + return { + durationMinutes: Number(duration), + startMinutes: start ? Number(start) : null, + isPaid: paid.has(String(index)), + label: labels[index]?.trim() || null, + }; + }); +} + async function loadTeamContext(db: ScopedClient, teamId: string) { const team = await db.team.findUnique({ where: { id: teamId }, @@ -131,12 +166,15 @@ export async function createShiftAction( mealCount: formData.get('mealCount') || 0, labelId: formData.get('labelId') || undefined, note: formData.get('note') || undefined, + notify: formData.get('notify') === 'on', }); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; } + const rawBreaks = readBreaks(formData); + const week = parseWeekParam(parsed.data.week); if (!week) return { error: 'Semaine invalide.' }; @@ -164,6 +202,17 @@ export async function createShiftAction( const { from, to } = weekBounds(week, location.timezone); + // Les envois sont **différés à la fin de la transaction**. Écrire un + // courriel au fil des créneaux enverrait des avis pour des jours qu'un + // refus sur le dernier jour finirait par annuler — un salarié prévenu + // d'un créneau qui n'existe pas. + const notifications: Array<{ + membershipId: string; + localDate: string; + start: string; + end: string; + }> = []; + // 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 @@ -179,7 +228,17 @@ export async function createShiftAction( // 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); + // Les pauses détaillées font foi quand il y en a ; le champ « total » + // reste le repli du formulaire réduit. Deux sources pour la même + // valeur, mais une seule gagne, et elle est explicite. + const span = Math.round((endAt.getTime() - startAt.getTime()) / 60_000); + const breaks = normaliseBreaks(rawBreaks, span); + const totals = + breaks.length > 0 + ? breakTotals(breaks) + : { breakMinutes: parsed.data.breakMinutes, paidBreakMinutes: 0 }; + + const worked = shiftMinutes(startAt, endAt, totals.breakMinutes); if (worked <= 0) { throw new ValidationError( 'La pause dépasse la durée du créneau — rien ne serait travaillé.', @@ -209,13 +268,36 @@ export async function createShiftAction( localDate: new Date(`${localDate}T00:00:00Z`), startAt, endAt, - breakMinutes: parsed.data.breakMinutes, + breakMinutes: totals.breakMinutes, + paidBreakMinutes: totals.paidBreakMinutes, mealCount: parsed.data.mealCount, labelId: parsed.data.labelId ?? null, note: parsed.data.note ?? null, } as never, }); + for (const [position, entry] of breaks.entries()) { + await db.shiftBreak.create({ + data: { + shiftId: created.id, + startMinutes: entry.startMinutes, + durationMinutes: entry.durationMinutes, + isPaid: entry.isPaid, + label: entry.label, + position, + } as never, + }); + } + + if (parsed.data.notify && created.membershipId) { + notifications.push({ + membershipId: created.membershipId, + localDate, + start: parsed.data.start, + end: parsed.data.end, + }); + } + await recordAudit(db, { actorMembershipId: actor.membershipId, action: 'planning.shift.create', @@ -233,6 +315,12 @@ export async function createShiftAction( } await assertNoBlocking(db, schedule.id); + + // Après le dernier contrôle : un créneau bloquant annule la transaction, + // et l'avis ne doit pas partir pour un planning qui n'a pas été écrit. + for (const notice of notifications) { + await notifyShiftAssigned(db, notice, team.name); + } }); } catch (error) { return toState(error, 'Vous ne pouvez pas créer de créneau ici.'); @@ -242,6 +330,50 @@ export async function createShiftAction( return { ok: true }; } +const dayFormat = new Intl.DateTimeFormat('fr-FR', { + weekday: 'long', + day: 'numeric', + month: 'long', + timeZone: 'UTC', +}); + +/** + * Prévient un salarié qu'un créneau lui a été posé. + * + * L'échec n'interrompt pas : un serveur SMTP indisponible ne doit pas empêcher + * de planifier. `sendEmail` journalise l'échec, et le journal d'envois répond à + * « je n'ai rien reçu » mieux qu'une transaction annulée. + * + * Un salarié sans compte n'a pas d'adresse : il est prévenu par le planning + * affiché, comme aujourd'hui. + */ +async function notifyShiftAssigned( + db: ScopedClient, + notice: { membershipId: string; localDate: string; start: string; end: string }, + teamName: string, +): Promise { + const membership = await db.membership.findUnique({ + where: { id: notice.membershipId }, + include: { user: { select: { email: true, firstName: true } } }, + }); + if (!membership?.user?.email) return; + + const day = dayFormat.format(new Date(`${notice.localDate}T00:00:00Z`)); + + await sendEmail( + db, + shiftAssignedMessage(membership.user.email, { + firstName: membership.user.firstName, + day, + start: notice.start, + end: notice.end, + teamName, + }), + 'SHIFT_ASSIGNED', + membership.id, + ); +} + const moveInput = z.object({ shiftId: z.string().min(1), membershipId: z.string().optional(), @@ -277,6 +409,8 @@ export async function updateShiftAction( return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; } + const rawBreaks = readBreaks(formData); + try { await mutate('planning.edit', async (db, actor) => { const shift = await db.shift.findUnique({ @@ -312,7 +446,14 @@ export async function updateShiftAction( ); if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000); - if (shiftMinutes(startAt, endAt, parsed.data.breakMinutes) <= 0) { + const span = Math.round((endAt.getTime() - startAt.getTime()) / 60_000); + const breaks = normaliseBreaks(rawBreaks, span); + const totals = + breaks.length > 0 + ? breakTotals(breaks) + : { breakMinutes: parsed.data.breakMinutes, paidBreakMinutes: 0 }; + + if (shiftMinutes(startAt, endAt, totals.breakMinutes) <= 0) { throw new ValidationError( 'La pause dépasse la durée du créneau — rien ne serait travaillé.', ); @@ -330,7 +471,8 @@ export async function updateShiftAction( localDate: new Date(`${parsed.data.localDate}T00:00:00Z`), startAt, endAt, - breakMinutes: parsed.data.breakMinutes, + breakMinutes: totals.breakMinutes, + paidBreakMinutes: totals.paidBreakMinutes, mealCount: parsed.data.mealCount, labelId: parsed.data.labelId ?? null, note: parsed.data.note ?? null, @@ -338,6 +480,23 @@ export async function updateShiftAction( }, }); + // Les pauses sont **remplacées**, pas fusionnées. Le formulaire porte + // l'état voulu au complet ; rapprocher ligne à ligne inventerait une + // identité que la saisie n'a pas, et ferait survivre une pause retirée. + await db.shiftBreak.deleteMany({ where: { shiftId: shift.id } }); + for (const [position, entry] of breaks.entries()) { + await db.shiftBreak.create({ + data: { + shiftId: shift.id, + startMinutes: entry.startMinutes, + durationMinutes: entry.durationMinutes, + isPaid: entry.isPaid, + label: entry.label, + position, + } as never, + }); + } + await recordAudit(db, { actorMembershipId: actor.membershipId, action: 'planning.shift.update', @@ -829,6 +988,9 @@ function assertMayEditPublished(actor: Actor): void { function toState(error: unknown, denied: string): PlanningActionState { if (error instanceof ValidationError) return { error: error.message }; + // Une pause mal saisie se corrige dans le formulaire : le message dit + // laquelle et pourquoi, plutôt qu'un refus général. + if (error instanceof BreakError) return { error: error.message }; // Le verrou de période porte son propre message, qui explique la sortie : // déverrouiller, ou régulariser sur la période suivante. if (error instanceof PeriodLockedError) return { error: error.message }; diff --git a/src/server/planning/queries.ts b/src/server/planning/queries.ts index 5611c0f..966d7c9 100644 --- a/src/server/planning/queries.ts +++ b/src/server/planning/queries.ts @@ -177,10 +177,20 @@ export async function getWeekBoard( startAt: true, endAt: true, breakMinutes: true, + paidBreakMinutes: true, mealCount: true, isValidated: true, note: true, labelId: true, + breaks: { + orderBy: { position: 'asc' }, + select: { + startMinutes: true, + durationMinutes: true, + isPaid: true, + label: true, + }, + }, }, }); @@ -241,6 +251,13 @@ export async function getWeekBoard( startAt: shift.startAt, endAt: shift.endAt, breakMinutes: shift.breakMinutes, + paidBreakMinutes: shift.paidBreakMinutes, + breaks: shift.breaks.map((entry) => ({ + startMinutes: entry.startMinutes, + durationMinutes: entry.durationMinutes, + isPaid: entry.isPaid, + label: entry.label, + })), mealCount: shift.mealCount, labelId: shift.labelId, poste: posteOf(paletteById.get(shift.labelId ?? '')), diff --git a/tests/unit/compliance-rules.test.ts b/tests/unit/compliance-rules.test.ts index 9a756a1..e70a7a3 100644 --- a/tests/unit/compliance-rules.test.ts +++ b/tests/unit/compliance-rules.test.ts @@ -37,7 +37,14 @@ function shift( const startAt = zonedInstant(date, start, TZ); let endAt = zonedInstant(date, end, TZ); if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000); - return { id: `s${sequence}`, startAt, endAt, breakMinutes, assigned }; + return { + id: `s${sequence}`, + startAt, + endAt, + breakMinutes, + paidBreakMinutes: 0, + assigned, + }; } function context(over: Partial = {}): ComplianceContext { diff --git a/tests/unit/planning-board.test.ts b/tests/unit/planning-board.test.ts index 3a61028..85d3b71 100644 --- a/tests/unit/planning-board.test.ts +++ b/tests/unit/planning-board.test.ts @@ -33,6 +33,8 @@ const shift = ( startAt: zonedInstant(date, start, TZ), endAt: zonedInstant(date, end, TZ), breakMinutes: 0, + paidBreakMinutes: 0, + breaks: [], mealCount: 0, poste: 'vte', labelId: null, @@ -91,6 +93,8 @@ describe('buildRows', () => { startAt, endAt, breakMinutes: 0, + paidBreakMinutes: 0, + breaks: [], mealCount: 0, poste: 'inv', labelId: null, diff --git a/tests/unit/planning-breaks.test.ts b/tests/unit/planning-breaks.test.ts new file mode 100644 index 0000000..e9de344 --- /dev/null +++ b/tests/unit/planning-breaks.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + BreakError, + breakTotals, + normaliseBreaks, + totalRestMinutes, + type BreakInput, +} from '@/domain/planning/breaks'; + +const SHIFT = 8 * 60; + +const entry = (over: Partial = {}): Partial => ({ + startMinutes: null, + durationMinutes: 20, + isPaid: false, + label: null, + ...over, +}); + +describe('normaliseBreaks', () => { + it('écarte les lignes vides sans échouer', () => { + // Une ligne ajoutée puis laissée vide n'est pas une saisie : c'est un + // formulaire en cours. + const breaks = normaliseBreaks([entry(), {}], SHIFT); + expect(breaks).toHaveLength(1); + }); + + it('refuse une durée nulle ou négative', () => { + expect(() => normaliseBreaks([entry({ durationMinutes: 0 })], SHIFT)).toThrow( + BreakError, + ); + expect(() => + normaliseBreaks([entry({ durationMinutes: -10 })], SHIFT), + ).toThrow(BreakError); + }); + + it('refuse une pause qui sort du créneau', () => { + // Laissée passer, elle fausserait l'amplitude de la journée. + expect(() => + normaliseBreaks( + [entry({ startMinutes: SHIFT - 10, durationMinutes: 30 })], + SHIFT, + ), + ).toThrow(BreakError); + }); + + it('refuse deux pauses situées qui se chevauchent', () => { + // Elles compteraient deux fois : le salarié se verrait retirer un temps + // qu'il n'a pris qu'une fois. + expect(() => + normaliseBreaks( + [ + entry({ startMinutes: 120, durationMinutes: 60 }), + entry({ startMinutes: 150, durationMinutes: 30 }), + ], + SHIFT, + ), + ).toThrow(BreakError); + }); + + it('accepte deux pauses situées qui se touchent', () => { + const breaks = normaliseBreaks( + [ + entry({ startMinutes: 120, durationMinutes: 30 }), + entry({ startMinutes: 150, durationMinutes: 30 }), + ], + SHIFT, + ); + expect(breaks).toHaveLength(2); + }); + + it('range les pauses situées avant les autres', () => { + const breaks = normaliseBreaks( + [ + entry({ startMinutes: null, durationMinutes: 15 }), + entry({ startMinutes: 240, durationMinutes: 30 }), + entry({ startMinutes: 120, durationMinutes: 20 }), + ], + SHIFT, + ); + expect(breaks.map((row) => row.startMinutes)).toEqual([120, 240, null]); + }); + + it('refuse plus de six pauses', () => { + expect(() => + normaliseBreaks(Array.from({ length: 7 }, () => entry()), SHIFT), + ).toThrow(BreakError); + }); +}); + +describe('breakTotals', () => { + it('sépare le déduit du rémunéré', () => { + // La distinction porte de l'argent : une pause payée ne se retire pas du + // temps de travail, mais reste du repos. + const totals = breakTotals([ + { startMinutes: null, durationMinutes: 30, isPaid: false, label: null }, + { startMinutes: null, durationMinutes: 20, isPaid: true, label: null }, + ]); + + expect(totals).toEqual({ breakMinutes: 30, paidBreakMinutes: 20 }); + expect(totalRestMinutes(totals)).toBe(50); + }); + + it('compte zéro sans pause', () => { + expect(breakTotals([])).toEqual({ breakMinutes: 0, paidBreakMinutes: 0 }); + }); +}); diff --git a/tests/unit/planning-presence.test.ts b/tests/unit/planning-presence.test.ts index 53e5157..552c2cc 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, + paidBreakMinutes: 0, + breaks: [], mealCount: 0, labelId: null, state: 'published',