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',