Le panneau créait un créneau, pour un jour. Or un même horaire se répète d'un jour à l'autre bien plus souvent qu'il ne varie : ressaisir cinq fois « 9 h–17 h, pause 30 » était le geste le plus répété de la semaine, et le plus exposé à la faute de frappe. Les sept jours sont désormais cochables. Le jour ouvert est coché et verrouillé — on ne crée pas ailleurs qu'ici depuis la case d'un jour donné — et voyage dans un champ caché, puisqu'une case désactivée n'est pas envoyée. La création est **tout ou rien**. Un jour refusé, pour période close ou chevauchement, annule les autres : une répétition à demi appliquée laisserait un planning que personne n'a voulu, et qu'il faudrait défaire à la main pour le refaire. Le contrôle de période porte donc sur tous les jours demandés avant la première écriture. Trois champs du modèle étaient inatteignables depuis l'écran : le nombre de repas, la note du créneau, et l'étiquette en modification. Cette dernière était un vrai défaut — une étiquette posée de travers se corrigeait en supprimant le créneau pour le refaire, ce qui perdait sa validation et son historique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
838 lines
27 KiB
TypeScript
838 lines
27 KiB
TypeScript
'use server';
|
||
|
||
import { revalidatePath } from 'next/cache';
|
||
import { z } from 'zod';
|
||
|
||
import {
|
||
PUBLICATION_INTENT_FIELD,
|
||
PUBLISH_INTENT,
|
||
} from '@/domain/planning/publication';
|
||
import { AuthorizationError, can, type Actor } from '@/domain/access/authorize';
|
||
import { shiftMinutes } from '@/domain/counters/week';
|
||
import {
|
||
parseWeekParam,
|
||
weekBounds,
|
||
weekDates,
|
||
zonedClock,
|
||
zonedDate,
|
||
zonedInstant,
|
||
} from '@/domain/planning/week';
|
||
import { recordAudit } from '@/server/audit';
|
||
import { evaluateAround, evaluateSchedule } from '@/server/compliance/evaluate';
|
||
import { mutate } from '@/server/context';
|
||
import { assertPeriodOpen, PeriodLockedError } from '@/server/payroll/periods';
|
||
import type { ScopedClient } from '@/server/tenant';
|
||
|
||
/**
|
||
* Écritures du planning.
|
||
*
|
||
* Deux règles gouvernent ce fichier :
|
||
*
|
||
* 1. **Les instants font foi.** Le formulaire envoie une date civile et deux
|
||
* heures locales ; ils sont convertis en instants dans le fuseau de
|
||
* l'établissement avant toute écriture. `localDate` n'est qu'un cache de
|
||
* regroupement, jamais une source de vérité.
|
||
* 2. **Publier est une décision, la modifier ensuite en est une autre.**
|
||
* Toucher à une semaine publiée exige une capacité distincte : un salarié a
|
||
* organisé sa semaine sur ce qu'il a lu.
|
||
*/
|
||
|
||
export interface PlanningActionState {
|
||
error?: string;
|
||
ok?: boolean;
|
||
}
|
||
|
||
class ValidationError extends Error {}
|
||
|
||
const HOUR = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||
|
||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
||
const shiftInput = z.object({
|
||
teamId: z.string().min(1),
|
||
week: z.string().min(1),
|
||
membershipId: z.string().optional(),
|
||
/**
|
||
* Jours à créer, un créneau par jour.
|
||
*
|
||
* Un même horaire se répète d'un jour à l'autre bien plus souvent qu'il ne
|
||
* varie : ressaisir cinq fois « 9 h–17 h, pause 30 » est le geste le plus
|
||
* répété de la semaine, et le plus exposé à la faute de frappe.
|
||
*/
|
||
localDates: z
|
||
.array(z.string().regex(ISO_DATE, 'Date invalide'))
|
||
.min(1, 'Sélectionnez au moins un jour')
|
||
.max(7),
|
||
start: z.string().regex(HOUR, 'Heure de début invalide'),
|
||
end: z.string().regex(HOUR, 'Heure de fin invalide'),
|
||
breakMinutes: z.coerce.number().int().min(0).max(600).default(0),
|
||
mealCount: z.coerce.number().int().min(0).max(5).default(0),
|
||
labelId: z.string().optional(),
|
||
note: z.string().trim().max(500).optional(),
|
||
});
|
||
|
||
async function loadTeamContext(db: ScopedClient, teamId: string) {
|
||
const team = await db.team.findUnique({
|
||
where: { id: teamId },
|
||
select: { id: true, name: true, locationId: true },
|
||
});
|
||
if (!team) throw new AuthorizationError('planning.create');
|
||
|
||
const location = await db.location.findUnique({
|
||
where: { id: team.locationId },
|
||
select: { id: true, timezone: true },
|
||
});
|
||
if (!location) throw new AuthorizationError('planning.create');
|
||
|
||
return { team, location };
|
||
}
|
||
|
||
/**
|
||
* Crée ou remplace la semaine de l'équipe.
|
||
*
|
||
* `upsert` plutôt que `create` : la semaine naît au premier créneau posé, et
|
||
* deux managers qui commencent la même semaine en même temps ne doivent pas
|
||
* produire deux enregistrements — la contrainte d'unicité s'en charge.
|
||
*/
|
||
async function ensureSchedule(
|
||
db: ScopedClient,
|
||
teamId: string,
|
||
locationId: string,
|
||
isoYear: number,
|
||
isoWeek: number,
|
||
) {
|
||
return db.weeklySchedule.upsert({
|
||
where: { teamId_isoYear_isoWeek: { teamId, isoYear, isoWeek } },
|
||
update: {},
|
||
create: { teamId, locationId, isoYear, isoWeek } as never,
|
||
});
|
||
}
|
||
|
||
export async function createShiftAction(
|
||
_previous: PlanningActionState,
|
||
formData: FormData,
|
||
): Promise<PlanningActionState> {
|
||
// Les cases de répétition portent le même nom : la case du jour ouvert est
|
||
// cochée d'office, si bien qu'un envoi sans répétition renvoie exactement une
|
||
// date. Le repli sur `localDate` couvre le formulaire d'un client qui aurait
|
||
// désactivé JavaScript.
|
||
const checked = formData.getAll('localDates').map(String).filter(Boolean);
|
||
const single = String(formData.get('localDate') ?? '');
|
||
const localDates = checked.length > 0 ? [...new Set(checked)] : single ? [single] : [];
|
||
|
||
const parsed = shiftInput.safeParse({
|
||
teamId: formData.get('teamId'),
|
||
week: formData.get('week'),
|
||
membershipId: formData.get('membershipId') || undefined,
|
||
localDates,
|
||
start: formData.get('start'),
|
||
end: formData.get('end'),
|
||
breakMinutes: formData.get('breakMinutes') || 0,
|
||
mealCount: formData.get('mealCount') || 0,
|
||
labelId: formData.get('labelId') || undefined,
|
||
note: formData.get('note') || undefined,
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||
}
|
||
|
||
const week = parseWeekParam(parsed.data.week);
|
||
if (!week) return { error: 'Semaine invalide.' };
|
||
|
||
try {
|
||
await mutate('planning.create', async (db, actor) => {
|
||
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
|
||
|
||
// Un mois transmis au cabinet ne se modifie pas par inadvertance : le
|
||
// contrôle passe **avant** toute écriture, et porte sur tous les jours
|
||
// demandés — répéter sur cinq jours ne doit pas en glisser un dans une
|
||
// période close.
|
||
await assertPeriodOpen(db, location.id, parsed.data.localDates);
|
||
|
||
const schedule = await ensureSchedule(
|
||
db,
|
||
team.id,
|
||
location.id,
|
||
week.isoYear,
|
||
week.isoWeek,
|
||
);
|
||
|
||
if (schedule.status === 'PUBLISHED') {
|
||
assertMayEditPublished(actor);
|
||
}
|
||
|
||
const { from, to } = weekBounds(week, location.timezone);
|
||
|
||
// 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
|
||
// pour le refaire.
|
||
for (const localDate of parsed.data.localDates) {
|
||
const startAt = zonedInstant(
|
||
localDate,
|
||
parsed.data.start,
|
||
location.timezone,
|
||
);
|
||
let endAt = zonedInstant(localDate, parsed.data.end, location.timezone);
|
||
// Fin avant début = créneau de nuit : il finit le lendemain. Le refuser
|
||
// 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);
|
||
if (worked <= 0) {
|
||
throw new ValidationError(
|
||
'La pause dépasse la durée du créneau — rien ne serait travaillé.',
|
||
);
|
||
}
|
||
|
||
if (startAt < from || startAt >= to) {
|
||
throw new ValidationError(
|
||
'Un des jours demandés ne commence pas dans la semaine affichée.',
|
||
);
|
||
}
|
||
|
||
if (parsed.data.membershipId) {
|
||
await assertNoOverlap(
|
||
db,
|
||
parsed.data.membershipId,
|
||
startAt,
|
||
endAt,
|
||
null,
|
||
);
|
||
}
|
||
|
||
const created = await db.shift.create({
|
||
data: {
|
||
weeklyScheduleId: schedule.id,
|
||
membershipId: parsed.data.membershipId ?? null,
|
||
localDate: new Date(`${localDate}T00:00:00Z`),
|
||
startAt,
|
||
endAt,
|
||
breakMinutes: parsed.data.breakMinutes,
|
||
mealCount: parsed.data.mealCount,
|
||
labelId: parsed.data.labelId ?? null,
|
||
note: parsed.data.note ?? null,
|
||
} as never,
|
||
});
|
||
|
||
await recordAudit(db, {
|
||
actorMembershipId: actor.membershipId,
|
||
action: 'planning.shift.create',
|
||
entityType: 'Shift',
|
||
entityId: created.id,
|
||
after: {
|
||
teamId: team.id,
|
||
membershipId: created.membershipId,
|
||
startAt: startAt.toISOString(),
|
||
endAt: endAt.toISOString(),
|
||
minutes: worked,
|
||
mealCount: created.mealCount,
|
||
},
|
||
});
|
||
}
|
||
|
||
await assertNoBlocking(db, schedule.id);
|
||
});
|
||
} catch (error) {
|
||
return toState(error, 'Vous ne pouvez pas créer de créneau ici.');
|
||
}
|
||
|
||
revalidatePath('/planning/semaine');
|
||
return { ok: true };
|
||
}
|
||
|
||
const moveInput = z.object({
|
||
shiftId: z.string().min(1),
|
||
membershipId: z.string().optional(),
|
||
localDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date invalide'),
|
||
start: z.string().regex(HOUR, 'Heure de début invalide'),
|
||
end: z.string().regex(HOUR, 'Heure de fin invalide'),
|
||
breakMinutes: z.coerce.number().int().min(0).max(600).default(0),
|
||
mealCount: z.coerce.number().int().min(0).max(5).default(0),
|
||
// Modifiables à la correction comme à la création : une étiquette posée de
|
||
// travers se corrigeait jusqu'ici en supprimant le créneau pour le refaire,
|
||
// ce qui perdait sa validation et son historique.
|
||
labelId: z.string().optional(),
|
||
note: z.string().trim().max(500).optional(),
|
||
});
|
||
|
||
export async function updateShiftAction(
|
||
_previous: PlanningActionState,
|
||
formData: FormData,
|
||
): Promise<PlanningActionState> {
|
||
const parsed = moveInput.safeParse({
|
||
shiftId: formData.get('shiftId'),
|
||
membershipId: formData.get('membershipId') || undefined,
|
||
localDate: formData.get('localDate'),
|
||
start: formData.get('start'),
|
||
end: formData.get('end'),
|
||
breakMinutes: formData.get('breakMinutes') || 0,
|
||
mealCount: formData.get('mealCount') || 0,
|
||
labelId: formData.get('labelId') || undefined,
|
||
note: formData.get('note') || undefined,
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||
}
|
||
|
||
try {
|
||
await mutate('planning.edit', async (db, actor) => {
|
||
const shift = await db.shift.findUnique({
|
||
where: { id: parsed.data.shiftId },
|
||
include: { schedule: true },
|
||
});
|
||
if (!shift) throw new AuthorizationError('planning.edit');
|
||
|
||
const location = await db.location.findUnique({
|
||
where: { id: shift.schedule.locationId },
|
||
select: { timezone: true },
|
||
});
|
||
if (!location) throw new AuthorizationError('planning.edit');
|
||
|
||
if (shift.schedule.status === 'PUBLISHED') {
|
||
assertMayEditPublished(actor);
|
||
}
|
||
|
||
await assertPeriodOpen(db, shift.schedule.locationId, [
|
||
shift.localDate.toISOString().slice(0, 10),
|
||
parsed.data.localDate,
|
||
]);
|
||
|
||
const startAt = zonedInstant(
|
||
parsed.data.localDate,
|
||
parsed.data.start,
|
||
location.timezone,
|
||
);
|
||
let endAt = zonedInstant(
|
||
parsed.data.localDate,
|
||
parsed.data.end,
|
||
location.timezone,
|
||
);
|
||
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
|
||
|
||
if (shiftMinutes(startAt, endAt, parsed.data.breakMinutes) <= 0) {
|
||
throw new ValidationError(
|
||
'La pause dépasse la durée du créneau — rien ne serait travaillé.',
|
||
);
|
||
}
|
||
|
||
const membershipId = parsed.data.membershipId ?? null;
|
||
if (membershipId) {
|
||
await assertNoOverlap(db, membershipId, startAt, endAt, shift.id);
|
||
}
|
||
|
||
await db.shift.update({
|
||
where: { id: shift.id },
|
||
data: {
|
||
membershipId,
|
||
localDate: new Date(`${parsed.data.localDate}T00:00:00Z`),
|
||
startAt,
|
||
endAt,
|
||
breakMinutes: parsed.data.breakMinutes,
|
||
mealCount: parsed.data.mealCount,
|
||
labelId: parsed.data.labelId ?? null,
|
||
note: parsed.data.note ?? null,
|
||
version: { increment: 1 },
|
||
},
|
||
});
|
||
|
||
await recordAudit(db, {
|
||
actorMembershipId: actor.membershipId,
|
||
action: 'planning.shift.update',
|
||
entityType: 'Shift',
|
||
entityId: shift.id,
|
||
before: {
|
||
membershipId: shift.membershipId,
|
||
startAt: shift.startAt.toISOString(),
|
||
endAt: shift.endAt.toISOString(),
|
||
},
|
||
after: {
|
||
membershipId,
|
||
startAt: startAt.toISOString(),
|
||
endAt: endAt.toISOString(),
|
||
},
|
||
});
|
||
|
||
await assertNoBlocking(db, shift.weeklyScheduleId);
|
||
});
|
||
} catch (error) {
|
||
return toState(error, 'Vous ne pouvez pas modifier ce créneau.');
|
||
}
|
||
|
||
revalidatePath('/planning/semaine');
|
||
return { ok: true };
|
||
}
|
||
|
||
export async function deleteShiftAction(
|
||
_previous: PlanningActionState,
|
||
formData: FormData,
|
||
): Promise<PlanningActionState> {
|
||
const shiftId = String(formData.get('shiftId') ?? '');
|
||
if (!shiftId) return { error: 'Créneau introuvable.' };
|
||
|
||
try {
|
||
await mutate('planning.delete', async (db, actor) => {
|
||
const shift = await db.shift.findUnique({
|
||
where: { id: shiftId },
|
||
include: { schedule: true },
|
||
});
|
||
if (!shift) throw new AuthorizationError('planning.delete');
|
||
|
||
if (shift.schedule.status === 'PUBLISHED') {
|
||
assertMayEditPublished(actor);
|
||
}
|
||
|
||
await assertPeriodOpen(db, shift.schedule.locationId, [
|
||
shift.localDate.toISOString().slice(0, 10),
|
||
]);
|
||
|
||
// La trace est écrite **avant** la suppression : après, l'identifiant ne
|
||
// désigne plus rien, et l'état supprimé serait perdu.
|
||
await recordAudit(db, {
|
||
actorMembershipId: actor.membershipId,
|
||
action: 'planning.shift.delete',
|
||
entityType: 'Shift',
|
||
entityId: shift.id,
|
||
before: {
|
||
membershipId: shift.membershipId,
|
||
startAt: shift.startAt.toISOString(),
|
||
endAt: shift.endAt.toISOString(),
|
||
},
|
||
});
|
||
|
||
await db.shift.delete({ where: { id: shift.id } });
|
||
|
||
// Supprimer ne peut pas créer d'incohérence, mais peut en résoudre une :
|
||
// la semaine et ses voisines sont réévaluées pour que les badges
|
||
// disparaissent avec le créneau.
|
||
await evaluateAround(db, shift.weeklyScheduleId);
|
||
});
|
||
} catch (error) {
|
||
return toState(error, 'Vous ne pouvez pas supprimer ce créneau.');
|
||
}
|
||
|
||
revalidatePath('/planning/semaine');
|
||
return { ok: true };
|
||
}
|
||
|
||
const publishInput = z.object({
|
||
teamId: z.string().min(1),
|
||
week: z.string().min(1),
|
||
expectedVersion: z.coerce.number().int().min(0),
|
||
/** Justification exigée dès qu'une alerte non acquittée subsiste. */
|
||
acknowledgement: z.string().trim().max(500).optional(),
|
||
});
|
||
|
||
/**
|
||
* Publier et dépublier par une **seule** action, l'intention venant du
|
||
* formulaire.
|
||
*
|
||
* Passer tantôt l'une tantôt l'autre à `useActionState` selon l'état paraît
|
||
* naturel et ne fonctionne pas : le formulaire cesse de suivre le changement
|
||
* d'action, et un bouton « Dépublier » finit par republier — sans message
|
||
* d'erreur, puisque l'action exécutée réussit.
|
||
*
|
||
* Mesuré sur trois envois consécutifs, en lisant l'en-tête `Next-Action` :
|
||
*
|
||
* 1er envoi next-action: 60b8097e (dépublier) → brouillon
|
||
* 2e envoi next-action: 6012d40f (publier) → publiée
|
||
* 3e envoi next-action: 6012d40f (publier) → publiée
|
||
*
|
||
* L'intention voyage donc avec le bouton, `name`/`value` sur le déclencheur :
|
||
* un champ caché serait remis à sa valeur d'origine par la réinitialisation
|
||
* que React applique après chaque action, un bouton ne l'est jamais.
|
||
*/
|
||
export async function setWeekPublicationAction(
|
||
_previous: PlanningActionState,
|
||
formData: FormData,
|
||
): Promise<PlanningActionState> {
|
||
return setPublication(
|
||
formData,
|
||
formData.get(PUBLICATION_INTENT_FIELD) === PUBLISH_INTENT,
|
||
);
|
||
}
|
||
|
||
const duplicateInput = z.object({
|
||
teamId: z.string().min(1),
|
||
/** Semaine à copier. */
|
||
source: z.string().min(1),
|
||
/** Semaine de destination. */
|
||
target: z.string().min(1),
|
||
});
|
||
|
||
/**
|
||
* Duplique une semaine vers une autre.
|
||
*
|
||
* C'est le geste le plus fréquent du métier : les plannings de commerce se
|
||
* répètent d'une semaine à l'autre, à quelques ajustements près. La copie
|
||
* reporte les créneaux **jour à jour** — le lundi source devient le lundi
|
||
* cible — et recalcule les instants dans le fuseau de l'établissement, sans
|
||
* quoi une copie franchissant un changement d'heure décalerait tout d'une
|
||
* heure.
|
||
*
|
||
* La destination doit être **vide** : écraser silencieusement le travail de
|
||
* quelqu'un d'autre est pire que refuser.
|
||
*/
|
||
export async function duplicateWeekAction(
|
||
_previous: PlanningActionState,
|
||
formData: FormData,
|
||
): Promise<PlanningActionState> {
|
||
const parsed = duplicateInput.safeParse({
|
||
teamId: formData.get('teamId'),
|
||
source: formData.get('source'),
|
||
target: formData.get('target'),
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||
}
|
||
|
||
const source = parseWeekParam(parsed.data.source);
|
||
const target = parseWeekParam(parsed.data.target);
|
||
if (!source || !target) return { error: 'Semaine invalide.' };
|
||
if (source.isoYear === target.isoYear && source.isoWeek === target.isoWeek) {
|
||
return { error: 'La semaine source et la semaine cible sont identiques.' };
|
||
}
|
||
|
||
let copied = 0;
|
||
|
||
try {
|
||
await mutate('planning.duplicate', async (db, actor) => {
|
||
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
|
||
|
||
const from = await db.weeklySchedule.findUnique({
|
||
where: {
|
||
teamId_isoYear_isoWeek: {
|
||
teamId: team.id,
|
||
isoYear: source.isoYear,
|
||
isoWeek: source.isoWeek,
|
||
},
|
||
},
|
||
});
|
||
if (!from) {
|
||
throw new ValidationError("La semaine à copier n'existe pas.");
|
||
}
|
||
|
||
const shifts = await db.shift.findMany({
|
||
where: { weeklyScheduleId: from.id },
|
||
});
|
||
if (shifts.length === 0) {
|
||
throw new ValidationError('La semaine à copier ne contient aucun créneau.');
|
||
}
|
||
|
||
const to = await ensureSchedule(
|
||
db,
|
||
team.id,
|
||
location.id,
|
||
target.isoYear,
|
||
target.isoWeek,
|
||
);
|
||
|
||
const existing = await db.shift.count({
|
||
where: { weeklyScheduleId: to.id },
|
||
});
|
||
if (existing > 0) {
|
||
throw new ValidationError(
|
||
'La semaine de destination contient déjà des créneaux. Videz-la avant de dupliquer.',
|
||
);
|
||
}
|
||
|
||
const sourceDates = weekDates(source);
|
||
const targetDates = weekDates(target);
|
||
const dayOf = new Map(sourceDates.map((date, index) => [date, index]));
|
||
|
||
for (const shift of shifts) {
|
||
const column = dayOf.get(zonedDate(shift.startAt, location.timezone));
|
||
if (column === undefined) continue;
|
||
|
||
const targetDate = targetDates[column] as string;
|
||
// Report par heure locale, pas par décalage de sept jours : entre
|
||
// mars et avril, sept jours d'écart ne redonnent pas la même heure.
|
||
const startClock = zonedClock(shift.startAt, location.timezone);
|
||
const endClock = zonedClock(shift.endAt, location.timezone);
|
||
|
||
const startAt = zonedInstant(targetDate, startClock, location.timezone);
|
||
let endAt = zonedInstant(targetDate, endClock, location.timezone);
|
||
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
|
||
|
||
await db.shift.create({
|
||
data: {
|
||
weeklyScheduleId: to.id,
|
||
membershipId: shift.membershipId,
|
||
localDate: new Date(`${targetDate}T00:00:00Z`),
|
||
startAt,
|
||
endAt,
|
||
breakMinutes: shift.breakMinutes,
|
||
labelId: shift.labelId,
|
||
note: shift.note,
|
||
} as never,
|
||
});
|
||
copied += 1;
|
||
}
|
||
|
||
await assertNoBlocking(db, to.id);
|
||
|
||
await recordAudit(db, {
|
||
actorMembershipId: actor.membershipId,
|
||
action: 'planning.week.duplicate',
|
||
entityType: 'WeeklySchedule',
|
||
entityId: to.id,
|
||
after: {
|
||
teamId: team.id,
|
||
from: `${source.isoYear}-W${source.isoWeek}`,
|
||
to: `${target.isoYear}-W${target.isoWeek}`,
|
||
shifts: copied,
|
||
},
|
||
});
|
||
});
|
||
} catch (error) {
|
||
return toState(error, 'Vous ne pouvez pas dupliquer cette semaine.');
|
||
}
|
||
|
||
revalidatePath('/planning/semaine');
|
||
return { ok: true };
|
||
}
|
||
|
||
/**
|
||
* Publie ou dépublie la semaine d'une **équipe**.
|
||
*
|
||
* Le verrou optimiste porte sur `version` : deux managers sur la même grille
|
||
* est le cas normal, pas l'exception. Sans lui, le second écraserait la
|
||
* décision du premier sans que personne ne le voie (PLAN.md §3.4).
|
||
*/
|
||
async function setPublication(
|
||
formData: FormData,
|
||
publish: boolean,
|
||
): Promise<PlanningActionState> {
|
||
const parsed = publishInput.safeParse({
|
||
teamId: formData.get('teamId'),
|
||
week: formData.get('week'),
|
||
expectedVersion: formData.get('expectedVersion') ?? 0,
|
||
acknowledgement: formData.get('acknowledgement') || undefined,
|
||
});
|
||
|
||
if (!parsed.success) {
|
||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||
}
|
||
|
||
const week = parseWeekParam(parsed.data.week);
|
||
if (!week) return { error: 'Semaine invalide.' };
|
||
|
||
try {
|
||
await mutate(publish ? 'planning.publish' : 'planning.unpublish', async (db, actor) => {
|
||
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
|
||
const schedule = await ensureSchedule(
|
||
db,
|
||
team.id,
|
||
location.id,
|
||
week.isoYear,
|
||
week.isoWeek,
|
||
);
|
||
|
||
if (publish) {
|
||
const count = await db.shift.count({
|
||
where: { weeklyScheduleId: schedule.id },
|
||
});
|
||
if (count === 0) {
|
||
throw new ValidationError(
|
||
'Cette semaine ne contient aucun créneau : rien à publier.',
|
||
);
|
||
}
|
||
|
||
// Contrôle complet du périmètre publié, pas seulement des créneaux
|
||
// touchés depuis la dernière saisie : publier est l'engagement.
|
||
await evaluateSchedule(db, schedule.id);
|
||
await acknowledgeBeforePublishing(
|
||
db,
|
||
schedule.id,
|
||
actor.membershipId,
|
||
parsed.data.acknowledgement ?? null,
|
||
);
|
||
}
|
||
|
||
const updated = await db.weeklySchedule.updateMany({
|
||
where: { id: schedule.id, version: parsed.data.expectedVersion },
|
||
data: {
|
||
status: publish ? 'PUBLISHED' : 'DRAFT',
|
||
publishedAt: publish ? new Date() : null,
|
||
publishedBy: publish ? actor.membershipId : null,
|
||
version: { increment: 1 },
|
||
},
|
||
});
|
||
|
||
if (updated.count === 0) {
|
||
throw new ValidationError(
|
||
'Cette semaine a été modifiée entre-temps. Rechargez la page avant de recommencer.',
|
||
);
|
||
}
|
||
|
||
await recordAudit(db, {
|
||
actorMembershipId: actor.membershipId,
|
||
action: publish ? 'planning.publish' : 'planning.unpublish',
|
||
entityType: 'WeeklySchedule',
|
||
entityId: schedule.id,
|
||
before: { status: schedule.status, version: schedule.version },
|
||
after: {
|
||
status: publish ? 'PUBLISHED' : 'DRAFT',
|
||
teamId: team.id,
|
||
isoYear: week.isoYear,
|
||
isoWeek: week.isoWeek,
|
||
},
|
||
});
|
||
});
|
||
} catch (error) {
|
||
return toState(
|
||
error,
|
||
publish
|
||
? 'Vous ne pouvez pas publier cette semaine.'
|
||
: 'Vous ne pouvez pas dépublier cette semaine.',
|
||
);
|
||
}
|
||
|
||
revalidatePath('/planning/semaine');
|
||
return { ok: true };
|
||
}
|
||
|
||
|
||
|
||
/**
|
||
* Publier malgré une alerte suppose de l'assumer.
|
||
*
|
||
* Un `BLOCKING` interdit la publication : c'est une incohérence, pas un choix.
|
||
* Un `WARNING` reste franchissable, mais la justification est enregistrée sur
|
||
* chaque constat et laisse une entrée d'audit — c'est ce qui distingue une
|
||
* dérogation assumée d'une alerte ignorée.
|
||
*/
|
||
async function acknowledgeBeforePublishing(
|
||
db: ScopedClient,
|
||
scheduleId: string,
|
||
actorMembershipId: string,
|
||
reason: string | null,
|
||
): Promise<void> {
|
||
const blocking = await db.complianceViolation.findFirst({
|
||
where: { weeklyScheduleId: scheduleId, severity: 'BLOCKING' },
|
||
select: { message: true },
|
||
});
|
||
if (blocking) {
|
||
throw new ValidationError(
|
||
`Publication impossible — ${blocking.message}`,
|
||
);
|
||
}
|
||
|
||
const pending = await db.complianceViolation.findMany({
|
||
where: {
|
||
weeklyScheduleId: scheduleId,
|
||
severity: 'WARNING',
|
||
acknowledgedAt: null,
|
||
},
|
||
select: { id: true, ruleCode: true, message: true },
|
||
});
|
||
if (pending.length === 0) return;
|
||
|
||
if (!reason) {
|
||
throw new ValidationError(
|
||
`${pending.length} alerte${pending.length > 1 ? 's' : ''} de convention non justifiée${pending.length > 1 ? 's' : ''} : ${pending[0]?.message ?? ''} Indiquez un motif pour publier malgré tout.`,
|
||
);
|
||
}
|
||
|
||
const now = new Date();
|
||
await db.complianceViolation.updateMany({
|
||
where: { id: { in: pending.map((entry) => entry.id) } },
|
||
data: {
|
||
acknowledgedBy: actorMembershipId,
|
||
acknowledgedAt: now,
|
||
acknowledgementReason: reason,
|
||
},
|
||
});
|
||
|
||
await recordAudit(db, {
|
||
actorMembershipId,
|
||
action: 'planning.alert.acknowledge',
|
||
entityType: 'WeeklySchedule',
|
||
entityId: scheduleId,
|
||
after: {
|
||
acknowledged: pending.length,
|
||
rules: pending.map((entry) => entry.ruleCode).join(', '),
|
||
},
|
||
reason,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Réévalue la semaine touchée et ses voisines, et refuse l'enregistrement si un
|
||
* constat bloquant apparaît.
|
||
*
|
||
* Les `BLOCKING` sont des incohérences de données — chevauchement, créneau
|
||
* pendant une absence — pas des arbitrages d'organisation. La transaction est
|
||
* annulée : mieux vaut refuser une saisie que garder en base un planning dont
|
||
* les heures se comptent deux fois.
|
||
*/
|
||
async function assertNoBlocking(
|
||
db: ScopedClient,
|
||
scheduleId: string,
|
||
): Promise<void> {
|
||
await evaluateAround(db, scheduleId);
|
||
|
||
const blocking = await db.complianceViolation.findFirst({
|
||
where: { weeklyScheduleId: scheduleId, severity: 'BLOCKING' },
|
||
select: { message: true },
|
||
});
|
||
|
||
if (blocking) throw new ValidationError(blocking.message);
|
||
}
|
||
|
||
/**
|
||
* Deux créneaux qui se chevauchent pour le même salarié sont impossibles dans
|
||
* la réalité, et compteraient deux fois en heures. Le contrôle est en base et
|
||
* en transaction — un contrôle côté formulaire ne survit pas à deux managers
|
||
* qui planifient en même temps.
|
||
*/
|
||
async function assertNoOverlap(
|
||
db: ScopedClient,
|
||
membershipId: string,
|
||
startAt: Date,
|
||
endAt: Date,
|
||
ignoreShiftId: string | null,
|
||
): Promise<void> {
|
||
const clash = await db.shift.findFirst({
|
||
where: {
|
||
membershipId,
|
||
startAt: { lt: endAt },
|
||
endAt: { gt: startAt },
|
||
...(ignoreShiftId ? { id: { not: ignoreShiftId } } : {}),
|
||
},
|
||
select: { id: true },
|
||
});
|
||
|
||
if (clash) {
|
||
throw new ValidationError(
|
||
'Ce salarié a déjà un créneau qui recouvre cette plage.',
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Une semaine publiée n'est pas gelée, mais la toucher est une seconde
|
||
* décision : le salarié a organisé sa semaine sur ce qu'il a lu. D'où une
|
||
* capacité distincte de `planning.edit`, que le rôle manager n'a pas par
|
||
* défaut.
|
||
*/
|
||
function assertMayEditPublished(actor: Actor): void {
|
||
if (!can(actor, 'planning.edit_published')) {
|
||
throw new ValidationError(
|
||
'Cette semaine est publiée. Dépubliez-la, ou demandez le droit de modifier une semaine publiée.',
|
||
);
|
||
}
|
||
}
|
||
|
||
function toState(error: unknown, denied: string): PlanningActionState {
|
||
if (error instanceof ValidationError) 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 };
|
||
if (error instanceof AuthorizationError) return { error: denied };
|
||
throw error;
|
||
}
|