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
205 lines
6.1 KiB
TypeScript
205 lines
6.1 KiB
TypeScript
/**
|
||
* Mise en forme de la grille hebdomadaire.
|
||
*
|
||
* Volontairement séparé de l'accès aux données : ranger sept jours de créneaux
|
||
* en colonnes est la partie où l'on se trompe (fuseau, semaine à cheval,
|
||
* créneau de nuit), et elle se teste sans base.
|
||
*/
|
||
|
||
import type { ShiftState } from '@/components/planning/ShiftChip';
|
||
import type { WeekCounters } from '@/domain/counters/week';
|
||
import { shiftMinutes } from '@/domain/counters/week';
|
||
import { zonedClock, zonedDate } from '@/domain/planning/week';
|
||
import type { PosteCode } from '@/lib/design/postes';
|
||
|
||
export interface BoardShiftInput {
|
||
id: string;
|
||
membershipId: string | null;
|
||
startAt: Date;
|
||
endAt: Date;
|
||
breakMinutes: number;
|
||
poste: PosteCode;
|
||
isValidated: boolean;
|
||
note: string | null;
|
||
}
|
||
|
||
export interface BoardShift {
|
||
id: string;
|
||
poste: PosteCode;
|
||
/** « 09:00–17:00 », en heure locale de l'établissement. */
|
||
time: string;
|
||
minutes: number;
|
||
breakMinutes: number;
|
||
state: ShiftState;
|
||
note: string | null;
|
||
}
|
||
|
||
export interface BoardRow {
|
||
/** `null` sur la ligne des besoins non couverts. */
|
||
membershipId: string | null;
|
||
firstName: string;
|
||
lastName: string;
|
||
job: string;
|
||
forfaitJours: boolean;
|
||
counters: WeekCounters;
|
||
/** Sept cases, lundi → dimanche. */
|
||
days: BoardShift[][];
|
||
unassigned: boolean;
|
||
}
|
||
|
||
export interface RowPerson {
|
||
membershipId: string;
|
||
firstName: string;
|
||
lastName: string;
|
||
job: string;
|
||
forfaitJours: boolean;
|
||
contractMinutes: number;
|
||
}
|
||
|
||
/**
|
||
* Range les créneaux d'une semaine en lignes × sept colonnes.
|
||
*
|
||
* Le jour de rattachement se déduit de `startAt` **vu depuis le fuseau de
|
||
* l'établissement**, pas de la colonne `localDate` : celle-ci est un cache de
|
||
* regroupement, et un créneau déplacé sans la mettre à jour se rangerait au
|
||
* mauvais endroit. Ici, les deux ne peuvent pas diverger.
|
||
*/
|
||
export function buildRows(
|
||
people: RowPerson[],
|
||
shifts: BoardShiftInput[],
|
||
weekDates: string[],
|
||
timeZone: string,
|
||
isPublished: boolean,
|
||
): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
|
||
const columnOf = new Map(weekDates.map((date, index) => [date, index]));
|
||
|
||
const emptyDays = (): BoardShift[][] =>
|
||
Array.from({ length: 7 }, () => [] as BoardShift[]);
|
||
|
||
const byMember = new Map<string, BoardShift[][]>();
|
||
const minutesByMember = new Map<string, number>();
|
||
const sundaysByMember = new Map<string, Set<number>>();
|
||
const unassignedDays = emptyDays();
|
||
let hasUnassigned = false;
|
||
|
||
for (const shift of shifts) {
|
||
const column = columnOf.get(zonedDate(shift.startAt, timeZone));
|
||
// Un créneau hors des sept jours ne peut pas s'afficher : le taire vaut
|
||
// mieux que de l'écraser sur lundi, où il fausserait le compteur.
|
||
if (column === undefined) continue;
|
||
|
||
const minutes = shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes);
|
||
const view: BoardShift = {
|
||
id: shift.id,
|
||
poste: shift.poste,
|
||
time: `${zonedClock(shift.startAt, timeZone)}–${zonedClock(shift.endAt, timeZone)}`,
|
||
minutes,
|
||
breakMinutes: shift.breakMinutes,
|
||
state: shiftState(shift.isValidated, isPublished, shift.membershipId),
|
||
note: shift.note,
|
||
};
|
||
|
||
if (!shift.membershipId) {
|
||
unassignedDays[column]?.push(view);
|
||
hasUnassigned = true;
|
||
continue;
|
||
}
|
||
|
||
let days = byMember.get(shift.membershipId);
|
||
if (!days) {
|
||
days = emptyDays();
|
||
byMember.set(shift.membershipId, days);
|
||
}
|
||
days[column]?.push(view);
|
||
minutesByMember.set(
|
||
shift.membershipId,
|
||
(minutesByMember.get(shift.membershipId) ?? 0) + minutes,
|
||
);
|
||
if (column === 6) {
|
||
const sundays =
|
||
sundaysByMember.get(shift.membershipId) ?? new Set<number>();
|
||
sundays.add(column);
|
||
sundaysByMember.set(shift.membershipId, sundays);
|
||
}
|
||
}
|
||
|
||
for (const days of byMember.values()) {
|
||
for (const column of days) column.sort(byStartTime);
|
||
}
|
||
for (const column of unassignedDays) column.sort(byStartTime);
|
||
|
||
const rows = people.map((person) => {
|
||
const days = byMember.get(person.membershipId) ?? emptyDays();
|
||
const worked = days.filter((column) => column.length > 0).length;
|
||
return {
|
||
membershipId: person.membershipId,
|
||
firstName: person.firstName,
|
||
lastName: person.lastName,
|
||
job: person.job,
|
||
forfaitJours: person.forfaitJours,
|
||
unassigned: false,
|
||
days,
|
||
counters: {
|
||
contractMinutes: person.forfaitJours ? 0 : person.contractMinutes,
|
||
plannedMinutes: minutesByMember.get(person.membershipId) ?? 0,
|
||
// Les absences arrivent au lot suivant ; le compteur existe déjà pour
|
||
// que la formule d'écart ne change pas quand elles se brancheront.
|
||
absenceMinutes: 0,
|
||
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
|
||
restDays: 7 - worked,
|
||
},
|
||
} satisfies BoardRow;
|
||
});
|
||
|
||
const unassignedRow: BoardRow | null = hasUnassigned
|
||
? {
|
||
membershipId: null,
|
||
firstName: 'Non',
|
||
lastName: 'assigné',
|
||
job: 'Besoins à couvrir',
|
||
forfaitJours: false,
|
||
unassigned: true,
|
||
days: unassignedDays,
|
||
counters: {
|
||
contractMinutes: 0,
|
||
plannedMinutes: unassignedDays
|
||
.flat()
|
||
.reduce((total, shift) => total + shift.minutes, 0),
|
||
absenceMinutes: 0,
|
||
sundaysWorked: 0,
|
||
restDays: 0,
|
||
},
|
||
}
|
||
: null;
|
||
|
||
return { rows, unassignedRow };
|
||
}
|
||
|
||
function byStartTime(a: BoardShift, b: BoardShift): number {
|
||
return a.time.localeCompare(b.time);
|
||
}
|
||
|
||
/**
|
||
* État visuel d'un créneau.
|
||
*
|
||
* L'ordre des tests compte : un besoin non couvert reste affiché en fantôme
|
||
* même sur une semaine publiée, sinon il se confondrait avec un créneau réel.
|
||
*/
|
||
export function shiftState(
|
||
isValidated: boolean,
|
||
isPublished: boolean,
|
||
membershipId: string | null,
|
||
): ShiftState {
|
||
if (!membershipId) return 'unassigned';
|
||
if (isValidated) return 'validated';
|
||
return isPublished ? 'published' : 'draft';
|
||
}
|
||
|
||
export function initialsOf(row: BoardRow): string {
|
||
return `${row.firstName.charAt(0)}${row.lastName.charAt(0)}`.toUpperCase();
|
||
}
|
||
|
||
export function displayName(row: BoardRow): string {
|
||
return `${row.firstName} ${row.lastName}`;
|
||
}
|