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

+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 };