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 (
+
+ );
+}
+
/**
* 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',