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:
MichaelandClaude Opus 5 committed 2026-08-11 14:32:03 +02:00
1 parent aa7336d749
commit fab497acdc
17 files changed
+802 -21

No files matched your search

+149 -13
View File
@@ -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.
*
+8 -3
View File
@@ -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,
},
+9
View File
@@ -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;
}
+37
View File
@@ -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.
*
+16
View File
@@ -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),
+161
View File
@@ -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;
}
+4
View File
@@ -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,
}));
+1
View File
@@ -37,6 +37,7 @@ export type EmailKind =
| 'INVITATION'
| 'PASSWORD_RESET'
| 'PLANNING_PUBLISHED'
| 'SHIFT_ASSIGNED'
| 'TIMEOFF_DECISION'
| 'LEAVE_NOTICE';
+166 -4
View File
@@ -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 };
+17
View File
@@ -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 ?? '')),