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:
Claude committed 2026-08-07 23:43:48 +00:00
1 parent e9d459366d
commit 023f236881
27 files changed
+3373 -674

No files matched your search

+2 -2
View File
@@ -40,12 +40,12 @@ export default defineConfig({
// Parcours d'authentification : doit partir d'un navigateur vierge.
{
name: 'anonyme',
testMatch: /(auth|reglages)\.spec\.ts/,
testMatch: /(auth|reglages|acces)\.spec\.ts/,
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
},
{
name: 'chromium',
testIgnore: /(auth\.setup|auth\.spec|reglages\.spec)\.ts/,
testIgnore: /(auth\.setup|auth\.spec|reglages\.spec|acces\.spec)\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
@@ -0,0 +1,102 @@
-- CreateEnum
CREATE TYPE "ScheduleStatus" AS ENUM ('DRAFT', 'VALIDATED', 'PUBLISHED');
-- CreateEnum
CREATE TYPE "RestType" AS ENUM ('WEEKLY_REST', 'COMPENSATORY_REST');
-- CreateTable
CREATE TABLE "WeeklySchedule" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"isoYear" INTEGER NOT NULL,
"isoWeek" INTEGER NOT NULL,
"status" "ScheduleStatus" NOT NULL DEFAULT 'DRAFT',
"publishedAt" TIMESTAMP(3),
"publishedBy" TEXT,
"version" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "WeeklySchedule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Shift" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"weeklyScheduleId" TEXT NOT NULL,
"membershipId" TEXT,
"localDate" DATE NOT NULL,
"startAt" TIMESTAMPTZ NOT NULL,
"endAt" TIMESTAMPTZ NOT NULL,
"breakMinutes" INTEGER NOT NULL DEFAULT 0,
"actualStartAt" TIMESTAMPTZ,
"actualEndAt" TIMESTAMPTZ,
"actualBreakMinutes" INTEGER,
"labelId" TEXT,
"mealCount" INTEGER NOT NULL DEFAULT 0,
"isValidated" BOOLEAN NOT NULL DEFAULT false,
"validatedAt" TIMESTAMP(3),
"validatedBy" TEXT,
"note" TEXT,
"version" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Shift_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rest" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"weeklyScheduleId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"localDate" DATE NOT NULL,
"restType" "RestType" NOT NULL,
"minutes" INTEGER,
CONSTRAINT "Rest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DailyNote" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"weeklyScheduleId" TEXT NOT NULL,
"localDate" DATE NOT NULL,
"content" TEXT NOT NULL,
CONSTRAINT "DailyNote_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WeeklySchedule_accountId_idx" ON "WeeklySchedule"("accountId");
-- CreateIndex
CREATE INDEX "WeeklySchedule_locationId_isoYear_isoWeek_idx" ON "WeeklySchedule"("locationId", "isoYear", "isoWeek");
-- CreateIndex
CREATE UNIQUE INDEX "WeeklySchedule_teamId_isoYear_isoWeek_key" ON "WeeklySchedule"("teamId", "isoYear", "isoWeek");
-- CreateIndex
CREATE INDEX "Shift_accountId_idx" ON "Shift"("accountId");
-- CreateIndex
CREATE INDEX "Shift_weeklyScheduleId_localDate_idx" ON "Shift"("weeklyScheduleId", "localDate");
-- CreateIndex
CREATE INDEX "Shift_membershipId_localDate_idx" ON "Shift"("membershipId", "localDate");
-- CreateIndex
CREATE INDEX "Rest_accountId_idx" ON "Rest"("accountId");
-- CreateIndex
CREATE INDEX "DailyNote_accountId_idx" ON "DailyNote"("accountId");
-- AddForeignKey
ALTER TABLE "Shift" ADD CONSTRAINT "Shift_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rest" ADD CONSTRAINT "Rest_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DailyNote" ADD CONSTRAINT "DailyNote_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,25 @@
-- Étend l'isolation aux tables du planning.
--
-- La grille est la table la plus lue de l'application : c'est aussi celle où
-- une fuite inter-comptes serait la plus visible. Le test « toute table portant
-- accountId est protégée » échoue sans ceci.
DO $$
DECLARE
t text;
BEGIN
FOREACH t IN ARRAY ARRAY['WeeklySchedule', 'Shift', 'Rest', 'DailyNote']
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())',
t
);
EXECUTE format(
'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())',
t
);
END LOOP;
END;
$$;
@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "TeamMember" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"isPrimary" BOOLEAN NOT NULL DEFAULT true,
"position" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TeamMember_accountId_idx" ON "TeamMember"("accountId");
-- CreateIndex
CREATE INDEX "TeamMember_membershipId_idx" ON "TeamMember"("membershipId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamMember_teamId_membershipId_key" ON "TeamMember"("teamId", "membershipId");
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Isolation : `TeamMember` porte accountId, donc la politique est exigible.
ALTER TABLE "TeamMember" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TeamMember" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "TeamMember"
USING ("accountId" = planflow_current_account());
CREATE POLICY tenant_insert ON "TeamMember"
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
+128
View File
@@ -66,11 +66,35 @@ model Team {
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
scopes MembershipScope[]
members TeamMember[]
@@index([accountId])
@@index([locationId])
}
/// Rattachement d'un salarié à une équipe.
///
/// Distinct de `MembershipScope`, qui dit ce qu'un manager a le droit de voir.
/// Celui-ci dit où un salarié **apparaît dans la grille** — y compris une
/// semaine où il n'a aucun créneau, ce qui est justement l'état de départ
/// quand on construit un planning.
model TeamMember {
id String @id @default(cuid())
accountId String
teamId String
membershipId String
/// Équipe d'affectation principale ; les autres sont des renforts.
isPrimary Boolean @default(true)
position Int @default(0)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
@@unique([teamId, membershipId])
@@index([accountId])
@@index([membershipId])
}
// ============================================================================
// Identité
// ============================================================================
@@ -122,6 +146,7 @@ model Membership {
profile EmployeeProfile?
contracts UserContract[]
workPermits WorkPermit[]
teams TeamMember[]
@@unique([accountId, employeeNumber])
@@index([accountId])
@@ -545,3 +570,106 @@ model WorkloadReview {
@@index([accountId])
@@index([userContractId])
}
// ============================================================================
// Planning — PLAN.md §4.4, WP-04
// ============================================================================
/// Semaine de planning d'une équipe.
///
/// La publication est **par équipe** : l'audit montre un bouton « Dépublier »
/// sur chaque section de la grille, pas un seul pour l'établissement.
model WeeklySchedule {
id String @id @default(cuid())
accountId String
locationId String
teamId String
isoYear Int
isoWeek Int
status ScheduleStatus @default(DRAFT)
publishedAt DateTime?
publishedBy String?
/// Verrou optimiste : la grille s'édite à plusieurs (PLAN.md §3.4).
version Int @default(0)
shifts Shift[]
rests Rest[]
dailyNotes DailyNote[]
@@unique([teamId, isoYear, isoWeek])
@@index([accountId])
@@index([locationId, isoYear, isoWeek])
}
enum ScheduleStatus {
DRAFT
VALIDATED
PUBLISHED
}
model Shift {
id String @id @default(cuid())
accountId String
weeklyScheduleId String
/// Null = besoin non couvert, affiché sur sa propre ligne.
membershipId String?
/// Regroupement dans la grille uniquement. Les durées se calculent depuis
/// les instants — la nuit du changement d'heure, 22 h–06 h dure 7 h ou 9 h.
localDate DateTime @db.Date
startAt DateTime @db.Timestamptz
endAt DateTime @db.Timestamptz
breakMinutes Int @default(0)
actualStartAt DateTime? @db.Timestamptz
actualEndAt DateTime? @db.Timestamptz
actualBreakMinutes Int?
labelId String?
mealCount Int @default(0)
isValidated Boolean @default(false)
validatedAt DateTime?
validatedBy String?
note String?
version Int @default(0)
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
@@index([accountId])
@@index([weeklyScheduleId, localDate])
@@index([membershipId, localDate])
}
/// Repos de journée — hebdomadaire ou compensateur. Les pauses *dans* un
/// créneau sont `Shift.breakMinutes`.
model Rest {
id String @id @default(cuid())
accountId String
weeklyScheduleId String
membershipId String
localDate DateTime @db.Date
restType RestType
minutes Int?
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
@@index([accountId])
}
enum RestType {
WEEKLY_REST
COMPENSATORY_REST
}
/// Ligne « Notes et événements » de la grille.
model DailyNote {
id String @id @default(cuid())
accountId String
weeklyScheduleId String
localDate DateTime @db.Date
content String
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
@@index([accountId])
}
+287
View File
@@ -9,6 +9,13 @@ import {
PERMISSION_DEFINITIONS,
SYSTEM_ROLES,
} from '../src/domain/access/permissions';
import {
isoWeekOf,
previousIsoWeek,
weekDates,
zonedInstant,
} from '../src/domain/planning/week';
import { POSTE_CODES, POSTE_LABELS } from '../src/lib/design/postes';
/**
* Jeu de données de départ — PLAN.md §11.
@@ -240,6 +247,165 @@ async function main() {
}
console.log(` ${contractSpecs.length} contrats`);
console.log('→ Étiquettes de planning');
for (const [index, code] of POSTE_CODES.entries()) {
await prisma.label.upsert({
where: { accountId_code: { accountId: account.id, code: code.toUpperCase() } },
update: { name: POSTE_LABELS[code], paletteKey: code, position: index },
create: {
accountId: account.id,
code: code.toUpperCase(),
name: POSTE_LABELS[code],
paletteKey: code,
position: index,
},
});
}
console.log(` ${POSTE_CODES.length} étiquettes`);
console.log('→ Intitulés de poste');
const jobTitles = [
'Responsable de magasin',
'Adjoint·e de direction',
'Vendeur·se conseil',
'Hôte·sse de caisse',
'Employé·e de réserve',
];
const jobTitleIds = new Map<string, string>();
for (const name of jobTitles) {
const created = await prisma.jobTitle.upsert({
where: { accountId_name: { accountId: account.id, name } },
update: {},
create: { accountId: account.id, name },
});
jobTitleIds.set(name, created.id);
}
console.log('→ Salariés sans compte applicatif');
// La majorité d'une équipe de vente ne se connecte jamais à l'outil : ces
// salariés existent en planning et en paie, sans identifiants.
const staff = [
{ number: 'E0005', first: 'Sofia', last: 'Marchetti', job: 'Vendeur·se conseil', hours: 35, team: 0, location: 'loc-nantes' },
{ number: 'E0006', first: 'Yanis', last: 'Trabelsi', job: 'Vendeur·se conseil', hours: 30, team: 0, location: 'loc-nantes' },
{ number: 'E0007', first: 'Léa', last: 'Nguyen', job: 'Hôte·sse de caisse', hours: 24, team: 1, location: 'loc-nantes' },
{ number: 'E0008', first: 'Marius', last: 'Kowalski', job: 'Hôte·sse de caisse', hours: 35, team: 1, location: 'loc-nantes' },
{ number: 'E0009', first: 'Awa', last: 'Diallo', job: 'Employé·e de réserve', hours: 35, team: 2, location: 'loc-nantes' },
{ number: 'E0010', first: 'Théo', last: 'Berger', job: 'Vendeur·se conseil', hours: 28, team: 0, location: 'loc-rennes' },
{ number: 'E0011', first: 'Clara', last: 'Fontaine', job: 'Hôte·sse de caisse', hours: 35, team: 1, location: 'loc-rennes' },
{ number: 'E0012', first: 'Noé', last: 'Perrin', job: 'Employé·e de réserve', hours: 20, team: 2, location: 'loc-rennes' },
] as const;
for (const person of staff) {
const membership = await prisma.membership.upsert({
where: {
accountId_employeeNumber: {
accountId: account.id,
employeeNumber: person.number,
},
},
update: { status: 'ACTIVE' },
create: {
accountId: account.id,
roleId: roleIds.get('employee') as string,
employeeNumber: person.number,
status: 'ACTIVE',
},
});
await prisma.employeeProfile.upsert({
where: { membershipId: membership.id },
update: { firstName: person.first, lastName: person.last },
create: {
membershipId: membership.id,
accountId: account.id,
firstName: person.first,
lastName: person.last,
city: person.location === 'loc-rennes' ? 'Rennes' : 'Nantes',
},
});
const existing = await prisma.userContract.findFirst({
where: { membershipId: membership.id },
});
if (!existing) {
await prisma.userContract.create({
data: {
accountId: account.id,
membershipId: membership.id,
locationId: person.location,
contractType: 'CDI',
startDate: new Date('2024-09-02'),
workTimeArrangement: 'HOURLY',
weeklyHours: person.hours,
jobTitleId: jobTitleIds.get(person.job) ?? null,
monthlySalary: 1900,
},
});
}
await prisma.teamMember.upsert({
where: {
teamId_membershipId: {
teamId: `${person.location}-${person.team}`,
membershipId: membership.id,
},
},
update: {},
create: {
accountId: account.id,
teamId: `${person.location}-${person.team}`,
membershipId: membership.id,
},
});
}
console.log(` ${staff.length} salariés`);
console.log('→ Rattachements des titulaires de comptes');
// Les quatre comptes applicatifs planifient aussi : sans rattachement, ils
// n'apparaîtraient pas dans la grille.
const accountHolders = [
{ number: 'E0001', team: 'loc-nantes-0', job: 'Responsable de magasin' },
{ number: 'E0002', team: 'loc-nantes-0', job: 'Adjoint·e de direction' },
{ number: 'E0003', team: 'loc-rennes-0', job: 'Responsable de magasin' },
{ number: 'E0004', team: 'loc-nantes-1', job: 'Hôte·sse de caisse' },
] as const;
for (const holder of accountHolders) {
const membership = await prisma.membership.findUnique({
where: {
accountId_employeeNumber: {
accountId: account.id,
employeeNumber: holder.number,
},
},
});
if (!membership) continue;
await prisma.userContract.updateMany({
where: { membershipId: membership.id },
data: { jobTitleId: jobTitleIds.get(holder.job) ?? null },
});
await prisma.teamMember.upsert({
where: {
teamId_membershipId: {
teamId: holder.team,
membershipId: membership.id,
},
},
update: {},
create: {
accountId: account.id,
teamId: holder.team,
membershipId: membership.id,
},
});
}
console.log('→ Plannings');
await seedPlanning(account.id);
console.log('→ Durées de conservation');
const retention = [
['Shift', 12, 'creation', 'Décompte des horaires : 1 an minimum (matrice n° 21).'],
@@ -274,6 +440,127 @@ async function main() {
console.log(`\nMot de passe de démonstration : ${DEMO_PASSWORD}`);
}
/**
* Deux semaines de planning : la précédente publiée, la courante en brouillon.
*
* Semer les deux états est délibéré — c'est la seule façon de voir en un coup
* d'œil que la publication est bien **par équipe** et que les créneaux d'une
* semaine publiée se distinguent d'un brouillon.
*/
async function seedPlanning(accountId: string): Promise<void> {
const current = isoWeekOf(new Date());
const previous = previousIsoWeek(current);
const teams = await prisma.team.findMany({
where: { accountId },
orderBy: [{ locationId: 'asc' }, { position: 'asc' }],
});
const locations = await prisma.location.findMany({ where: { accountId } });
const timezoneOf = new Map(locations.map((l) => [l.id, l.timezone]));
const labels = await prisma.label.findMany({ where: { accountId } });
const labelOf = new Map(labels.map((label) => [label.paletteKey, label.id]));
/** Poste dominant de l'équipe, par position. */
const teamPoste = ['vte', 'cai', 'res'];
/** Trois plages types du commerce de détail. */
const patterns = [
{ start: '09:00', end: '17:00', pause: 60 },
{ start: '11:00', end: '19:00', pause: 60 },
{ start: '13:00', end: '20:00', pause: 30 },
];
let shiftsCreated = 0;
for (const week of [previous, current]) {
const dates = weekDates(week);
for (const team of teams) {
const timezone = timezoneOf.get(team.locationId) ?? 'Europe/Paris';
const members = await prisma.teamMember.findMany({
where: { teamId: team.id },
orderBy: { position: 'asc' },
});
if (members.length === 0) continue;
const schedule = await prisma.weeklySchedule.upsert({
where: {
teamId_isoYear_isoWeek: {
teamId: team.id,
isoYear: week.isoYear,
isoWeek: week.isoWeek,
},
},
update: {},
create: {
accountId,
teamId: team.id,
locationId: team.locationId,
isoYear: week.isoYear,
isoWeek: week.isoWeek,
status: week === previous ? 'PUBLISHED' : 'DRAFT',
publishedAt: week === previous ? new Date() : null,
},
});
const already = await prisma.shift.count({
where: { weeklyScheduleId: schedule.id },
});
if (already > 0) continue;
const poste = teamPoste[team.position] ?? 'vte';
const labelId = labelOf.get(poste) ?? null;
for (const [index, member] of members.entries()) {
const pattern = patterns[index % patterns.length] as (typeof patterns)[number];
// Deux jours de repos glissants : la semaine de six jours consécutifs
// est justement ce que les règles de convention interdisent.
const rest = new Set([(index * 2) % 7, ((index * 2) + 1) % 7]);
for (const [dayIndex, date] of dates.entries()) {
if (rest.has(dayIndex)) continue;
await prisma.shift.create({
data: {
accountId,
weeklyScheduleId: schedule.id,
membershipId: member.membershipId,
localDate: new Date(`${date}T00:00:00Z`),
startAt: zonedInstant(date, pattern.start, timezone),
endAt: zonedInstant(date, pattern.end, timezone),
breakMinutes: pattern.pause,
labelId,
isValidated: week === previous,
},
});
shiftsCreated += 1;
}
}
// Un besoin non couvert par équipe sur la semaine courante : c'est l'état
// normal d'un planning en construction, et la ligne dédiée doit s'afficher.
if (week === current) {
const saturday = dates[5] as string;
await prisma.shift.create({
data: {
accountId,
weeklyScheduleId: schedule.id,
membershipId: null,
localDate: new Date(`${saturday}T00:00:00Z`),
startAt: zonedInstant(saturday, '10:00', timezone),
endAt: zonedInstant(saturday, '18:00', timezone),
breakMinutes: 60,
labelId,
},
});
shiftsCreated += 1;
}
}
}
console.log(` ${shiftsCreated} créneaux sur 2 semaines`);
}
main()
.catch((error) => {
console.error(error);
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Base PostgreSQL de développement, locale et jetable.
#
# L'environnement d'exécution est éphémère : le répertoire de données disparaît
# entre deux sessions. Ce script est idempotent — il initialise si besoin,
# démarre sinon, et ne touche pas à un serveur déjà en marche.
#
# Le compte de connexion est ici l'administrateur du cluster, donc
# superutilisateur : il **contourne la row-level security** (voir
# src/server/db-guard.ts, qui l'avertit au démarrage et refuse en production).
# C'est acceptable en développement, et les tests d'isolation
# (tests/integration/rls.test.ts) créent de toute façon leur propre rôle
# restreint — sans quoi ils ne prouveraient rien.
set -euo pipefail
PGBIN=${PGBIN:-/usr/lib/postgresql/16/bin}
PGDATA=${PGDATA:-/tmp/planflow-pg}
PGPORT=${PGPORT:-55432}
DB=${DB:-planflow}
ADMIN=${ADMIN:-planflow}
# PostgreSQL refuse de tourner en root ; on passe par le compte système.
as_postgres() { if [ "$(id -u)" = 0 ]; then su postgres -c "$1"; else sh -c "$1"; fi; }
mkdir -p "$PGDATA"
if [ "$(id -u)" = 0 ]; then chown -R postgres "$PGDATA"; fi
if [ ! -s "$PGDATA/PG_VERSION" ]; then
as_postgres "$PGBIN/initdb -D $PGDATA -U $ADMIN --auth=trust --encoding=UTF8 >/dev/null"
fi
if ! as_postgres "$PGBIN/pg_ctl -D $PGDATA status >/dev/null 2>&1"; then
as_postgres "$PGBIN/pg_ctl -D $PGDATA -o '-p $PGPORT -k /tmp -c listen_addresses=127.0.0.1' -l $PGDATA/server.log -w start"
fi
psql() { "$PGBIN/psql" -h 127.0.0.1 -p "$PGPORT" -U "$ADMIN" -v ON_ERROR_STOP=1 "$@"; }
psql -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$DB'" | grep -q 1 ||
psql -d postgres -c "CREATE DATABASE $DB OWNER $ADMIN"
echo "postgres prêt sur 127.0.0.1:$PGPORT — DATABASE_URL=postgresql://$ADMIN@127.0.0.1:$PGPORT/$DB"
+82 -25
View File
@@ -1,48 +1,105 @@
import { DayTimeline, type DayLane } from '@/components/planning/DayTimeline';
import Link from 'next/link';
import { DayTimeline } from '@/components/planning/DayTimeline';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { UNASSIGNED_ROW, WEEK_ROWS } from '@/lib/demo/semaine';
import { getDayBoard } from '@/server/planning/queries';
export const metadata = { title: 'Planning · mercredi 12 août · PlanFlow' };
export const metadata = { title: 'Planning · jour · PlanFlow' };
/** Mercredi = index 2 dans la semaine de démonstration. */
const DAY_INDEX = 2;
interface PageProps {
searchParams: Promise<{ jour?: string; etablissement?: string }>;
}
export default function JourPage() {
const lanes: DayLane[] = WEEK_ROWS.map((row) => ({
employee: row.employee,
shifts: row.days[DAY_INDEX] ?? [],
})).filter((lane) => lane.shifts.length > 0);
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const unassigned = UNASSIGNED_ROW.days[DAY_INDEX] ?? [];
if (unassigned.length > 0) {
lanes.push({
employee: UNASSIGNED_ROW.employee,
shifts: unassigned,
unassigned: true,
});
export default async function JourPage({ searchParams }: PageProps) {
const params = await searchParams;
const isoDate =
params.jour && ISO_DATE.test(params.jour)
? params.jour
: new Date().toISOString().slice(0, 10);
const board = await getDayBoard(isoDate, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Planning · jour"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
const href = (jour: string, etablissement = board.location.id) =>
`/planning/jour?jour=${jour}&etablissement=${etablissement}`;
const present = board.lanes.filter((lane) => !lane.unassigned).length;
const open = board.lanes.find((lane) => lane.unassigned)?.shifts.length ?? 0;
return (
<PageBody>
<PageHeader
title="Planning · mercredi 12 août"
subtitle="Nantes Atlantis · amplitude 06:00 – 21:00"
title={`Planning · ${board.label}`}
subtitle={`${board.location.name} · amplitude ${String(board.fromHour).padStart(2, '0')}:00 – ${String(board.toHour % 24).padStart(2, '0')}:00`}
actions={
<>
<Button>Imprimer la journée</Button>
<Button variant="primary">Ajouter un créneau</Button>
<Link
href={href(board.previousDate)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Jour précédent
</Link>
<Link
href={href(board.nextDate)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Jour suivant →
</Link>
</>
}
/>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="accent">{lanes.length} salariés présents</Badge>
<Badge tone="warn">Creux de couverture à 13 h</Badge>
{board.locations.map((location) => (
<Link
key={location.id}
href={href(board.isoDate, location.id)}
aria-current={
location.id === board.location.id ? 'page' : undefined
}
className={
location.id === board.location.id
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
}
>
{location.name}
</Link>
))}
<Badge tone="accent">
{present} salarié{present > 1 ? 's' : ''} présent
{present > 1 ? 's' : ''}
</Badge>
{open > 0 ? (
<Badge tone="warn">
{open} besoin{open > 1 ? 's' : ''} non couvert{open > 1 ? 's' : ''}
</Badge>
) : null}
</div>
<DayTimeline lanes={lanes} fromHour={6} toHour={21} />
{board.lanes.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Personne n’est planifié ce jour-là.
</p>
) : (
<DayTimeline
lanes={board.lanes}
fromHour={board.fromHour}
toHour={board.toHour}
/>
)}
</PageBody>
);
}
+100 -18
View File
@@ -1,38 +1,120 @@
import { WeekGrid } from '@/components/planning/WeekGrid';
import Link from 'next/link';
import { TeamSection } from '@/components/planning/TeamSection';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { can } from '@/domain/access/authorize';
import { formatMinutes } from '@/domain/counters/week';
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
import { POSTE_CODES, POSTE_LABELS, posteTokens } from '@/lib/design/postes';
import { UNASSIGNED_ROW, WEEK_DAYS, WEEK_LABEL, WEEK_ROWS } from '@/lib/demo/semaine';
import { requireSession } from '@/server/context';
import { getWeekBoard } from '@/server/planning/queries';
export const metadata = { title: 'Planning · semaine 33 · PlanFlow' };
export const metadata = { title: 'Planning · semaine · PlanFlow' };
interface PageProps {
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
}
export default async function SemainePage({ searchParams }: PageProps) {
const params = await searchParams;
const session = await requireSession();
// Semaine courante par défaut : ouvrir le planning sur une semaine arbitraire
// obligerait à naviguer avant de voir quoi que ce soit d'utile.
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
const board = await getWeekBoard(week, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Planning"
subtitle="Aucun établissement n'est encore créé."
/>
<p className="text-sm text-ink-2">
Créez un établissement dans{' '}
<Link href="/reglages/etablissements" className="underline">
Réglages · Établissements
</Link>{' '}
pour commencer à planifier.
</p>
</PageBody>
);
}
const canEdit = can(session.actor, 'planning.create');
const canPublish = can(session.actor, 'planning.publish');
const href = (semaine: string, etablissement = board.location.id) =>
`/planning/semaine?semaine=${semaine}&etablissement=${etablissement}`;
export default function SemainePage() {
return (
<PageBody>
<PageHeader
title="Planning · semaine 33"
subtitle={`Nantes Atlantis · ${WEEK_LABEL.split('·')[1]?.trim()} · ${WEEK_ROWS.length} salariés planifiés`}
title={`Planning · semaine ${week.isoWeek}`}
subtitle={`${board.location.name} · ${board.label.split('·')[1]?.trim()} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
actions={
<>
<Button>Dupliquer S‑32</Button>
<Button>Imprimer</Button>
<Button variant="primary">Publier la semaine</Button>
<Link
href={href(board.previousParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Semaine précédente
</Link>
<Link
href={href(board.nextParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Semaine suivante →
</Link>
</>
}
/>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="warn">2 alertes de convention</Badge>
<Badge tone="info">Samedi non publié</Badge>
<Badge tone="neutral">5 besoins non couverts</Badge>
{board.locations.map((location) => (
<Link
key={location.id}
href={href(board.weekParam, location.id)}
aria-current={
location.id === board.location.id ? 'page' : undefined
}
className={
location.id === board.location.id
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
}
>
{location.name}
</Link>
))}
{board.totals.unassigned > 0 ? (
<Badge tone="warn">
{board.totals.unassigned} besoin
{board.totals.unassigned > 1 ? 's' : ''} non couvert
{board.totals.unassigned > 1 ? 's' : ''}
</Badge>
) : null}
</div>
<WeekGrid
days={WEEK_DAYS}
rows={WEEK_ROWS}
unassignedRow={UNASSIGNED_ROW}
/>
{board.sections.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Cet établissement n’a pas encore d’équipe.
</p>
) : null}
{board.sections.map((section) => (
<TeamSection
key={section.teamId}
section={section}
days={board.headings}
dates={board.dates}
labels={board.labels}
weekParam={board.weekParam}
canEdit={canEdit}
canPublish={canPublish}
/>
))}
<PosteLegend />
</PageBody>
+20 -18
View File
@@ -1,13 +1,15 @@
import { posteShort, posteTokens } from '@/lib/design/postes';
import { cx } from '@/lib/cx';
import { clock, initials, type DemoEmployee, type DemoShift } from '@/lib/demo/types';
import type { DayLane } from '@/server/planning/queries';
export interface DayLane {
employee: DemoEmployee;
shifts: DemoShift[];
unassigned?: boolean;
/** « 09:00 » depuis des minutes après minuit ; 25 h devient 01:00. */
function clock(minutes: number): string {
const hours = Math.floor(minutes / 60) % 24;
return `${String(hours).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
}
export type { DayLane };
export interface DayTimelineProps {
lanes: DayLane[];
/** Amplitude affichée, en heures locales. */
@@ -32,7 +34,9 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
return lanes.filter(
(lane) =>
!lane.unassigned &&
lane.shifts.some((shift) => shift.start < end && shift.end > start),
lane.shifts.some(
(shift) => shift.startMinutes < end && shift.endMinutes > start,
),
).length;
});
const peak = Math.max(1, ...coverage);
@@ -95,7 +99,7 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
{lanes.map((lane) => (
<div
key={lane.employee.id}
key={lane.id}
className={cx(
'flex border-b border-line-1 last:border-b-0',
lane.unassigned && 'bg-surface-2',
@@ -111,7 +115,7 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
: 'bg-surface-3 text-ink-2',
)}
>
{lane.unassigned ? '?' : initials(lane.employee)}
{lane.initials}
</span>
<span className="min-w-0">
<span
@@ -120,12 +124,10 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
lane.unassigned ? 'text-ink-3' : 'text-ink-1',
)}
>
{lane.unassigned
? 'Non assigné'
: `${lane.employee.firstName} ${lane.employee.lastName}`}
{lane.name}
</span>
<span className="block truncate text-micro text-ink-3">
{lane.employee.job}
{lane.job}
</span>
</span>
</div>
@@ -140,29 +142,29 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
/>
))}
{lane.shifts.map((shift, index) => {
{lane.shifts.map((shift) => {
const tokens = posteTokens(shift.poste);
const ghost = shift.state === 'unassigned';
return (
<div
key={index}
key={shift.id}
className="absolute top-1 bottom-1 flex items-center gap-1.5 overflow-hidden rounded-2 px-2 text-micro"
style={{
left: offset(shift.start),
width: `calc(${offset(shift.end)} - ${offset(shift.start)})`,
left: offset(shift.startMinutes),
width: `calc(${offset(shift.endMinutes)} - ${offset(shift.startMinutes)})`,
background: ghost ? 'transparent' : tokens.bg,
color: ghost ? 'var(--color-ink-2)' : tokens.fg,
border: ghost
? `1px dashed ${tokens.edge}`
: '1px solid transparent',
}}
title={`${posteShort(shift.poste)} · ${clock(shift.start)}–${clock(shift.end)}`}
title={`${posteShort(shift.poste)} · ${clock(shift.startMinutes)}–${clock(shift.endMinutes)}`}
>
<span className="font-semibold tracking-wide">
{posteShort(shift.poste)}
</span>
<span className="tnum truncate opacity-90">
{clock(shift.start)}–{clock(shift.end)}
{clock(shift.startMinutes)}–{clock(shift.endMinutes)}
</span>
</div>
);
+349
View File
@@ -0,0 +1,349 @@
'use client';
import { useActionState, useEffect, useState } from 'react';
import { WeekGrid } from '@/components/planning/WeekGrid';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { formatMinutes } from '@/domain/counters/week';
import { displayName, type BoardRow } from '@/domain/planning/board';
import {
createShiftAction,
deleteShiftAction,
publishWeekAction,
unpublishWeekAction,
type PlanningActionState,
} from '@/server/planning/actions';
import type { BoardLabel, BoardSection } from '@/server/planning/queries';
export interface TeamSectionProps {
section: BoardSection;
days: string[];
dates: string[];
labels: BoardLabel[];
weekParam: string;
/** Faux quand la capacité de modification manque : la grille reste lisible. */
canEdit: boolean;
canPublish: boolean;
}
const empty: PlanningActionState = {};
/**
* Une équipe, une grille, un état de publication.
*
* La publication est **par équipe** : l'audit montre un bouton « Dépublier »
* sur chaque section, pas un pour l'établissement. Le regrouper ferait
* republier la caisse quand on corrige la réserve.
*/
export function TeamSection({
section,
days,
dates,
labels,
weekParam,
canEdit,
canPublish,
}: TeamSectionProps) {
const [target, setTarget] = useState<{
membershipId: string | null;
dayIndex: number;
} | null>(null);
const published = section.status === 'PUBLISHED';
const plannedMinutes = section.rows.reduce(
(total, row) => total + row.counters.plannedMinutes,
0,
);
if (section.hidden) {
// Le brouillon n'est pas seulement masqué : il n'a pas été chargé. Dire
// « pas encore publié » est honnête et n'apprend rien de son contenu.
return (
<section className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-ink-1">
{section.teamName}
</h2>
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Le planning de cette équipe n’est pas encore publié.
</p>
</section>
);
}
return (
<section className="flex flex-col gap-3">
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-ink-1">
{section.teamName}
</h2>
<Badge tone={published ? 'ok' : 'warn'}>
{published ? 'Publiée' : 'Brouillon'}
</Badge>
<span className="text-xs text-ink-3">
{formatMinutes(plannedMinutes)} planifiées
</span>
</div>
{canPublish ? (
<PublishControl
teamId={section.teamId}
weekParam={weekParam}
version={section.version}
published={published}
/>
) : null}
</header>
<WeekGrid
days={days}
dates={dates}
rows={section.rows}
unassignedRow={section.unassignedRow}
{...(canEdit
? {
cellAction: (row: BoardRow, dayIndex: number) => (
<button
type="button"
onClick={() =>
setTarget({ membershipId: row.membershipId, dayIndex })
}
className="mt-auto rounded-2 border border-dashed border-line-2 px-1 py-0.5 text-micro text-ink-3 opacity-0 transition-opacity hover:border-accent hover:text-accent focus-visible:opacity-100 group-hover/cell:opacity-100"
>
+ Créneau
<span className="sr-only">
{` pour ${row.unassigned ? 'un besoin non couvert' : displayName(row)} le ${days[dayIndex] ?? ''}`}
</span>
</button>
),
shiftAction: (shiftId: string) => (
<DeleteShift shiftId={shiftId} />
),
}
: {})}
/>
{target ? (
<ShiftComposer
teamId={section.teamId}
weekParam={weekParam}
labels={labels}
rows={section.rows}
localDate={dates[target.dayIndex] ?? ''}
dayLabel={days[target.dayIndex] ?? ''}
membershipId={target.membershipId}
onClose={() => setTarget(null)}
/>
) : null}
</section>
);
}
function PublishControl({
teamId,
weekParam,
version,
published,
}: {
teamId: string;
weekParam: string;
version: number;
published: boolean;
}) {
const [state, formAction, pending] = useActionState(
published ? unpublishWeekAction : publishWeekAction,
empty,
);
return (
<form action={formAction} className="flex items-center gap-2">
<input type="hidden" name="teamId" value={teamId} />
<input type="hidden" name="week" value={weekParam} />
{/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */}
<input type="hidden" name="expectedVersion" value={version} />
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
</span>
) : null}
<Button
type="submit"
size="sm"
variant={published ? 'secondary' : 'primary'}
disabled={pending}
>
{published ? 'Dépublier' : 'Publier'}
</Button>
</form>
);
}
function DeleteShift({ shiftId }: { shiftId: string }) {
const [state, formAction, pending] = useActionState(deleteShiftAction, empty);
return (
<form action={formAction} className="absolute -top-1 -right-1">
<input type="hidden" name="shiftId" value={shiftId} />
{/* Volontairement pas désactivé pendant l'envoi : la suppression fait
disparaître le créneau, donc le bouton, et un état désactivé
transitoire ne protège de rien tout en rendant la cible instable. */}
<button
type="submit"
aria-busy={pending}
title={state.error ?? 'Supprimer le créneau'}
className="flex size-4 items-center justify-center rounded-full border border-line-2 bg-surface text-micro leading-none text-ink-3 opacity-0 hover:border-danger hover:text-danger focus-visible:opacity-100 group-hover/cell:opacity-100"
>
<span aria-hidden>×</span>
<span className="sr-only">Supprimer le créneau</span>
</button>
</form>
);
}
function ShiftComposer({
teamId,
weekParam,
labels,
rows,
localDate,
dayLabel,
membershipId,
onClose,
}: {
teamId: string;
weekParam: string;
labels: BoardLabel[];
rows: BoardRow[];
localDate: string;
dayLabel: string;
membershipId: string | null;
onClose: () => void;
}) {
const [state, formAction, pending] = useActionState(createShiftAction, empty);
// La grille est déjà revalidée côté serveur ; refermer le panneau évite de
// reposter le même créneau par inadvertance.
useEffect(() => {
if (state.ok) onClose();
}, [state.ok, onClose]);
return (
<form
action={formAction}
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
>
<input type="hidden" name="teamId" value={teamId} />
<input type="hidden" name="week" value={weekParam} />
<input type="hidden" name="localDate" value={localDate} />
<Field label="Jour">
<span className="flex h-8 items-center px-1 text-sm text-ink-2">
{dayLabel}
</span>
</Field>
<Field label="Salarié" htmlFor="composer-membership">
<select
id="composer-membership"
name="membershipId"
defaultValue={membershipId ?? ''}
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
<option value="">Besoin non couvert</option>
{rows.map((row) => (
<option key={row.membershipId} value={row.membershipId ?? ''}>
{displayName(row)}
</option>
))}
</select>
</Field>
<Field label="Début" htmlFor="composer-start">
<input
id="composer-start"
name="start"
type="time"
required
defaultValue="09:00"
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
<Field label="Fin" htmlFor="composer-end">
<input
id="composer-end"
name="end"
type="time"
required
defaultValue="17:00"
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
<Field label="Pause (min)" htmlFor="composer-break">
<input
id="composer-break"
name="breakMinutes"
type="number"
min={0}
max={600}
step={5}
defaultValue={0}
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
<Field label="Poste" htmlFor="composer-label">
<select
id="composer-label"
name="labelId"
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
{labels.map((label) => (
<option key={label.id} value={label.id}>
{label.name}
</option>
))}
</select>
</Field>
<div className="flex items-center gap-2">
<Button type="submit" variant="primary" size="sm" disabled={pending}>
Ajouter
</Button>
<Button type="button" size="sm" onClick={onClose}>
Annuler
</Button>
</div>
{state.error ? (
<p role="alert" className="w-full text-xs text-danger">
{state.error}
</p>
) : null}
</form>
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label
htmlFor={htmlFor}
className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase"
>
{label}
</label>
{children}
</div>
);
}
+75 -47
View File
@@ -1,19 +1,22 @@
import { AbsenceBar } from '@/components/planning/AbsenceBar';
import { CounterStrip } from '@/components/planning/CounterStrip';
import { ShiftChip } from '@/components/planning/ShiftChip';
import { computeWeekCounters } from '@/domain/counters/week';
import { cx } from '@/lib/cx';
import {
fullName,
initials,
timeRange,
type DemoWeekRow,
} from '@/lib/demo/types';
displayName,
initialsOf,
type BoardRow,
} from '@/domain/planning/board';
import { cx } from '@/lib/cx';
export interface WeekGridProps {
days: readonly string[];
rows: DemoWeekRow[];
unassignedRow?: DemoWeekRow;
dates: readonly string[];
rows: BoardRow[];
unassignedRow?: BoardRow | null;
/** Rendu de la case d'un jour : sert à greffer l'ajout d'un créneau. */
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
/** Rendu attaché à un créneau : suppression, édition. */
shiftAction?: (shiftId: string) => React.ReactNode;
}
/**
@@ -24,14 +27,41 @@ export interface WeekGridProps {
* majoré et repos compensateur — et les distinguer d'un coup d'œil évite de les
* planifier par inadvertance.
*/
export function WeekGrid({ days, rows, unassignedRow }: WeekGridProps) {
export function WeekGrid({
days,
dates,
rows,
unassignedRow,
cellAction,
shiftAction,
}: WeekGridProps) {
return (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<div className="min-w-[1040px]">
<GridHeader days={days} />
{unassignedRow ? <Row row={unassignedRow} days={days} /> : null}
{unassignedRow ? (
<Row
row={unassignedRow}
days={days}
dates={dates}
{...(cellAction ? { cellAction } : {})}
{...(shiftAction ? { shiftAction } : {})}
/>
) : null}
{rows.length === 0 && !unassignedRow ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucun salarié rattaché à cette équipe.
</p>
) : null}
{rows.map((row) => (
<Row key={row.employee.id} row={row} days={days} />
<Row
key={row.membershipId ?? 'unassigned'}
row={row}
days={days}
dates={dates}
{...(cellAction ? { cellAction } : {})}
{...(shiftAction ? { shiftAction } : {})}
/>
))}
</div>
</div>
@@ -61,9 +91,21 @@ function GridHeader({ days }: { days: readonly string[] }) {
);
}
function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
function Row({
row,
days,
dates,
cellAction,
shiftAction,
}: {
row: BoardRow;
days: readonly string[];
dates: readonly string[];
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
shiftAction?: (shiftId: string) => React.ReactNode;
}) {
const counters = computeWeekCounters(row.counters);
const name = row.unassigned ? 'Non assigné' : fullName(row.employee);
const name = row.unassigned ? 'Non assigné' : displayName(row);
return (
<div
@@ -86,7 +128,7 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
aria-hidden
className="flex size-6 items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
>
{initials(row.employee)}
{initialsOf(row)}
</span>
)}
<span className="min-w-0">
@@ -99,57 +141,43 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
{name}
</span>
<span className="block truncate text-micro text-ink-3">
{row.employee.job}
{row.job}
</span>
</span>
</div>
<CounterStrip
counters={counters}
forfaitJours={row.employee.forfaitJours ?? false}
unassigned={row.unassigned ?? false}
forfaitJours={row.forfaitJours}
unassigned={row.unassigned}
className="mt-1"
/>
</div>
<div className="relative grid flex-1 grid-cols-7">
{row.absence ? (
<AbsenceBar
kind={row.absence.kind}
label={row.absence.label}
duration={`${row.absence.span} j`}
startDay={row.absence.startDay}
span={row.absence.span}
className="top-1.5"
/>
) : null}
{days.map((day, index) => {
const shifts = row.days[index] ?? [];
return (
<div
key={day}
key={dates[index] ?? day}
className={cx(
'flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
'group/cell flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
index > 4 && 'bg-surface-2',
row.absence &&
index >= row.absence.startDay &&
index < row.absence.startDay + row.absence.span &&
'pt-7',
)}
>
{shifts.map((shift, position) => (
<ShiftChip
key={`${day}-${position}`}
poste={shift.poste}
time={timeRange(shift)}
state={shift.state ?? 'published'}
{...(shift.delta ? { delta: shift.delta } : {})}
{...(shift.alert ? { alert: shift.alert } : {})}
{...(shift.breakMinutes
? { breakMinutes: shift.breakMinutes }
: {})}
/>
{shifts.map((shift) => (
<div key={shift.id} className="relative">
<ShiftChip
poste={shift.poste}
time={shift.time}
state={shift.state}
{...(shift.breakMinutes
? { breakMinutes: shift.breakMinutes }
: {})}
/>
{shiftAction?.(shift.id)}
</div>
))}
{cellAction?.(row, index)}
</div>
);
})}
+204
View File
@@ -0,0 +1,204 @@
/**
* 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}`;
}
+263
View File
@@ -0,0 +1,263 @@
/**
* Repérage des semaines de planning.
*
* Une semaine est désignée par son couple **année ISO + numéro ISO**, jamais
* par une date de début. La raison est pratique : fin décembre, le lundi 29
* décembre 2025 appartient à la semaine 1 de 2026. Stocker « 2025-12-29 »
* comme clé ferait apparaître deux semaines 1 selon le point d'entrée, et la
* contrainte d'unicité `(teamId, isoYear, isoWeek)` ne protégerait plus rien.
*
* Toutes les fonctions de ce module sont pures. Les instants sont calculés en
* UTC pour la clé, et les *jours* de la grille sont produits dans le fuseau de
* l'établissement — les deux ne coïncident pas (PLAN.md §3.3).
*/
export interface IsoWeek {
isoYear: number;
isoWeek: number;
}
const DAY_MS = 86_400_000;
/** Minuit UTC du jour donné, sans dépendre du fuseau de la machine. */
function utcDay(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month - 1, day));
}
/**
* Numéro de semaine ISO 8601 d'une date **civile**.
*
* L'algorithme est celui de la norme : on se déplace au jeudi de la semaine,
* dont l'année donne l'année ISO, puis on compte les semaines depuis le
* 4 janvier. Passer par le jeudi est ce qui rend le résultat correct au
* changement d'année, où la semaine chevauche deux millésimes.
*/
export function isoWeekOf(date: Date): IsoWeek {
const target = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
// getUTCDay : 0 = dimanche. En ISO, lundi = 1 et dimanche = 7.
const dayNumber = target.getUTCDay() === 0 ? 7 : target.getUTCDay();
target.setUTCDate(target.getUTCDate() + 4 - dayNumber);
const isoYear = target.getUTCFullYear();
const firstThursday = utcDay(isoYear, 1, 4);
const firstDayNumber =
firstThursday.getUTCDay() === 0 ? 7 : firstThursday.getUTCDay();
firstThursday.setUTCDate(firstThursday.getUTCDate() + 4 - firstDayNumber);
const isoWeek =
1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * DAY_MS));
return { isoYear, isoWeek };
}
/** Lundi (date civile, minuit UTC) de la semaine ISO donnée. */
export function mondayOfIsoWeek({ isoYear, isoWeek }: IsoWeek): Date {
const jan4 = utcDay(isoYear, 1, 4);
const dayNumber = jan4.getUTCDay() === 0 ? 7 : jan4.getUTCDay();
const week1Monday = new Date(jan4.getTime() - (dayNumber - 1) * DAY_MS);
return new Date(week1Monday.getTime() + (isoWeek - 1) * 7 * DAY_MS);
}
/** Nombre de semaines ISO de l'année : 52, ou 53 les années longues. */
export function weeksInIsoYear(isoYear: number): number {
return isoWeekOf(utcDay(isoYear, 12, 28)).isoWeek;
}
/** Semaine précédente, en franchissant correctement les années à 53 semaines. */
export function previousIsoWeek({ isoYear, isoWeek }: IsoWeek): IsoWeek {
if (isoWeek > 1) return { isoYear, isoWeek: isoWeek - 1 };
const year = isoYear - 1;
return { isoYear: year, isoWeek: weeksInIsoYear(year) };
}
export function nextIsoWeek({ isoYear, isoWeek }: IsoWeek): IsoWeek {
if (isoWeek < weeksInIsoYear(isoYear)) {
return { isoYear, isoWeek: isoWeek + 1 };
}
return { isoYear: isoYear + 1, isoWeek: 1 };
}
/** Les sept dates civiles de la semaine, lundi → dimanche, en `YYYY-MM-DD`. */
export function weekDates(week: IsoWeek): string[] {
const monday = mondayOfIsoWeek(week);
return Array.from({ length: 7 }, (_, index) =>
new Date(monday.getTime() + index * DAY_MS).toISOString().slice(0, 10),
);
}
/**
* Bornes absolues de la semaine dans un fuseau donné.
*
* Sert à charger les créneaux : `startAt >= from && startAt < to`. Les bornes
* sont des **instants**, pas des dates civiles — la semaine du passage à
* l'heure d'hiver dure 169 heures, celle du passage à l'heure d'été 167.
*/
export function weekBounds(
week: IsoWeek,
timeZone: string,
): { from: Date; to: Date } {
const dates = weekDates(week);
const first = dates[0];
const monday = zonedMidnight(first ?? '1970-01-01', timeZone);
const nextMonday = zonedMidnight(
new Date(mondayOfIsoWeek(week).getTime() + 7 * DAY_MS)
.toISOString()
.slice(0, 10),
timeZone,
);
return { from: monday, to: nextMonday };
}
/**
* Instant correspondant à minuit local d'une date civile dans un fuseau.
*
* Il n'existe pas d'API native pour cela : on tâtonne à partir de l'UTC, en
* mesurant le décalage réel de ce fuseau **à cet instant-là**, puis on corrige.
* Deux passes suffisent, y compris le jour d'un changement d'heure.
*/
export function zonedMidnight(isoDate: string, timeZone: string): Date {
const [year, month, day] = isoDate.split('-').map(Number);
const naive = Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1);
let instant = naive;
for (let pass = 0; pass < 2; pass += 1) {
instant = naive - offsetAt(new Date(instant), timeZone);
}
return new Date(instant);
}
/** Décalage du fuseau à un instant donné, en millisecondes. */
export function offsetAt(instant: Date, timeZone: string): number {
// `en-US` avec `hourCycle: 'h23'` évite le « 24:00 » que produit `h24` à
// minuit, qui décalerait le calcul d'une journée entière.
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(instant);
const get = (type: string) =>
Number(parts.find((part) => part.type === type)?.value ?? '0');
const asUtc = Date.UTC(
get('year'),
get('month') - 1,
get('day'),
get('hour'),
get('minute'),
get('second'),
);
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
}
/**
* Instant correspondant à une heure locale « 09:00 » d'une date civile.
*
* Passe par minuit local puis corrige : le jour d'un changement d'heure, le
* décalage à 09 h n'est pas celui de minuit, et ajouter neuf heures à minuit
* donnerait 08 h ou 10 h locales.
*/
export function zonedInstant(
isoDate: string,
clock: string,
timeZone: string,
): Date {
const [hours, minutes] = clock.split(':').map(Number);
const midnight = zonedMidnight(isoDate, timeZone);
const naive = new Date(
midnight.getTime() + ((hours ?? 0) * 60 + (minutes ?? 0)) * 60_000,
);
const reached = offsetAt(naive, timeZone);
const atMidnight = offsetAt(midnight, timeZone);
return reached === atMidnight
? naive
: new Date(naive.getTime() - (reached - atMidnight));
}
/** Date civile (`YYYY-MM-DD`) d'un instant, vue depuis un fuseau. */
export function zonedDate(instant: Date, timeZone: string): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(instant);
}
/** Heure locale « 09:00 » d'un instant, vue depuis un fuseau. */
export function zonedClock(instant: Date, timeZone: string): string {
return new Intl.DateTimeFormat('fr-FR', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(instant);
}
const DAY_LABELS = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.'];
/** « Lun. 10 » — en-tête de colonne de la grille. */
export function dayHeadings(week: IsoWeek): string[] {
return weekDates(week).map((isoDate, index) => {
const label = DAY_LABELS[index] ?? '';
const day = Number(isoDate.slice(8, 10));
return `${label.charAt(0).toUpperCase()}${label.slice(1)} ${day}`;
});
}
const MONTHS = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
/** « Semaine 33 · 10 – 16 août 2026 ». */
export function weekLabel(week: IsoWeek): string {
const dates = weekDates(week);
const first = dates[0] ?? '';
const last = dates[6] ?? '';
const startDay = Number(first.slice(8, 10));
const endDay = Number(last.slice(8, 10));
const startMonth = MONTHS[Number(first.slice(5, 7)) - 1] ?? '';
const endMonth = MONTHS[Number(last.slice(5, 7)) - 1] ?? '';
const endYear = last.slice(0, 4);
const span =
startMonth === endMonth
? `${startDay} – ${endDay} ${endMonth} ${endYear}`
: `${startDay} ${startMonth} – ${endDay} ${endMonth} ${endYear}`;
return `Semaine ${week.isoWeek} · ${span}`;
}
/** Analyse « 2026-W33 » ; renvoie `null` si la forme ou les bornes sont fausses. */
export function parseWeekParam(value: string | undefined): IsoWeek | null {
if (!value) return null;
const match = /^(\d{4})-W(\d{1,2})$/.exec(value);
if (!match) return null;
const isoYear = Number(match[1]);
const isoWeek = Number(match[2]);
if (isoWeek < 1 || isoWeek > weeksInIsoYear(isoYear)) return null;
return { isoYear, isoWeek };
}
export function formatWeekParam({ isoYear, isoWeek }: IsoWeek): string {
return `${isoYear}-W${String(isoWeek).padStart(2, '0')}`;
}
-130
View File
@@ -1,130 +0,0 @@
import type { DemoEmployee } from '@/lib/demo/types';
/** Effectif fictif de l'établissement Nantes Atlantis. */
export const EMPLOYEES: DemoEmployee[] = [
{
id: 'camille-ferrand',
firstName: 'Camille',
lastName: 'Ferrand',
job: 'Responsable univers maison',
poste: 'enc',
contract: 'CDI 39 h',
since: '4 mars 2021',
status: 'actif',
},
{
id: 'jonas-meyer',
firstName: 'Jonas',
lastName: 'Meyer',
job: 'Adjoint magasin',
poste: 'enc',
contract: 'CDI · forfait jours',
since: '12 sept. 2019',
status: 'actif',
forfaitJours: true,
},
{
id: 'remi-chartier',
firstName: 'Rémi',
lastName: 'Chartier',
job: 'Vendeur',
poste: 'vte',
contract: 'CDI 35 h',
since: '2 juin 2022',
status: 'actif',
},
{
id: 'ines-bakhti',
firstName: 'Inès',
lastName: 'Bakhti',
job: 'Hôtesse de caisse',
poste: 'cai',
contract: 'CDI 35 h',
since: '18 janv. 2023',
status: 'actif',
},
{
id: 'nour-zaidi',
firstName: 'Nour',
lastName: 'Zaidi',
job: 'Vendeuse décoration',
poste: 'vte',
contract: 'CDI 30 h',
since: '5 oct. 2020',
status: 'actif',
},
{
id: 'alix-perron',
firstName: 'Alix',
lastName: 'Perron',
job: 'Employé réserve',
poste: 'res',
contract: 'CDI 35 h',
since: '9 mai 2024',
status: 'actif',
},
{
id: 'elsa-tavares',
firstName: 'Elsa',
lastName: 'Tavares',
job: 'Vendeuse',
poste: 'vte',
contract: 'CDD 24 h',
since: '3 févr. 2026',
status: 'essai',
},
{
id: 'malik-ouedraogo',
firstName: 'Malik',
lastName: 'Ouedraogo',
job: 'Employé logistique',
poste: 'liv',
contract: 'CDI 35 h',
since: '21 nov. 2018',
status: 'actif',
},
{
id: 'chloe-vasseur',
firstName: 'Chloé',
lastName: 'Vasseur',
job: 'Vendeuse',
poste: 'vte',
contract: 'CDI 20 h',
since: '14 juil. 2025',
status: 'actif',
},
{
id: 'yanis-bouchard',
firstName: 'Yanis',
lastName: 'Bouchard',
job: 'Vendeur univers maison',
poste: 'vte',
contract: 'CDI 35 h',
since: '17 août 2026',
status: 'essai',
},
{
id: 'sarah-lemoine',
firstName: 'Sarah',
lastName: 'Lemoine',
job: 'Vendeuse décoration',
poste: 'vte',
contract: 'CDI 30 h',
since: '7 sept. 2026',
status: 'essai',
},
{
id: 'hugo-delaunay',
firstName: 'Hugo',
lastName: 'Delaunay',
job: 'Employé retrait web',
poste: 'clc',
contract: 'CDI 28 h',
since: '2 avr. 2023',
status: 'sortie',
},
];
export function findEmployee(id: string): DemoEmployee | undefined {
return EMPLOYEES.find((employee) => employee.id === id);
}
-96
View File
@@ -1,96 +0,0 @@
import type { Tone } from '@/components/ui/Badge';
import type { PosteCode } from '@/lib/design/postes';
export interface FicheShift {
day: string;
poste: PosteCode;
time: string;
worked: string;
state: string;
tone: Tone;
}
export const FICHE_SHIFTS: FicheShift[] = [
{
day: 'Lun. 10 août',
poste: 'enc',
time: '08:00–16:00',
worked: '8 h 00',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mar. 11 août',
poste: 'enc',
time: '08:00–16:00',
worked: '8 h 00',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mer. 12 août',
poste: 'for',
time: '09:00–12:30',
worked: '3 h 30',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mer. 12 août',
poste: 'enc',
time: '13:30–17:00',
worked: '3 h 30',
state: 'Heures réelles manquantes',
tone: 'warn',
},
{
day: 'Jeu. 13 août',
poste: 'enc',
time: '11:00–19:00',
worked: '8 h 00',
state: 'Publié',
tone: 'neutral',
},
];
export interface FicheDocument {
label: string;
date: string;
state: string;
tone: Tone;
}
export const FICHE_DOCUMENTS: FicheDocument[] = [
{
label: 'Contrat de travail — avenant temps partiel',
date: '2 janv. 2026',
state: 'Signé',
tone: 'ok',
},
{
label: 'Entretien professionnel 2026',
date: '18 mai 2026',
state: 'Signé',
tone: 'ok',
},
{
label: 'Attestation mutuelle',
date: '—',
state: 'En attente',
tone: 'warn',
},
];
export interface FicheCounter {
label: string;
value: string;
tone?: Tone;
}
export const FICHE_COUNTERS: FicheCounter[] = [
{ label: 'Contrat', value: '39 h' },
{ label: 'Planifié S33', value: '39 h 00' },
{ label: 'Écart', value: '+0 h 00' },
{ label: 'CP acquis', value: '14,5 j' },
{ label: 'Récupération', value: '4 h 30', tone: 'warn' },
];
-268
View File
@@ -1,268 +0,0 @@
import { findEmployee } from '@/lib/demo/equipe';
import type { DemoEmployee, DemoShift, DemoWeekRow } from '@/lib/demo/types';
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
function pick(id: string): DemoEmployee {
const employee = findEmployee(id);
if (!employee) throw new Error(`Salarié de démonstration inconnu : ${id}`);
return employee;
}
const NONE: DemoShift[] = [];
export const WEEK_DAYS = [
'Lun. 10',
'Mar. 11',
'Mer. 12',
'Jeu. 13',
'Ven. 14',
'Sam. 15',
'Dim. 16',
] as const;
export const WEEK_LABEL = 'Semaine 33 · 10 – 16 août 2026';
export const WEEK_ROWS: DemoWeekRow[] = [
{
employee: pick('camille-ferrand'),
counters: {
contractMinutes: h(39),
plannedMinutes: h(39),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'enc', start: h(8), end: h(16), state: 'validated' }],
[{ poste: 'enc', start: h(8), end: h(16), state: 'validated' }],
[
{ poste: 'for', start: h(9), end: h(12, 30), state: 'validated' },
{ poste: 'enc', start: h(13, 30), end: h(17), state: 'validated' },
],
[{ poste: 'enc', start: h(11), end: h(19), state: 'validated' }],
[{ poste: 'enc', start: h(11), end: h(19), state: 'validated' }],
NONE,
NONE,
],
},
{
employee: pick('jonas-meyer'),
counters: {
contractMinutes: 0,
plannedMinutes: h(38, 30),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
[
{ poste: 'inv', start: h(6, 30), end: h(11), state: 'validated' },
{ poste: 'enc', start: h(11, 30), end: h(15), state: 'validated' },
],
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
NONE,
[{ poste: 'enc', start: h(9), end: h(19), state: 'draft' }],
NONE,
],
},
{
employee: pick('remi-chartier'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(36, 30),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 2,
},
days: [
[
{ poste: 'vte', start: h(9, 30), end: h(13), state: 'validated' },
{ poste: 'cai', start: h(14), end: h(18, 30), state: 'validated' },
],
[
{
poste: 'cai',
start: h(12),
end: h(20),
state: 'alert',
alert: 'Repos < 11 h',
},
],
[{ poste: 'vte', start: h(10), end: h(14), state: 'validated' }],
NONE,
[
{ poste: 'vte', start: h(9), end: h(13), state: 'validated' },
{ poste: 'cab', start: h(14), end: h(18), state: 'validated' },
],
[{ poste: 'cai', start: h(9, 30), end: h(19, 30), state: 'draft' }],
NONE,
],
},
{
employee: pick('ines-bakhti'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(35),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'cai', start: h(9), end: h(17), state: 'validated' }],
[{ poste: 'cai', start: h(9), end: h(17), state: 'validated' }],
NONE,
[{ poste: 'cai', start: h(11, 30), end: h(19, 30), state: 'validated' }],
[
{ poste: 'cai', start: h(9), end: h(14), state: 'validated' },
{ poste: 'clc', start: h(14, 30), end: h(17, 30), state: 'validated' },
],
[
{
poste: 'cai',
start: h(9, 30),
end: h(19, 30),
state: 'alert',
alert: 'Amplitude > 10 h',
},
],
NONE,
],
},
{
employee: pick('nour-zaidi'),
counters: {
contractMinutes: h(30),
plannedMinutes: h(12),
absenceMinutes: h(18),
sundaysWorked: 0,
restDays: 5,
},
absence: { kind: 'cp', label: 'Congés payés', startDay: 0, span: 5 },
days: [
NONE,
NONE,
NONE,
NONE,
NONE,
[{ poste: 'vte', start: h(13), end: h(19), state: 'draft' }],
[{ poste: 'vte', start: h(11), end: h(17), state: 'unpublished' }],
],
},
{
employee: pick('alix-perron'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(33, 15),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 2,
},
days: [
[
{ poste: 'liv', start: h(6), end: h(11), state: 'validated' },
{ poste: 'res', start: h(11, 30), end: h(14, 45), state: 'validated' },
],
[{ poste: 'res', start: h(6), end: h(13), state: 'validated' }],
[{ poste: 'liv', start: h(6), end: h(11), state: 'validated' }],
NONE,
[{ poste: 'res', start: h(6), end: h(13), state: 'validated' }],
[{ poste: 'rss', start: h(7), end: h(13), state: 'draft' }],
NONE,
],
},
{
employee: pick('elsa-tavares'),
counters: {
contractMinutes: h(24),
plannedMinutes: h(25, 30),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 3,
},
days: [
NONE,
NONE,
[{ poste: 'vte', start: h(14), end: h(19), state: 'validated' }],
[{ poste: 'cab', start: h(14), end: h(19), state: 'validated' }],
[{ poste: 'vte', start: h(14), end: h(19, 30), state: 'validated' }],
[{ poste: 'cai', start: h(13, 30), end: h(19, 30), state: 'draft' }],
[{ poste: 'vte', start: h(10), end: h(14), state: 'unpublished' }],
],
},
{
employee: pick('malik-ouedraogo'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(31),
absenceMinutes: h(14),
sundaysWorked: 0,
restDays: 2,
},
absence: { kind: 'maladie', label: 'Arrêt maladie', startDay: 3, span: 2 },
days: [
[
{ poste: 'liv', start: h(5, 30), end: h(10, 30), state: 'validated' },
{ poste: 'res', start: h(11), end: h(14), state: 'validated' },
],
[{ poste: 'rss', start: h(6), end: h(13, 30), state: 'validated' }],
[{ poste: 'inv', start: h(6), end: h(13), state: 'validated' }],
NONE,
NONE,
[{ poste: 'liv', start: h(6), end: h(12, 30), state: 'draft' }],
NONE,
],
},
{
employee: pick('chloe-vasseur'),
counters: {
contractMinutes: h(20),
plannedMinutes: h(21, 15),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 4,
},
days: [
NONE,
NONE,
[{ poste: 'for', start: h(9), end: h(13), state: 'validated' }],
NONE,
[{ poste: 'vte', start: h(15), end: h(19, 30), state: 'validated' }],
[{ poste: 'cai', start: h(11), end: h(18), state: 'draft' }],
[{ poste: 'vte', start: h(11), end: h(15), state: 'unpublished' }],
],
},
];
/** Ligne des besoins sans titulaire, affichée en tête de grille. */
export const UNASSIGNED_ROW: DemoWeekRow = {
employee: {
id: 'non-assigne',
firstName: '',
lastName: 'Non assigné',
job: 'Besoin non couvert',
poste: 'cai',
contract: '—',
since: '—',
status: 'actif',
},
unassigned: true,
counters: {
contractMinutes: 0,
plannedMinutes: h(14),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
},
days: [
NONE,
[{ poste: 'net', start: h(20), end: h(22), state: 'unassigned' }],
NONE,
[{ poste: 'clc', start: h(16), end: h(20), state: 'unassigned' }],
[{ poste: 'cai', start: h(17), end: h(20), state: 'unassigned' }],
[{ poste: 'cai', start: h(14), end: h(19), state: 'unassigned' }],
[{ poste: 'clc', start: h(13), end: h(17), state: 'unassigned' }],
],
};
-63
View File
@@ -1,63 +0,0 @@
import type { AbsenceKind } from '@/components/planning/AbsenceBar';
import type { ShiftState } from '@/components/planning/ShiftChip';
import type { WeekCounters } from '@/domain/counters/week';
import type { PosteCode } from '@/lib/design/postes';
export interface DemoShift {
poste: PosteCode;
/** Début et fin en minutes depuis minuit, jour local. */
start: number;
end: number;
breakMinutes?: number;
state?: ShiftState;
delta?: string;
alert?: string;
}
export interface DemoAbsence {
kind: AbsenceKind;
label: string;
/** 0 = lundi. */
startDay: number;
span: number;
}
export interface DemoEmployee {
id: string;
firstName: string;
lastName: string;
job: string;
poste: PosteCode;
contract: string;
since: string;
status: 'actif' | 'essai' | 'sortie';
forfaitJours?: boolean;
}
export interface DemoWeekRow {
employee: DemoEmployee;
counters: WeekCounters;
/** Sept entrées, lundi → dimanche. */
days: DemoShift[][];
absence?: DemoAbsence;
unassigned?: boolean;
}
export function fullName(employee: DemoEmployee): string {
return `${employee.firstName} ${employee.lastName}`;
}
export function initials(employee: DemoEmployee): string {
return `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`;
}
/** « 09:00 » depuis des minutes après minuit. */
export function clock(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export function timeRange(shift: DemoShift): string {
return `${clock(shift.start)}–${clock(shift.end)}`;
}
+499
View File
@@ -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;
}
+526
View File
@@ -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);
}
+60
View File
@@ -0,0 +1,60 @@
import { expect, test, type Page } from '@playwright/test';
import {
formatWeekParam,
isoWeekOf,
previousIsoWeek,
} from '../../src/domain/planning/week';
/**
* Ce que chaque rôle voit du planning.
*
* Ces tests se connectent eux-mêmes : ils ne peuvent pas réutiliser la session
* partagée de la direction, d'où le projet « anonyme ».
*/
async function signIn(page: Page, email: string) {
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(email);
await page.getByLabel('Mot de passe').fill('planflow-demo-2026');
await page.getByRole('button', { name: 'Se connecter' }).click();
// Attendre la sortie de l'écran de connexion : naviguer tout de suite
// annulerait la redirection en cours, et le test repartirait déconnecté.
await expect(page).not.toHaveURL(/\/connexion/);
}
const current = isoWeekOf(new Date());
const CURRENT_WEEK = formatWeekParam(current);
const PUBLISHED_WEEK = formatWeekParam(previousIsoWeek(current));
test('un salarié ne voit pas une semaine non publiée', async ({ page }) => {
await signIn(page, 'salarie@example.test');
// Le seed laisse la semaine courante en brouillon et publie la précédente.
await page.goto(`/planning/semaine?semaine=${CURRENT_WEEK}`);
await expect(page.getByText(/n’est pas encore publié/).first()).toBeVisible();
// Le masquage n'est pas cosmétique : les créneaux ne sont pas chargés, donc
// absents du HTML. Un simple `display: none` les y laisserait.
expect(await page.content()).not.toContain('09:00–17:00');
// Ni bouton de publication ni ajout de créneau : les capacités manquent.
await expect(page.getByRole('button', { name: 'Publier' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /\+ Créneau/ })).toHaveCount(0);
});
test('un salarié voit la semaine publiée', async ({ page }) => {
await signIn(page, 'salarie@example.test');
await page.goto(`/planning/semaine?semaine=${PUBLISHED_WEEK}`);
await expect(page.getByText('Publiée').first()).toBeVisible();
await expect(page.getByText('09:00–17:00').first()).toBeVisible();
});
test('un manager voit le brouillon et peut le publier', async ({ page }) => {
await signIn(page, 'manager.nantes@example.test');
await page.goto(`/planning/semaine?semaine=${CURRENT_WEEK}`);
await expect(page.getByText('Brouillon').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Publier' }).first()).toBeVisible();
});
+1 -1
View File
@@ -45,7 +45,7 @@ test('connexion, navigation, puis déconnexion', async ({ page }) => {
await page.getByRole('link', { name: 'Plannings' }).click();
await expect(
page.getByRole('heading', { name: /Planning · semaine 33/ }),
page.getByRole('heading', { name: /Planning · semaine \d+/ }),
).toBeVisible();
await page.getByRole('button', { name: 'Déconnexion' }).click();
+6 -4
View File
@@ -6,14 +6,16 @@ test('les six écrans se chargent et affichent leur contenu', async ({ page }) =
await page.getByRole('link', { name: 'Plannings' }).click();
await expect(
page.getByRole('heading', { name: /Planning · semaine 33/ }),
page.getByRole('heading', { name: /Planning · semaine \d+/ }),
).toBeVisible();
// La grille doit porter des créneaux, pas seulement son ossature.
await expect(page.getByText('Congés payés').first()).toBeVisible();
// La grille doit porter des créneaux venus de la base, pas seulement son
// ossature : le seed plante deux semaines sur trois équipes.
await expect(page.getByRole('heading', { name: 'Vente' })).toBeVisible();
await expect(page.getByText('09:00–17:00').first()).toBeVisible();
await page.getByRole('link', { name: 'Vue jour' }).click();
await expect(
page.getByRole('heading', { name: /Planning · mercredi 12 août/ }),
page.getByRole('heading', { name: /^Planning · / }),
).toBeVisible();
await page.getByRole('link', { name: 'Équipe', exact: true }).click();
+116
View File
@@ -0,0 +1,116 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
/**
* Parcours du planning : poser un créneau, publier, dépublier.
*
* Le test travaille sur une semaine **lointaine** plutôt que la semaine
* courante : celle du seed change à chaque exécution, et un test qui dépend de
* la date du jour finit par échouer un lundi de janvier sans que personne ne
* sache pourquoi.
*
* La base de test n'est pas remise à zéro entre deux passages : la semaine est
* donc vidée au début, sans quoi le second passage partirait d'un état publié
* ou déjà rempli.
*/
const WEEK = '2027-W20';
function venteSection(page: Page): Locator {
return page
.locator('section')
.filter({ has: page.getByRole('heading', { name: 'Vente' }) });
}
async function resetWeek(page: Page): Promise<void> {
await page.goto(`/planning/semaine?semaine=${WEEK}`);
const section = venteSection(page);
await expect(section).toBeVisible();
const unpublish = section.getByRole('button', { name: 'Dépublier' });
if (await unpublish.isVisible()) {
await unpublish.click();
await expect(section.getByText('Brouillon')).toBeVisible();
}
const remove = section.getByRole('button', { name: 'Supprimer le créneau' });
for (let guard = 0; guard < 30; guard += 1) {
const before = await remove.count();
if (before === 0) break;
await remove.first().click();
// Compter plutôt que regarder un libellé : c'est la disparition du
// créneau qui est attendue, et elle est observable sans ambiguïté.
await expect(remove).toHaveCount(before - 1);
}
await expect(remove).toHaveCount(0);
}
async function addShift(
page: Page,
section: Locator,
times: { start: string; end: string; pause?: string },
): Promise<Locator> {
const row = section
.locator('div')
.filter({ hasText: /Camille Ferrand/ })
.first();
await row.getByRole('button', { name: /\+ Créneau/ }).first().click();
const composer = page.locator('form').filter({ hasText: 'Poste' }).first();
await composer.getByLabel('Début').fill(times.start);
await composer.getByLabel('Fin').fill(times.end);
await composer.getByLabel('Pause (min)').fill(times.pause ?? '0');
await composer.getByRole('button', { name: 'Ajouter' }).click();
return composer;
}
test('un manager pose un créneau, publie, puis dépublie', async ({ page }) => {
await resetWeek(page);
const section = venteSection(page);
// Publier une semaine vide n'a pas de sens : rien à annoncer aux salariés.
await section.getByRole('button', { name: 'Publier' }).click();
await expect(section.getByText(/ne contient aucun créneau/)).toBeVisible();
await addShift(page, section, {
start: '08:30',
end: '16:30',
pause: '45',
});
await expect(section.getByText('08:30–16:30').first()).toBeVisible();
// 8 h d'amplitude moins 45 min de pause : le compteur doit dire 7 h 15,
// pas 8 h. C'est la déduction de la pause qui est vérifiée ici.
await expect(section.getByText('7 h 15').first()).toBeVisible();
// Un créneau qui recouvre le premier compterait deux fois en heures. Le
// refus vient du serveur, en transaction, pas du formulaire.
const composer = await addShift(page, section, {
start: '12:00',
end: '20:00',
});
await expect(composer.getByText(/recouvre cette plage/)).toBeVisible();
await composer.getByRole('button', { name: 'Annuler' }).click();
await section.getByRole('button', { name: 'Publier' }).click();
await expect(section.getByText('Publiée')).toBeVisible();
await section.getByRole('button', { name: 'Dépublier' }).click();
await expect(section.getByText('Brouillon')).toBeVisible();
});
test('la navigation de semaine change la grille', async ({ page }) => {
await page.goto(`/planning/semaine?semaine=${WEEK}`);
await expect(
page.getByRole('heading', { name: 'Planning · semaine 20' }),
).toBeVisible();
await page.getByRole('link', { name: 'Semaine suivante →' }).click();
await expect(
page.getByRole('heading', { name: 'Planning · semaine 21' }),
).toBeVisible();
await page.getByRole('link', { name: '← Semaine précédente' }).click();
await expect(
page.getByRole('heading', { name: 'Planning · semaine 20' }),
).toBeVisible();
});
+7 -2
View File
@@ -67,10 +67,13 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
// L'effectif vient de la base, pas du module de démonstration.
await expect(page.getByText('E0001')).toBeVisible();
// Nom unique par exécution : la base de test n'est pas remise à zéro entre
// deux passages, et un nom fixe finirait par désigner plusieurs salariés.
const matricule = `E9${Date.now() % 100000}`;
const nom = `Sanscompte${matricule}`;
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.locator('input[name="firstName"]').fill('Sans');
await form.locator('input[name="lastName"]').fill('Compte');
await form.locator('input[name="lastName"]').fill(nom);
await form.locator('input[name="employeeNumber"]').fill(matricule);
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
@@ -87,5 +90,7 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
).toBeVisible();
// Le nom vit sur le dossier, pas sur le compte : un salarié sans accès
// applicatif doit tout de même figurer nommément au registre du personnel.
await expect(page.getByRole('link', { name: /Sans Compte/ })).toBeVisible();
await expect(
page.getByRole('link', { name: new RegExp(`Sans ${nom}`) }),
).toBeVisible();
});
+231
View File
@@ -0,0 +1,231 @@
import { describe, expect, it } from 'vitest';
import {
buildRows,
shiftState,
type BoardShiftInput,
type RowPerson,
} from '@/domain/planning/board';
import { weekDates, zonedInstant } from '@/domain/planning/week';
const TZ = 'Europe/Paris';
const WEEK = { isoYear: 2026, isoWeek: 33 };
const DATES = weekDates(WEEK);
const person = (over: Partial<RowPerson> = {}): RowPerson => ({
membershipId: 'm1',
firstName: 'Sofia',
lastName: 'Marchetti',
job: 'Vendeuse conseil',
forfaitJours: false,
contractMinutes: 35 * 60,
...over,
});
const shift = (
date: string,
start: string,
end: string,
over: Partial<BoardShiftInput> = {},
): BoardShiftInput => ({
id: `${date}-${start}`,
membershipId: 'm1',
startAt: zonedInstant(date, start, TZ),
endAt: zonedInstant(date, end, TZ),
breakMinutes: 0,
poste: 'vte',
isValidated: false,
note: null,
...over,
});
describe('buildRows', () => {
it('range chaque créneau dans la colonne de son jour local', () => {
const { rows } = buildRows(
[person()],
[shift('2026-08-12', '09:00', '17:00')],
DATES,
TZ,
false,
);
expect(rows[0]?.days[2]).toHaveLength(1);
expect(rows[0]?.days[2]?.[0]?.time).toBe('09:00–17:00');
expect(rows[0]?.days.flat()).toHaveLength(1);
});
it('affiche un salarié sans créneau', () => {
// Un planning se construit à partir d'une grille vide : masquer les
// salariés non planifiés rendrait l'écran inutilisable le lundi matin.
const { rows } = buildRows([person()], [], DATES, TZ, false);
expect(rows).toHaveLength(1);
expect(rows[0]?.counters.plannedMinutes).toBe(0);
expect(rows[0]?.counters.restDays).toBe(7);
});
it('déduit la pause du temps planifié', () => {
const { rows } = buildRows(
[person()],
[shift('2026-08-10', '09:00', '17:00', { breakMinutes: 60 })],
DATES,
TZ,
false,
);
expect(rows[0]?.counters.plannedMinutes).toBe(7 * 60);
});
it('compte la nuit du changement d’heure à sa vraie durée', () => {
// 25 octobre 2026, retour à l'heure d'hiver : 22 h–06 h dure neuf heures.
const dates = weekDates({ isoYear: 2026, isoWeek: 43 });
const startAt = zonedInstant('2026-10-24', '22:00', TZ);
const endAt = zonedInstant('2026-10-25', '06:00', TZ);
const { rows } = buildRows(
[person()],
[
{
id: 's',
membershipId: 'm1',
startAt,
endAt,
breakMinutes: 0,
poste: 'inv',
isValidated: false,
note: null,
},
],
dates,
TZ,
false,
);
expect(rows[0]?.counters.plannedMinutes).toBe(9 * 60);
// Le créneau se range au jour de son **début**, samedi.
expect(rows[0]?.days[5]).toHaveLength(1);
expect(rows[0]?.days[6]).toHaveLength(0);
});
it('sépare les besoins non couverts sur leur propre ligne', () => {
const { rows, unassignedRow } = buildRows(
[person()],
[
shift('2026-08-15', '10:00', '18:00', {
id: 'open',
membershipId: null,
}),
],
DATES,
TZ,
false,
);
expect(rows[0]?.days.flat()).toHaveLength(0);
expect(unassignedRow?.days[5]).toHaveLength(1);
expect(unassignedRow?.days[5]?.[0]?.state).toBe('unassigned');
// Pas de contrat sur cette ligne : la comparer à 35 h n'aurait aucun sens.
expect(unassignedRow?.counters.contractMinutes).toBe(0);
});
it('n’ouvre pas de ligne « non assigné » quand tout est couvert', () => {
const { unassignedRow } = buildRows(
[person()],
[shift('2026-08-10', '09:00', '17:00')],
DATES,
TZ,
false,
);
expect(unassignedRow).toBeNull();
});
it('compte les dimanches travaillés', () => {
// Le dimanche du maire ouvre droit à majoration **et** repos compensateur :
// le compteur doit être juste, pas approximatif.
const { rows } = buildRows(
[person()],
[
shift('2026-08-16', '10:00', '18:00'),
shift('2026-08-14', '10:00', '18:00'),
],
DATES,
TZ,
false,
);
expect(rows[0]?.counters.sundaysWorked).toBe(1);
expect(rows[0]?.counters.restDays).toBe(5);
});
it('ignore un créneau hors de la semaine affichée', () => {
const { rows } = buildRows(
[person()],
[shift('2026-08-24', '09:00', '17:00')],
DATES,
TZ,
false,
);
// Le taire vaut mieux que de l'écraser sur lundi, où il fausserait le
// compteur sans que rien ne le signale.
expect(rows[0]?.counters.plannedMinutes).toBe(0);
});
it('neutralise la durée contractuelle au forfait jours', () => {
const { rows } = buildRows(
[person({ forfaitJours: true, contractMinutes: 35 * 60 })],
[],
DATES,
TZ,
false,
);
expect(rows[0]?.counters.contractMinutes).toBe(0);
});
it('ordonne les créneaux d’une même journée', () => {
const { rows } = buildRows(
[person()],
[
shift('2026-08-10', '14:00', '19:00'),
shift('2026-08-10', '07:00', '12:00'),
],
DATES,
TZ,
false,
);
expect(rows[0]?.days[0]?.map((entry) => entry.time)).toEqual([
'07:00–12:00',
'14:00–19:00',
]);
});
});
describe('shiftState', () => {
it('distingue brouillon, publié et validé', () => {
expect(shiftState(false, false, 'm1')).toBe('draft');
expect(shiftState(false, true, 'm1')).toBe('published');
expect(shiftState(true, true, 'm1')).toBe('validated');
});
it('garde le besoin non couvert reconnaissable même publié', () => {
expect(shiftState(false, true, null)).toBe('unassigned');
});
});
describe('zonedInstant', () => {
it('vise la bonne heure locale été comme hiver', () => {
expect(zonedInstant('2026-08-10', '09:00', TZ).toISOString()).toBe(
'2026-08-10T07:00:00.000Z',
);
expect(zonedInstant('2026-01-05', '09:00', TZ).toISOString()).toBe(
'2026-01-05T08:00:00.000Z',
);
});
it('reste juste le jour du changement d’heure', () => {
// 29 mars 2026 : minuit est en UTC+1, 09 h en UTC+2. Ajouter neuf heures à
// minuit donnerait 10 h locales.
expect(zonedInstant('2026-03-29', '09:00', TZ).toISOString()).toBe(
'2026-03-29T07:00:00.000Z',
);
expect(zonedInstant('2026-10-25', '09:00', TZ).toISOString()).toBe(
'2026-10-25T08:00:00.000Z',
);
});
});
+215
View File
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest';
import {
dayHeadings,
formatWeekParam,
isoWeekOf,
mondayOfIsoWeek,
nextIsoWeek,
offsetAt,
parseWeekParam,
previousIsoWeek,
weekBounds,
weekDates,
weekLabel,
weeksInIsoYear,
zonedClock,
zonedDate,
zonedMidnight,
} from '@/domain/planning/week';
const utc = (iso: string) => new Date(iso);
describe('isoWeekOf', () => {
it('numérote une semaine ordinaire', () => {
expect(isoWeekOf(utc('2026-08-12T00:00:00Z'))).toEqual({
isoYear: 2026,
isoWeek: 33,
});
});
it('rattache le 1ᵉʳ janvier à l’année ISO précédente quand la norme l’exige', () => {
// 1ᵉʳ janvier 2027 est un vendredi : il appartient à la semaine 53 de 2026.
expect(isoWeekOf(utc('2027-01-01T00:00:00Z'))).toEqual({
isoYear: 2026,
isoWeek: 53,
});
});
it('rattache fin décembre à l’année ISO suivante quand la norme l’exige', () => {
// 29 décembre 2025 est un lundi : semaine 1 de 2026.
expect(isoWeekOf(utc('2025-12-29T00:00:00Z'))).toEqual({
isoYear: 2026,
isoWeek: 1,
});
});
it('reconnaît les années à 53 semaines', () => {
expect(weeksInIsoYear(2026)).toBe(53);
expect(weeksInIsoYear(2025)).toBe(52);
});
});
describe('mondayOfIsoWeek', () => {
it('est l’inverse de isoWeekOf', () => {
for (let week = 1; week <= 52; week += 1) {
const monday = mondayOfIsoWeek({ isoYear: 2026, isoWeek: week });
expect(monday.getUTCDay()).toBe(1);
expect(isoWeekOf(monday)).toEqual({ isoYear: 2026, isoWeek: week });
}
});
it('place la semaine 1 de 2026 au 29 décembre 2025', () => {
expect(
mondayOfIsoWeek({ isoYear: 2026, isoWeek: 1 }).toISOString().slice(0, 10),
).toBe('2025-12-29');
});
});
describe('navigation de semaine', () => {
it('recule sur l’année précédente en respectant sa longueur', () => {
// 2026 compte 53 semaines : reculer depuis la 1 de 2027 doit donner 53,
// pas 52. Une erreur ici fait disparaître une semaine de planning par an.
expect(previousIsoWeek({ isoYear: 2027, isoWeek: 1 })).toEqual({
isoYear: 2026,
isoWeek: 53,
});
expect(previousIsoWeek({ isoYear: 2026, isoWeek: 1 })).toEqual({
isoYear: 2025,
isoWeek: 52,
});
});
it('avance au-delà de la dernière semaine', () => {
expect(nextIsoWeek({ isoYear: 2026, isoWeek: 53 })).toEqual({
isoYear: 2027,
isoWeek: 1,
});
expect(nextIsoWeek({ isoYear: 2025, isoWeek: 52 })).toEqual({
isoYear: 2026,
isoWeek: 1,
});
});
it('fait un aller-retour sans dérive sur toute une année', () => {
let week = { isoYear: 2026, isoWeek: 1 };
for (let step = 0; step < 60; step += 1) week = nextIsoWeek(week);
for (let step = 0; step < 60; step += 1) week = previousIsoWeek(week);
expect(week).toEqual({ isoYear: 2026, isoWeek: 1 });
});
});
describe('weekDates', () => {
it('donne sept jours consécutifs du lundi au dimanche', () => {
expect(weekDates({ isoYear: 2026, isoWeek: 33 })).toEqual([
'2026-08-10',
'2026-08-11',
'2026-08-12',
'2026-08-13',
'2026-08-14',
'2026-08-15',
'2026-08-16',
]);
});
});
describe('fuseau horaire', () => {
it('mesure le décalage de Paris en hiver et en été', () => {
expect(offsetAt(utc('2026-01-15T12:00:00Z'), 'Europe/Paris')).toBe(
3_600_000,
);
expect(offsetAt(utc('2026-07-15T12:00:00Z'), 'Europe/Paris')).toBe(
7_200_000,
);
});
it('place minuit local au bon instant absolu', () => {
expect(zonedMidnight('2026-08-10', 'Europe/Paris').toISOString()).toBe(
'2026-08-09T22:00:00.000Z',
);
expect(zonedMidnight('2026-01-05', 'Europe/Paris').toISOString()).toBe(
'2026-01-04T23:00:00.000Z',
);
});
it('tient au jour même du changement d’heure', () => {
// 25 octobre 2026 : retour à l'heure d'hiver. Minuit local est encore en
// UTC+2 ; une seule passe de correction donnerait une heure de trop.
expect(zonedMidnight('2026-10-25', 'Europe/Paris').toISOString()).toBe(
'2026-10-24T22:00:00.000Z',
);
expect(zonedMidnight('2026-03-29', 'Europe/Paris').toISOString()).toBe(
'2026-03-28T23:00:00.000Z',
);
});
it('mesure une semaine de changement d’heure à sa vraie durée', () => {
// La semaine du passage à l'heure d'hiver dure 169 h, pas 168. Un calcul
// en heures murales la croirait normale et fausserait tout compteur qui
// s'appuierait sur ses bornes.
const autumn = weekBounds({ isoYear: 2026, isoWeek: 43 }, 'Europe/Paris');
expect((autumn.to.getTime() - autumn.from.getTime()) / 3_600_000).toBe(169);
const spring = weekBounds({ isoYear: 2026, isoWeek: 13 }, 'Europe/Paris');
expect((spring.to.getTime() - spring.from.getTime()) / 3_600_000).toBe(167);
});
it('restitue la date et l’heure locales d’un instant', () => {
// 22 h UTC le 9 août, c'est déjà le 10 août à Paris : le créneau de lundi
// matin doit se ranger dans la colonne du lundi.
const instant = utc('2026-08-09T22:00:00Z');
expect(zonedDate(instant, 'Europe/Paris')).toBe('2026-08-10');
expect(zonedClock(instant, 'Europe/Paris')).toBe('00:00');
expect(zonedClock(utc('2026-08-10T07:30:00Z'), 'Europe/Paris')).toBe(
'09:30',
);
});
});
describe('libellés', () => {
it('compose les en-têtes de colonnes', () => {
expect(dayHeadings({ isoYear: 2026, isoWeek: 33 })).toEqual([
'Lun. 10',
'Mar. 11',
'Mer. 12',
'Jeu. 13',
'Ven. 14',
'Sam. 15',
'Dim. 16',
]);
});
it('compose le libellé de semaine', () => {
expect(weekLabel({ isoYear: 2026, isoWeek: 33 })).toBe(
'Semaine 33 · 10 – 16 août 2026',
);
});
it('nomme les deux mois quand la semaine est à cheval', () => {
expect(weekLabel({ isoYear: 2026, isoWeek: 31 })).toBe(
'Semaine 31 · 27 juillet – 2 août 2026',
);
});
});
describe('paramètre d’URL', () => {
it('fait un aller-retour', () => {
const week = { isoYear: 2026, isoWeek: 7 };
expect(parseWeekParam(formatWeekParam(week))).toEqual(week);
expect(formatWeekParam(week)).toBe('2026-W07');
});
it('rejette ce qui n’est pas une semaine', () => {
// Le paramètre vient de l'URL : il est fourni par le visiteur, donc
// toujours suspect. Une semaine 54 ne doit pas atteindre la base.
expect(parseWeekParam('2026-W54')).toBeNull();
expect(parseWeekParam('2026-W00')).toBeNull();
expect(parseWeekParam('août')).toBeNull();
expect(parseWeekParam(undefined)).toBeNull();
});
it('accepte la 53ᵉ semaine d’une année longue', () => {
expect(parseWeekParam('2026-W53')).toEqual({ isoYear: 2026, isoWeek: 53 });
expect(parseWeekParam('2025-W53')).toBeNull();
});
});