WP-04: planning sur données réelles — grille semaine, vue jour, publication par équipe
Le planning quitte le module de démonstration : la grille lit `WeeklySchedule` et `Shift`, et les quatre derniers fichiers de `src/lib/demo/` qui portaient des salariés fictifs disparaissent. Modèle - `TeamMember` : rattachement d'un salarié à une équipe, distinct de `MembershipScope` (qui dit ce qu'un manager a le droit de voir). Sans lui, un salarié sans créneau n'apparaîtrait pas dans la grille — c'est-à-dire l'état de départ de toute semaine en construction. - RLS sur `WeeklySchedule`, `Shift`, `Rest`, `DailyNote` et `TeamMember`. Temps - `src/domain/planning/week.ts` : repérage par couple année ISO + semaine ISO, jamais par date de début. Le lundi 29 décembre 2025 appartient à la semaine 1 de 2026 ; une clé fondée sur la date ferait apparaître deux semaines 1. - `zonedInstant` / `zonedMidnight` corrigent le décalage mesuré **à l'instant visé**. Le 29 mars 2026, minuit est en UTC+1 et 09 h en UTC+2 : ajouter neuf heures à minuit donnerait 10 h locales. - La semaine du retour à l'heure d'hiver dure 169 h, celle du passage à l'heure d'été 167 — vérifié par test. Écritures - Création, modification, suppression de créneau ; publication et dépublication **par équipe**, avec verrou optimiste sur `version` : deux managers sur la même grille est le cas normal, pas l'exception. - Chevauchement refusé en transaction, pas seulement dans le formulaire. - Modifier une semaine publiée exige `planning.edit_published`, capacité que le rôle manager n'a pas : un salarié a organisé sa semaine sur ce qu'il a lu. - Toute mutation laisse une entrée d'audit ; la suppression écrit sa trace **avant** l'effacement, sinon l'état supprimé serait perdu. Lecture - `planning.view_unpublished` filtre en base : sans cette capacité, les brouillons ne sont pas chargés du tout. Un test vérifie que les horaires n'apparaissent pas dans le HTML servi — un masquage CSS les y laisserait. - Vue jour reconstruite sur les mêmes données, amplitude déduite de la journée réelle plutôt que figée à 06 h–21 h. Vérification - 26 tests unitaires sur le repérage des semaines et la mise en grille. - Parcours e2e : poser un créneau, refus de chevauchement, publier, dépublier ; et ce que voient un salarié et un manager sur la même semaine. - `scripts/dev-db.sh` : la base de développement est éphémère dans cet environnement, la remonter ne doit pas être une redécouverte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
27 files changed
+3373
-674
No files matched your search
@@ -0,0 +1,499 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError, can, type Actor } from '@/domain/access/authorize';
|
||||
import { shiftMinutes } from '@/domain/counters/week';
|
||||
import {
|
||||
parseWeekParam,
|
||||
weekBounds,
|
||||
zonedInstant,
|
||||
} from '@/domain/planning/week';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
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 shiftInput = z.object({
|
||||
teamId: z.string().min(1),
|
||||
week: 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),
|
||||
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> {
|
||||
const parsed = shiftInput.safeParse({
|
||||
teamId: formData.get('teamId'),
|
||||
week: formData.get('week'),
|
||||
membershipId: formData.get('membershipId') || undefined,
|
||||
localDate: formData.get('localDate'),
|
||||
start: formData.get('start'),
|
||||
end: formData.get('end'),
|
||||
breakMinutes: formData.get('breakMinutes') || 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);
|
||||
|
||||
const startAt = zonedInstant(
|
||||
parsed.data.localDate,
|
||||
parsed.data.start,
|
||||
location.timezone,
|
||||
);
|
||||
let endAt = zonedInstant(
|
||||
parsed.data.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é.',
|
||||
);
|
||||
}
|
||||
|
||||
const { from, to } = weekBounds(week, location.timezone);
|
||||
if (startAt < from || startAt >= to) {
|
||||
throw new ValidationError(
|
||||
"Ce créneau ne commence pas dans la semaine affichée.",
|
||||
);
|
||||
}
|
||||
|
||||
const schedule = await ensureSchedule(
|
||||
db,
|
||||
team.id,
|
||||
location.id,
|
||||
week.isoYear,
|
||||
week.isoWeek,
|
||||
);
|
||||
|
||||
if (schedule.status === 'PUBLISHED') {
|
||||
assertMayEditPublished(actor);
|
||||
}
|
||||
|
||||
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(`${parsed.data.localDate}T00:00:00Z`),
|
||||
startAt,
|
||||
endAt,
|
||||
breakMinutes: parsed.data.breakMinutes,
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
} 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),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
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(),
|
||||
},
|
||||
});
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
|
||||
// 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 } });
|
||||
});
|
||||
} 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),
|
||||
});
|
||||
|
||||
export async function publishWeekAction(
|
||||
_previous: PlanningActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlanningActionState> {
|
||||
return setPublication(formData, true);
|
||||
}
|
||||
|
||||
export async function unpublishWeekAction(
|
||||
_previous: PlanningActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlanningActionState> {
|
||||
return setPublication(formData, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
if (error instanceof AuthorizationError) return { error: denied };
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
import {
|
||||
buildRows,
|
||||
shiftState,
|
||||
type BoardRow,
|
||||
type BoardShiftInput,
|
||||
} from '@/domain/planning/board';
|
||||
import type { ShiftState } from '@/components/planning/ShiftChip';
|
||||
import {
|
||||
dayHeadings,
|
||||
formatWeekParam,
|
||||
nextIsoWeek,
|
||||
previousIsoWeek,
|
||||
weekBounds,
|
||||
weekDates,
|
||||
weekLabel,
|
||||
zonedClock,
|
||||
zonedMidnight,
|
||||
type IsoWeek,
|
||||
} from '@/domain/planning/week';
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import { query } from '@/server/context';
|
||||
import { POSTE_CODES, type PosteCode } from '@/lib/design/postes';
|
||||
import type { ScopedClient } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Lecture de la grille hebdomadaire.
|
||||
*
|
||||
* Une section par équipe : c'est la maille de publication constatée dans
|
||||
* l'audit — chaque section porte son propre bouton « Dépublier », pas
|
||||
* l'établissement entier.
|
||||
*/
|
||||
|
||||
export interface BoardSection {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
/** Vrai quand la semaine existe mais reste masquée faute de capacité. */
|
||||
hidden: boolean;
|
||||
scheduleId: string | null;
|
||||
status: 'DRAFT' | 'VALIDATED' | 'PUBLISHED';
|
||||
version: number;
|
||||
publishedAt: Date | null;
|
||||
rows: BoardRow[];
|
||||
unassignedRow: BoardRow | null;
|
||||
}
|
||||
|
||||
export interface BoardLabel {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
poste: PosteCode;
|
||||
}
|
||||
|
||||
export interface WeekBoard {
|
||||
location: { id: string; name: string; timezone: string };
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
week: IsoWeek;
|
||||
label: string;
|
||||
headings: string[];
|
||||
dates: string[];
|
||||
previousParam: string;
|
||||
nextParam: string;
|
||||
weekParam: string;
|
||||
sections: BoardSection[];
|
||||
labels: BoardLabel[];
|
||||
totals: { plannedMinutes: number; shifts: number; unassigned: number };
|
||||
}
|
||||
|
||||
/** Poste par défaut quand le créneau ne porte pas d'étiquette. */
|
||||
const FALLBACK_POSTE: PosteCode = 'vte';
|
||||
|
||||
function posteOf(paletteKey: string | null | undefined): PosteCode {
|
||||
if (!paletteKey) return FALLBACK_POSTE;
|
||||
return (POSTE_CODES as readonly string[]).includes(paletteKey)
|
||||
? (paletteKey as PosteCode)
|
||||
: FALLBACK_POSTE;
|
||||
}
|
||||
|
||||
export async function listLocations(): Promise<
|
||||
Array<{ id: string; name: string; timezone: string }>
|
||||
> {
|
||||
return query('planning.view', async (db) => {
|
||||
return db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true, timezone: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWeekBoard(
|
||||
week: IsoWeek,
|
||||
locationId?: string,
|
||||
): Promise<WeekBoard | null> {
|
||||
return query(
|
||||
'planning.view',
|
||||
async (db, actor) => {
|
||||
// Un salarié ne voit que ce qui est publié : un brouillon est une
|
||||
// intention de manager, pas une information sur laquelle organiser sa
|
||||
// semaine. La restriction est **serveur** — masquer côté écran laisserait
|
||||
// les créneaux dans la charge utile.
|
||||
const seesDrafts = can(actor, 'planning.view_unpublished');
|
||||
|
||||
const locations = await db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true, timezone: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
const location =
|
||||
locations.find((candidate) => candidate.id === locationId) ??
|
||||
locations[0];
|
||||
if (!location) return null;
|
||||
|
||||
const dates = weekDates(week);
|
||||
const { from, to } = weekBounds(week, location.timezone);
|
||||
|
||||
const teams = await db.team.findMany({
|
||||
where: { locationId: location.id, archivedAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
const schedules = await db.weeklySchedule.findMany({
|
||||
where: {
|
||||
teamId: { in: teams.map((team) => team.id) },
|
||||
isoYear: week.isoYear,
|
||||
isoWeek: week.isoWeek,
|
||||
...(seesDrafts ? {} : { status: 'PUBLISHED' }),
|
||||
},
|
||||
});
|
||||
const scheduleByTeam = new Map(
|
||||
schedules.map((schedule) => [schedule.teamId, schedule]),
|
||||
);
|
||||
|
||||
const shifts = await db.shift.findMany({
|
||||
where: {
|
||||
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
|
||||
// Bornes en instants : un créneau de nuit du dimanche appartient à
|
||||
// cette semaine par son début, quel que soit son jour de fin.
|
||||
startAt: { gte: from, lt: to },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
weeklyScheduleId: true,
|
||||
membershipId: true,
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
breakMinutes: true,
|
||||
isValidated: true,
|
||||
note: true,
|
||||
labelId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const labels = await db.label.findMany({
|
||||
where: { archivedAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
select: { id: true, code: true, name: true, paletteKey: true },
|
||||
});
|
||||
const paletteById = new Map(
|
||||
labels.map((label) => [label.id, label.paletteKey]),
|
||||
);
|
||||
|
||||
const people = await loadPeople(db, teams.map((team) => team.id));
|
||||
|
||||
const sections = teams.map((team) => {
|
||||
const schedule = scheduleByTeam.get(team.id) ?? null;
|
||||
const isPublished = schedule?.status === 'PUBLISHED';
|
||||
const hidden = !seesDrafts && !schedule;
|
||||
const teamShifts: BoardShiftInput[] = shifts
|
||||
.filter((shift) => shift.weeklyScheduleId === schedule?.id)
|
||||
.map((shift) => ({
|
||||
id: shift.id,
|
||||
membershipId: shift.membershipId,
|
||||
startAt: shift.startAt,
|
||||
endAt: shift.endAt,
|
||||
breakMinutes: shift.breakMinutes,
|
||||
poste: posteOf(paletteById.get(shift.labelId ?? '')),
|
||||
isValidated: shift.isValidated,
|
||||
note: shift.note,
|
||||
}));
|
||||
|
||||
const { rows, unassignedRow } = buildRows(
|
||||
people.get(team.id) ?? [],
|
||||
teamShifts,
|
||||
dates,
|
||||
location.timezone,
|
||||
isPublished,
|
||||
);
|
||||
|
||||
return {
|
||||
teamId: team.id,
|
||||
teamName: team.name,
|
||||
hidden,
|
||||
scheduleId: schedule?.id ?? null,
|
||||
status: schedule?.status ?? 'DRAFT',
|
||||
version: schedule?.version ?? 0,
|
||||
publishedAt: schedule?.publishedAt ?? null,
|
||||
rows,
|
||||
unassignedRow,
|
||||
} satisfies BoardSection;
|
||||
});
|
||||
|
||||
const plannedMinutes = sections
|
||||
.flatMap((section) => section.rows)
|
||||
.reduce((total, row) => total + row.counters.plannedMinutes, 0);
|
||||
|
||||
return {
|
||||
location,
|
||||
locations: locations.map(({ id, name }) => ({ id, name })),
|
||||
week,
|
||||
label: weekLabel(week),
|
||||
headings: dayHeadings(week),
|
||||
dates,
|
||||
weekParam: formatWeekParam(week),
|
||||
previousParam: formatWeekParam(previousIsoWeek(week)),
|
||||
nextParam: formatWeekParam(nextIsoWeek(week)),
|
||||
sections,
|
||||
labels: labels.map((label) => ({
|
||||
id: label.id,
|
||||
code: label.code,
|
||||
name: label.name,
|
||||
poste: posteOf(label.paletteKey),
|
||||
})),
|
||||
totals: {
|
||||
plannedMinutes,
|
||||
shifts: shifts.filter((shift) => shift.membershipId).length,
|
||||
unassigned: shifts.filter((shift) => !shift.membershipId).length,
|
||||
},
|
||||
} satisfies WeekBoard;
|
||||
},
|
||||
locationId ? { locationId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Salariés rattachés à chaque équipe, avec leur durée contractuelle. */
|
||||
async function loadPeople(
|
||||
db: ScopedClient,
|
||||
teamIds: string[],
|
||||
): Promise<Map<string, Awaited<ReturnType<typeof toPerson>>[]>> {
|
||||
const assignments = await db.teamMember.findMany({
|
||||
where: { teamId: { in: teamIds } },
|
||||
orderBy: [{ position: 'asc' }],
|
||||
include: {
|
||||
membership: {
|
||||
include: {
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
contracts: {
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { startDate: 'desc' },
|
||||
take: 1,
|
||||
select: {
|
||||
weeklyHours: true,
|
||||
workTimeArrangement: true,
|
||||
jobTitleId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const jobTitleIds = [
|
||||
...new Set(
|
||||
assignments
|
||||
.flatMap((assignment) => assignment.membership.contracts)
|
||||
.map((contract) => contract.jobTitleId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
const jobTitles = await db.jobTitle.findMany({
|
||||
where: { id: { in: jobTitleIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const jobTitleById = new Map(jobTitles.map((job) => [job.id, job.name]));
|
||||
|
||||
const byTeam = new Map<string, Awaited<ReturnType<typeof toPerson>>[]>();
|
||||
for (const assignment of assignments) {
|
||||
const list = byTeam.get(assignment.teamId) ?? [];
|
||||
list.push(toPerson(assignment, jobTitleById));
|
||||
byTeam.set(assignment.teamId, list);
|
||||
}
|
||||
return byTeam;
|
||||
}
|
||||
|
||||
function toPerson(
|
||||
assignment: {
|
||||
membershipId: string;
|
||||
membership: {
|
||||
employeeNumber: string;
|
||||
profile: { firstName: string; lastName: string } | null;
|
||||
contracts: Array<{
|
||||
weeklyHours: { toString(): string };
|
||||
workTimeArrangement: string;
|
||||
jobTitleId: string | null;
|
||||
}>;
|
||||
};
|
||||
},
|
||||
jobTitleById: Map<string, string>,
|
||||
) {
|
||||
const contract = assignment.membership.contracts[0];
|
||||
const forfaitJours = contract?.workTimeArrangement === 'FORFAIT_JOURS';
|
||||
return {
|
||||
membershipId: assignment.membershipId,
|
||||
firstName: assignment.membership.profile?.firstName ?? '',
|
||||
lastName:
|
||||
assignment.membership.profile?.lastName ??
|
||||
assignment.membership.employeeNumber,
|
||||
job: contract?.jobTitleId
|
||||
? (jobTitleById.get(contract.jobTitleId) ?? 'Poste à préciser')
|
||||
: 'Poste à préciser',
|
||||
forfaitJours,
|
||||
contractMinutes: contract
|
||||
? Math.round(Number(contract.weeklyHours.toString()) * 60)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue jour : qui est présent, et quand.
|
||||
*
|
||||
* Elle répond à une question différente de la vue semaine — « qui tient la
|
||||
* caisse à 14 h » plutôt que « qui fait combien d'heures » — d'où une requête
|
||||
* distincte plutôt qu'un filtrage de la grille hebdomadaire.
|
||||
*/
|
||||
|
||||
export interface DayLaneShift {
|
||||
id: string;
|
||||
poste: PosteCode;
|
||||
/** Minutes depuis minuit local ; peut dépasser 1440 sur un créneau de nuit. */
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
state: ShiftState;
|
||||
}
|
||||
|
||||
export interface DayLane {
|
||||
id: string;
|
||||
name: string;
|
||||
job: string;
|
||||
initials: string;
|
||||
unassigned: boolean;
|
||||
shifts: DayLaneShift[];
|
||||
}
|
||||
|
||||
export interface DayBoard {
|
||||
location: { id: string; name: string; timezone: string };
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
isoDate: string;
|
||||
label: string;
|
||||
previousDate: string;
|
||||
nextDate: string;
|
||||
lanes: DayLane[];
|
||||
fromHour: number;
|
||||
toHour: number;
|
||||
}
|
||||
|
||||
const DAY_FORMAT = new Intl.DateTimeFormat('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
function shiftDate(isoDate: string, days: number): string {
|
||||
const base = new Date(`${isoDate}T00:00:00Z`);
|
||||
return new Date(base.getTime() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function getDayBoard(
|
||||
isoDate: string,
|
||||
locationId?: string,
|
||||
): Promise<DayBoard | null> {
|
||||
return query(
|
||||
'planning.view',
|
||||
async (db, actor) => {
|
||||
const seesDrafts = can(actor, 'planning.view_unpublished');
|
||||
|
||||
const locations = await db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true, timezone: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const location =
|
||||
locations.find((candidate) => candidate.id === locationId) ??
|
||||
locations[0];
|
||||
if (!location) return null;
|
||||
|
||||
const from = zonedMidnight(isoDate, location.timezone);
|
||||
const to = zonedMidnight(shiftDate(isoDate, 1), location.timezone);
|
||||
|
||||
const teams = await db.team.findMany({
|
||||
where: { locationId: location.id, archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
const schedules = await db.weeklySchedule.findMany({
|
||||
where: {
|
||||
teamId: { in: teams.map((team) => team.id) },
|
||||
...(seesDrafts ? {} : { status: 'PUBLISHED' }),
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
const statusById = new Map(
|
||||
schedules.map((schedule) => [schedule.id, schedule.status]),
|
||||
);
|
||||
|
||||
const shifts = await db.shift.findMany({
|
||||
where: {
|
||||
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
|
||||
startAt: { gte: from, lt: to },
|
||||
},
|
||||
orderBy: { startAt: 'asc' },
|
||||
});
|
||||
|
||||
const labels = await db.label.findMany({
|
||||
select: { id: true, paletteKey: true },
|
||||
});
|
||||
const paletteById = new Map(
|
||||
labels.map((label) => [label.id, label.paletteKey]),
|
||||
);
|
||||
|
||||
const memberIds = [
|
||||
...new Set(
|
||||
shifts
|
||||
.map((shift) => shift.membershipId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
const members = await db.membership.findMany({
|
||||
where: { id: { in: memberIds } },
|
||||
include: {
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
contracts: {
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { startDate: 'desc' },
|
||||
take: 1,
|
||||
select: { jobTitleId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const jobTitles = await db.jobTitle.findMany({
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const jobTitleById = new Map(jobTitles.map((job) => [job.id, job.name]));
|
||||
|
||||
const laneById = new Map<string, DayLane>();
|
||||
for (const member of members) {
|
||||
const jobTitleId = member.contracts[0]?.jobTitleId;
|
||||
laneById.set(member.id, {
|
||||
id: member.id,
|
||||
name: `${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
|
||||
job: jobTitleId
|
||||
? (jobTitleById.get(jobTitleId) ?? 'Poste à préciser')
|
||||
: 'Poste à préciser',
|
||||
initials:
|
||||
`${member.profile?.firstName?.charAt(0) ?? ''}${member.profile?.lastName?.charAt(0) ?? ''}`.toUpperCase(),
|
||||
unassigned: false,
|
||||
shifts: [],
|
||||
});
|
||||
}
|
||||
|
||||
const unassignedLane: DayLane = {
|
||||
id: 'unassigned',
|
||||
name: 'Non assigné',
|
||||
job: 'Besoins à couvrir',
|
||||
initials: '?',
|
||||
unassigned: true,
|
||||
shifts: [],
|
||||
};
|
||||
|
||||
for (const shift of shifts) {
|
||||
const startMinutes = localMinutes(shift.startAt, location.timezone);
|
||||
let endMinutes = localMinutes(shift.endAt, location.timezone);
|
||||
// Un créneau de nuit finit « avant » son début en heure murale : le
|
||||
// reporter au lendemain garde la barre continue au lieu de l'inverser.
|
||||
if (endMinutes <= startMinutes) endMinutes += 24 * 60;
|
||||
|
||||
const isPublished =
|
||||
statusById.get(shift.weeklyScheduleId) === 'PUBLISHED';
|
||||
const lane = shift.membershipId
|
||||
? laneById.get(shift.membershipId)
|
||||
: unassignedLane;
|
||||
lane?.shifts.push({
|
||||
id: shift.id,
|
||||
poste: posteOf(paletteById.get(shift.labelId ?? '')),
|
||||
startMinutes,
|
||||
endMinutes,
|
||||
state: shiftState(shift.isValidated, isPublished, shift.membershipId),
|
||||
});
|
||||
}
|
||||
|
||||
const lanes = [...laneById.values()]
|
||||
.filter((lane) => lane.shifts.length > 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr'));
|
||||
if (unassignedLane.shifts.length > 0) lanes.push(unassignedLane);
|
||||
|
||||
// L'amplitude s'ajuste à la journée réelle : figer 06 h–21 h couperait
|
||||
// un inventaire de nuit, et étirerait une journée creuse.
|
||||
const all = lanes.flatMap((lane) => lane.shifts);
|
||||
const fromHour = all.length
|
||||
? Math.max(0, Math.floor(Math.min(...all.map((s) => s.startMinutes)) / 60) - 1)
|
||||
: 7;
|
||||
const toHour = all.length
|
||||
? Math.min(30, Math.ceil(Math.max(...all.map((s) => s.endMinutes)) / 60) + 1)
|
||||
: 21;
|
||||
|
||||
return {
|
||||
location,
|
||||
locations: locations.map(({ id, name }) => ({ id, name })),
|
||||
isoDate,
|
||||
label: DAY_FORMAT.format(new Date(`${isoDate}T00:00:00Z`)),
|
||||
previousDate: shiftDate(isoDate, -1),
|
||||
nextDate: shiftDate(isoDate, 1),
|
||||
lanes,
|
||||
fromHour,
|
||||
toHour: Math.max(toHour, fromHour + 4),
|
||||
} satisfies DayBoard;
|
||||
},
|
||||
locationId ? { locationId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Minutes depuis minuit, en heure locale de l'établissement. */
|
||||
function localMinutes(instant: Date, timeZone: string): number {
|
||||
const [hours, minutes] = zonedClock(instant, timeZone).split(':').map(Number);
|
||||
return (hours ?? 0) * 60 + (minutes ?? 0);
|
||||
}
|
||||
Reference in new issue
Block a user