Détailler les pauses d'un créneau, et prévenir le salarié
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa7336d749
commit
fab497acdc
17 files changed
+802
-21
No files matched your search
@@ -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());
|
||||
@@ -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';
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -444,18 +444,10 @@ function ShiftComposer({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Pause (min)" htmlFor="composer-break">
|
||||
<input
|
||||
id="composer-break"
|
||||
name="breakMinutes"
|
||||
type="number"
|
||||
min={0}
|
||||
max={600}
|
||||
step={5}
|
||||
defaultValue={editing ? target.shift.breakMinutes : 0}
|
||||
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
<BreakRows
|
||||
initial={editing ? target.shift.breaks : []}
|
||||
fallbackMinutes={editing ? target.shift.breakMinutes : 0}
|
||||
/>
|
||||
|
||||
<Field label="Repas" htmlFor="composer-meals">
|
||||
<input
|
||||
@@ -501,7 +493,18 @@ function ShiftComposer({
|
||||
</Field>
|
||||
|
||||
{editing ? null : (
|
||||
<RepeatDays dates={dates} days={days} selected={localDate} />
|
||||
<>
|
||||
<RepeatDays dates={dates} days={days} selected={localDate} />
|
||||
|
||||
{/* 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. */}
|
||||
<label className="flex items-center gap-2 text-sm text-ink-2">
|
||||
<input type="checkbox" name="notify" />
|
||||
Prévenir le salarié par courriel
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -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 (
|
||||
<fieldset className="flex w-full flex-col gap-1.5">
|
||||
<legend className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase">
|
||||
Pauses
|
||||
</legend>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-micro text-ink-3">Aucune pause.</p>
|
||||
) : null}
|
||||
|
||||
{rows.map((row, index) => (
|
||||
<div key={index} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
name="breakDuration"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
step={5}
|
||||
required
|
||||
defaultValue={row.durationMinutes}
|
||||
aria-label={`Durée de la pause ${index + 1}, en minutes`}
|
||||
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
<span className="text-micro text-ink-3">min</span>
|
||||
|
||||
<input
|
||||
name="breakStart"
|
||||
type="number"
|
||||
min={0}
|
||||
step={5}
|
||||
defaultValue={row.startMinutes ?? ''}
|
||||
placeholder="début"
|
||||
aria-label={`Début de la pause ${index + 1}, en minutes après la prise de poste`}
|
||||
className="h-8 w-24 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
|
||||
<input
|
||||
name="breakLabel"
|
||||
type="text"
|
||||
maxLength={60}
|
||||
defaultValue={row.label ?? ''}
|
||||
placeholder="libellé"
|
||||
aria-label={`Libellé de la pause ${index + 1}`}
|
||||
className="h-8 w-28 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-1 text-micro text-ink-2">
|
||||
{/* La valeur porte l'index : une case non cochée n'est pas envoyée,
|
||||
et « on » seul ne dirait pas quelle ligne est payée. */}
|
||||
<input
|
||||
type="checkbox"
|
||||
name="breakPaid"
|
||||
value={String(index)}
|
||||
defaultChecked={row.isPaid}
|
||||
/>
|
||||
rémunérée
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRows(rows.filter((_, at) => at !== index))}
|
||||
className="rounded-2 px-1.5 py-0.5 text-micro text-ink-3 hover:text-danger"
|
||||
>
|
||||
Retirer
|
||||
<span className="sr-only">{` la pause ${index + 1}`}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setRows([
|
||||
...rows,
|
||||
{
|
||||
startMinutes: null,
|
||||
durationMinutes: 20,
|
||||
isPaid: false,
|
||||
label: null,
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
+ Ajouter une pause
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Répétition du créneau sur plusieurs jours.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Partial<BreakInput>>,
|
||||
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<BreakInput>): BreakTotals {
|
||||
return breaks.reduce<BreakTotals>(
|
||||
(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;
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export type EmailKind =
|
||||
| 'INVITATION'
|
||||
| 'PASSWORD_RESET'
|
||||
| 'PLANNING_PUBLISHED'
|
||||
| 'SHIFT_ASSIGNED'
|
||||
| 'TIMEOFF_DECISION'
|
||||
| 'LEAVE_NOTICE';
|
||||
|
||||
|
||||
@@ -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<Partial<BreakInput>> {
|
||||
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<void> {
|
||||
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 };
|
||||
|
||||
@@ -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 ?? '')),
|
||||
|
||||
@@ -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> = {}): ComplianceContext {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BreakInput> = {}): Partial<BreakInput> => ({
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user