From 023f23688148717778e4a1951607929b9f04df25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 23:43:48 +0000 Subject: [PATCH] =?UTF-8?q?WP-04:=20planning=20sur=20donn=C3=A9es=20r?= =?UTF-8?q?=C3=A9elles=20=E2=80=94=20grille=20semaine,=20vue=20jour,=20pub?= =?UTF-8?q?lication=20par=20=C3=A9quipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv --- playwright.config.ts | 4 +- .../20260807231841_planning/migration.sql | 102 ++++ .../20260807232500_rls_planning/migration.sql | 25 + .../20260807232652_team_members/migration.sql | 34 ++ prisma/schema.prisma | 128 +++++ prisma/seed.ts | 287 ++++++++++ scripts/dev-db.sh | 41 ++ src/app/(app)/planning/jour/page.tsx | 107 +++- src/app/(app)/planning/semaine/page.tsx | 118 +++- src/components/planning/DayTimeline.tsx | 38 +- src/components/planning/TeamSection.tsx | 349 ++++++++++++ src/components/planning/WeekGrid.tsx | 122 ++-- src/domain/planning/board.ts | 204 +++++++ src/domain/planning/week.ts | 263 +++++++++ src/lib/demo/equipe.ts | 130 ----- src/lib/demo/fiche.ts | 96 ---- src/lib/demo/semaine.ts | 268 --------- src/lib/demo/types.ts | 63 --- src/server/planning/actions.ts | 499 +++++++++++++++++ src/server/planning/queries.ts | 526 ++++++++++++++++++ tests/e2e/acces.spec.ts | 60 ++ tests/e2e/auth.spec.ts | 2 +- tests/e2e/navigation.spec.ts | 10 +- tests/e2e/planning.spec.ts | 116 ++++ tests/e2e/reglages.spec.ts | 9 +- tests/unit/planning-board.test.ts | 231 ++++++++ tests/unit/planning-week.test.ts | 215 +++++++ 27 files changed, 3373 insertions(+), 674 deletions(-) create mode 100644 prisma/migrations/20260807231841_planning/migration.sql create mode 100644 prisma/migrations/20260807232500_rls_planning/migration.sql create mode 100644 prisma/migrations/20260807232652_team_members/migration.sql create mode 100755 scripts/dev-db.sh create mode 100644 src/components/planning/TeamSection.tsx create mode 100644 src/domain/planning/board.ts create mode 100644 src/domain/planning/week.ts delete mode 100644 src/lib/demo/equipe.ts delete mode 100644 src/lib/demo/fiche.ts delete mode 100644 src/lib/demo/semaine.ts delete mode 100644 src/lib/demo/types.ts create mode 100644 src/server/planning/actions.ts create mode 100644 src/server/planning/queries.ts create mode 100644 tests/e2e/acces.spec.ts create mode 100644 tests/e2e/planning.spec.ts create mode 100644 tests/unit/planning-board.test.ts create mode 100644 tests/unit/planning-week.test.ts diff --git a/playwright.config.ts b/playwright.config.ts index d1e549e..8074bc8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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'], diff --git a/prisma/migrations/20260807231841_planning/migration.sql b/prisma/migrations/20260807231841_planning/migration.sql new file mode 100644 index 0000000..fa49b3c --- /dev/null +++ b/prisma/migrations/20260807231841_planning/migration.sql @@ -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; diff --git a/prisma/migrations/20260807232500_rls_planning/migration.sql b/prisma/migrations/20260807232500_rls_planning/migration.sql new file mode 100644 index 0000000..dda720e --- /dev/null +++ b/prisma/migrations/20260807232500_rls_planning/migration.sql @@ -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; +$$; diff --git a/prisma/migrations/20260807232652_team_members/migration.sql b/prisma/migrations/20260807232652_team_members/migration.sql new file mode 100644 index 0000000..eb76b64 --- /dev/null +++ b/prisma/migrations/20260807232652_team_members/migration.sql @@ -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()); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f11f68c..4ad05fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 04bf277..b4dfdc5 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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(); + 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 { + 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); diff --git a/scripts/dev-db.sh b/scripts/dev-db.sh new file mode 100755 index 0000000..b50953b --- /dev/null +++ b/scripts/dev-db.sh @@ -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" diff --git a/src/app/(app)/planning/jour/page.tsx b/src/app/(app)/planning/jour/page.tsx index e86fe87..28a1f48 100644 --- a/src/app/(app)/planning/jour/page.tsx +++ b/src/app/(app)/planning/jour/page.tsx @@ -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 ( + + + + ); } + 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 ( - - + + ← Jour précédent + + + Jour suivant → + } />
- {lanes.length} salariés présents - Creux de couverture à 13 h + {board.locations.map((location) => ( + + {location.name} + + ))} + + {present} salarié{present > 1 ? 's' : ''} présent + {present > 1 ? 's' : ''} + + {open > 0 ? ( + + {open} besoin{open > 1 ? 's' : ''} non couvert{open > 1 ? 's' : ''} + + ) : null}
- + {board.lanes.length === 0 ? ( +

+ Personne n’est planifié ce jour-là. +

+ ) : ( + + )}
); } diff --git a/src/app/(app)/planning/semaine/page.tsx b/src/app/(app)/planning/semaine/page.tsx index e3d1cc9..e3128fb 100644 --- a/src/app/(app)/planning/semaine/page.tsx +++ b/src/app/(app)/planning/semaine/page.tsx @@ -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 ( + + +

+ Créez un établissement dans{' '} + + Réglages · Établissements + {' '} + pour commencer à planifier. +

+
+ ); + } + + 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 ( - - - + + ← Semaine précédente + + + Semaine suivante → + } />
- 2 alertes de convention - Samedi non publié - 5 besoins non couverts + {board.locations.map((location) => ( + + {location.name} + + ))} + {board.totals.unassigned > 0 ? ( + + {board.totals.unassigned} besoin + {board.totals.unassigned > 1 ? 's' : ''} non couvert + {board.totals.unassigned > 1 ? 's' : ''} + + ) : null}
- + {board.sections.length === 0 ? ( +

+ Cet établissement n’a pas encore d’équipe. +

+ ) : null} + + {board.sections.map((section) => ( + + ))}
diff --git a/src/components/planning/DayTimeline.tsx b/src/components/planning/DayTimeline.tsx index a43425d..0fe26f3 100644 --- a/src/components/planning/DayTimeline.tsx +++ b/src/components/planning/DayTimeline.tsx @@ -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) => (
- {lane.unassigned ? '?' : initials(lane.employee)} + {lane.initials} - {lane.unassigned - ? 'Non assigné' - : `${lane.employee.firstName} ${lane.employee.lastName}`} + {lane.name} - {lane.employee.job} + {lane.job}
@@ -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 (
{posteShort(shift.poste)} - {clock(shift.start)}–{clock(shift.end)} + {clock(shift.startMinutes)}–{clock(shift.endMinutes)}
); diff --git a/src/components/planning/TeamSection.tsx b/src/components/planning/TeamSection.tsx new file mode 100644 index 0000000..c0c2571 --- /dev/null +++ b/src/components/planning/TeamSection.tsx @@ -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.teamName} +

+

+ Le planning de cette équipe n’est pas encore publié. +

+
+ ); + } + + return ( +
+
+
+

+ {section.teamName} +

+ + {published ? 'Publiée' : 'Brouillon'} + + + {formatMinutes(plannedMinutes)} planifiées + +
+ + {canPublish ? ( + + ) : null} +
+ + ( + + ), + shiftAction: (shiftId: string) => ( + + ), + } + : {})} + /> + + {target ? ( + setTarget(null)} + /> + ) : null} +
+ ); +} + +function PublishControl({ + teamId, + weekParam, + version, + published, +}: { + teamId: string; + weekParam: string; + version: number; + published: boolean; +}) { + const [state, formAction, pending] = useActionState( + published ? unpublishWeekAction : publishWeekAction, + empty, + ); + + return ( +
+ + + {/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */} + + {state.error ? ( + + {state.error} + + ) : null} + +
+ ); +} + +function DeleteShift({ shiftId }: { shiftId: string }) { + const [state, formAction, pending] = useActionState(deleteShiftAction, empty); + + return ( +
+ + {/* 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. */} + +
+ ); +} + +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 ( +
+ + + + + + + {dayLabel} + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + {state.error ? ( +

+ {state.error} +

+ ) : null} +
+ ); +} + +function Field({ + label, + htmlFor, + children, +}: { + label: string; + htmlFor?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} diff --git a/src/components/planning/WeekGrid.tsx b/src/components/planning/WeekGrid.tsx index 1609616..412cedd 100644 --- a/src/components/planning/WeekGrid.tsx +++ b/src/components/planning/WeekGrid.tsx @@ -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 (
- {unassignedRow ? : null} + {unassignedRow ? ( + + ) : null} + {rows.length === 0 && !unassignedRow ? ( +

+ Aucun salarié rattaché à cette équipe. +

+ ) : null} {rows.map((row) => ( - + ))}
@@ -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 (
- {initials(row.employee)} + {initialsOf(row)} )} @@ -99,57 +141,43 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) { {name} - {row.employee.job} + {row.job}
- {row.absence ? ( - - ) : null} - {days.map((day, index) => { const shifts = row.days[index] ?? []; return (
4 && 'bg-surface-2', - row.absence && - index >= row.absence.startDay && - index < row.absence.startDay + row.absence.span && - 'pt-7', )} > - {shifts.map((shift, position) => ( - + {shifts.map((shift) => ( +
+ + {shiftAction?.(shift.id)} +
))} + {cellAction?.(row, index)}
); })} diff --git a/src/domain/planning/board.ts b/src/domain/planning/board.ts new file mode 100644 index 0000000..81bcaea --- /dev/null +++ b/src/domain/planning/board.ts @@ -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(); + const minutesByMember = new Map(); + const sundaysByMember = new Map>(); + 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(); + 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}`; +} diff --git a/src/domain/planning/week.ts b/src/domain/planning/week.ts new file mode 100644 index 0000000..a7db1ab --- /dev/null +++ b/src/domain/planning/week.ts @@ -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')}`; +} diff --git a/src/lib/demo/equipe.ts b/src/lib/demo/equipe.ts deleted file mode 100644 index b10bf26..0000000 --- a/src/lib/demo/equipe.ts +++ /dev/null @@ -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); -} diff --git a/src/lib/demo/fiche.ts b/src/lib/demo/fiche.ts deleted file mode 100644 index dd6d43b..0000000 --- a/src/lib/demo/fiche.ts +++ /dev/null @@ -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' }, -]; diff --git a/src/lib/demo/semaine.ts b/src/lib/demo/semaine.ts deleted file mode 100644 index a180434..0000000 --- a/src/lib/demo/semaine.ts +++ /dev/null @@ -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' }], - ], -}; diff --git a/src/lib/demo/types.ts b/src/lib/demo/types.ts deleted file mode 100644 index fc148d2..0000000 --- a/src/lib/demo/types.ts +++ /dev/null @@ -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)}`; -} diff --git a/src/server/planning/actions.ts b/src/server/planning/actions.ts new file mode 100644 index 0000000..d00fdf7 --- /dev/null +++ b/src/server/planning/actions.ts @@ -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 { + 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 { + 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 { + 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 { + return setPublication(formData, true); +} + +export async function unpublishWeekAction( + _previous: PlanningActionState, + formData: FormData, +): Promise { + 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 { + 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 { + 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; +} diff --git a/src/server/planning/queries.ts b/src/server/planning/queries.ts new file mode 100644 index 0000000..e1c7850 --- /dev/null +++ b/src/server/planning/queries.ts @@ -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 { + 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>[]>> { + 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>[]>(); + 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, +) { + 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 { + 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(); + 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); +} diff --git a/tests/e2e/acces.spec.ts b/tests/e2e/acces.spec.ts new file mode 100644 index 0000000..ca1e00a --- /dev/null +++ b/tests/e2e/acces.spec.ts @@ -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(); +}); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts index 7b3e790..e27ae3d 100644 --- a/tests/e2e/auth.spec.ts +++ b/tests/e2e/auth.spec.ts @@ -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(); diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index f13dbe7..a4eb7fa 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -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(); diff --git a/tests/e2e/planning.spec.ts b/tests/e2e/planning.spec.ts new file mode 100644 index 0000000..629ac73 --- /dev/null +++ b/tests/e2e/planning.spec.ts @@ -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 { + 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 { + 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(); +}); diff --git a/tests/e2e/reglages.spec.ts b/tests/e2e/reglages.spec.ts index ce0b34a..8b99c6a 100644 --- a/tests/e2e/reglages.spec.ts +++ b/tests/e2e/reglages.spec.ts @@ -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(); }); diff --git a/tests/unit/planning-board.test.ts b/tests/unit/planning-board.test.ts new file mode 100644 index 0000000..239b97f --- /dev/null +++ b/tests/unit/planning-board.test.ts @@ -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 => ({ + 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 => ({ + 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', + ); + }); +}); diff --git a/tests/unit/planning-week.test.ts b/tests/unit/planning-week.test.ts new file mode 100644 index 0000000..89f86ce --- /dev/null +++ b/tests/unit/planning-week.test.ts @@ -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(); + }); +});