WP-04: planning sur données réelles — grille semaine, vue jour, publication par équipe
Le planning quitte le module de démonstration : la grille lit `WeeklySchedule` et `Shift`, et les quatre derniers fichiers de `src/lib/demo/` qui portaient des salariés fictifs disparaissent. Modèle - `TeamMember` : rattachement d'un salarié à une équipe, distinct de `MembershipScope` (qui dit ce qu'un manager a le droit de voir). Sans lui, un salarié sans créneau n'apparaîtrait pas dans la grille — c'est-à-dire l'état de départ de toute semaine en construction. - RLS sur `WeeklySchedule`, `Shift`, `Rest`, `DailyNote` et `TeamMember`. Temps - `src/domain/planning/week.ts` : repérage par couple année ISO + semaine ISO, jamais par date de début. Le lundi 29 décembre 2025 appartient à la semaine 1 de 2026 ; une clé fondée sur la date ferait apparaître deux semaines 1. - `zonedInstant` / `zonedMidnight` corrigent le décalage mesuré **à l'instant visé**. Le 29 mars 2026, minuit est en UTC+1 et 09 h en UTC+2 : ajouter neuf heures à minuit donnerait 10 h locales. - La semaine du retour à l'heure d'hiver dure 169 h, celle du passage à l'heure d'été 167 — vérifié par test. Écritures - Création, modification, suppression de créneau ; publication et dépublication **par équipe**, avec verrou optimiste sur `version` : deux managers sur la même grille est le cas normal, pas l'exception. - Chevauchement refusé en transaction, pas seulement dans le formulaire. - Modifier une semaine publiée exige `planning.edit_published`, capacité que le rôle manager n'a pas : un salarié a organisé sa semaine sur ce qu'il a lu. - Toute mutation laisse une entrée d'audit ; la suppression écrit sa trace **avant** l'effacement, sinon l'état supprimé serait perdu. Lecture - `planning.view_unpublished` filtre en base : sans cette capacité, les brouillons ne sont pas chargés du tout. Un test vérifie que les horaires n'apparaissent pas dans le HTML servi — un masquage CSS les y laisserait. - Vue jour reconstruite sur les mêmes données, amplitude déduite de la journée réelle plutôt que figée à 06 h–21 h. Vérification - 26 tests unitaires sur le repérage des semaines et la mise en grille. - Parcours e2e : poser un créneau, refus de chevauchement, publier, dépublier ; et ce que voient un salarié et un manager sur la même semaine. - `scripts/dev-db.sh` : la base de développement est éphémère dans cet environnement, la remonter ne doit pas être une redécouverte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
27 files changed
+3373
-674
No files matched your search
@@ -40,12 +40,12 @@ export default defineConfig({
|
||||
// Parcours d'authentification : doit partir d'un navigateur vierge.
|
||||
{
|
||||
name: 'anonyme',
|
||||
testMatch: /(auth|reglages)\.spec\.ts/,
|
||||
testMatch: /(auth|reglages|acces)\.spec\.ts/,
|
||||
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
testIgnore: /(auth\.setup|auth\.spec|reglages\.spec)\.ts/,
|
||||
testIgnore: /(auth\.setup|auth\.spec|reglages\.spec|acces\.spec)\.ts/,
|
||||
dependencies: ['setup'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ScheduleStatus" AS ENUM ('DRAFT', 'VALIDATED', 'PUBLISHED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RestType" AS ENUM ('WEEKLY_REST', 'COMPENSATORY_REST');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "WeeklySchedule" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"isoYear" INTEGER NOT NULL,
|
||||
"isoWeek" INTEGER NOT NULL,
|
||||
"status" "ScheduleStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"publishedAt" TIMESTAMP(3),
|
||||
"publishedBy" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "WeeklySchedule_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Shift" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"weeklyScheduleId" TEXT NOT NULL,
|
||||
"membershipId" TEXT,
|
||||
"localDate" DATE NOT NULL,
|
||||
"startAt" TIMESTAMPTZ NOT NULL,
|
||||
"endAt" TIMESTAMPTZ NOT NULL,
|
||||
"breakMinutes" INTEGER NOT NULL DEFAULT 0,
|
||||
"actualStartAt" TIMESTAMPTZ,
|
||||
"actualEndAt" TIMESTAMPTZ,
|
||||
"actualBreakMinutes" INTEGER,
|
||||
"labelId" TEXT,
|
||||
"mealCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"isValidated" BOOLEAN NOT NULL DEFAULT false,
|
||||
"validatedAt" TIMESTAMP(3),
|
||||
"validatedBy" TEXT,
|
||||
"note" TEXT,
|
||||
"version" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "Shift_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Rest" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"weeklyScheduleId" TEXT NOT NULL,
|
||||
"membershipId" TEXT NOT NULL,
|
||||
"localDate" DATE NOT NULL,
|
||||
"restType" "RestType" NOT NULL,
|
||||
"minutes" INTEGER,
|
||||
|
||||
CONSTRAINT "Rest_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DailyNote" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"weeklyScheduleId" TEXT NOT NULL,
|
||||
"localDate" DATE NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
|
||||
CONSTRAINT "DailyNote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeeklySchedule_accountId_idx" ON "WeeklySchedule"("accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "WeeklySchedule_locationId_isoYear_isoWeek_idx" ON "WeeklySchedule"("locationId", "isoYear", "isoWeek");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "WeeklySchedule_teamId_isoYear_isoWeek_key" ON "WeeklySchedule"("teamId", "isoYear", "isoWeek");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Shift_accountId_idx" ON "Shift"("accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Shift_weeklyScheduleId_localDate_idx" ON "Shift"("weeklyScheduleId", "localDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Shift_membershipId_localDate_idx" ON "Shift"("membershipId", "localDate");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Rest_accountId_idx" ON "Rest"("accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DailyNote_accountId_idx" ON "DailyNote"("accountId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Shift" ADD CONSTRAINT "Shift_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Rest" ADD CONSTRAINT "Rest_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DailyNote" ADD CONSTRAINT "DailyNote_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Étend l'isolation aux tables du planning.
|
||||
--
|
||||
-- La grille est la table la plus lue de l'application : c'est aussi celle où
|
||||
-- une fuite inter-comptes serait la plus visible. Le test « toute table portant
|
||||
-- accountId est protégée » échoue sans ceci.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY['WeeklySchedule', 'Shift', 'Rest', 'DailyNote']
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
|
||||
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())',
|
||||
t
|
||||
);
|
||||
EXECUTE format(
|
||||
'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())',
|
||||
t
|
||||
);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "TeamMember" (
|
||||
"id" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"teamId" TEXT NOT NULL,
|
||||
"membershipId" TEXT NOT NULL,
|
||||
"isPrimary" BOOLEAN NOT NULL DEFAULT true,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_accountId_idx" ON "TeamMember"("accountId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TeamMember_membershipId_idx" ON "TeamMember"("membershipId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TeamMember_teamId_membershipId_key" ON "TeamMember"("teamId", "membershipId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Isolation : `TeamMember` porte accountId, donc la politique est exigible.
|
||||
ALTER TABLE "TeamMember" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "TeamMember" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON "TeamMember"
|
||||
USING ("accountId" = planflow_current_account());
|
||||
CREATE POLICY tenant_insert ON "TeamMember"
|
||||
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
|
||||
@@ -66,11 +66,35 @@ model Team {
|
||||
|
||||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
scopes MembershipScope[]
|
||||
members TeamMember[]
|
||||
|
||||
@@index([accountId])
|
||||
@@index([locationId])
|
||||
}
|
||||
|
||||
/// Rattachement d'un salarié à une équipe.
|
||||
///
|
||||
/// Distinct de `MembershipScope`, qui dit ce qu'un manager a le droit de voir.
|
||||
/// Celui-ci dit où un salarié **apparaît dans la grille** — y compris une
|
||||
/// semaine où il n'a aucun créneau, ce qui est justement l'état de départ
|
||||
/// quand on construit un planning.
|
||||
model TeamMember {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
teamId String
|
||||
membershipId String
|
||||
/// Équipe d'affectation principale ; les autres sont des renforts.
|
||||
isPrimary Boolean @default(true)
|
||||
position Int @default(0)
|
||||
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, membershipId])
|
||||
@@index([accountId])
|
||||
@@index([membershipId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Identité
|
||||
// ============================================================================
|
||||
@@ -122,6 +146,7 @@ model Membership {
|
||||
profile EmployeeProfile?
|
||||
contracts UserContract[]
|
||||
workPermits WorkPermit[]
|
||||
teams TeamMember[]
|
||||
|
||||
@@unique([accountId, employeeNumber])
|
||||
@@index([accountId])
|
||||
@@ -545,3 +570,106 @@ model WorkloadReview {
|
||||
@@index([accountId])
|
||||
@@index([userContractId])
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Planning — PLAN.md §4.4, WP-04
|
||||
// ============================================================================
|
||||
|
||||
/// Semaine de planning d'une équipe.
|
||||
///
|
||||
/// La publication est **par équipe** : l'audit montre un bouton « Dépublier »
|
||||
/// sur chaque section de la grille, pas un seul pour l'établissement.
|
||||
model WeeklySchedule {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
locationId String
|
||||
teamId String
|
||||
isoYear Int
|
||||
isoWeek Int
|
||||
status ScheduleStatus @default(DRAFT)
|
||||
publishedAt DateTime?
|
||||
publishedBy String?
|
||||
/// Verrou optimiste : la grille s'édite à plusieurs (PLAN.md §3.4).
|
||||
version Int @default(0)
|
||||
|
||||
shifts Shift[]
|
||||
rests Rest[]
|
||||
dailyNotes DailyNote[]
|
||||
|
||||
@@unique([teamId, isoYear, isoWeek])
|
||||
@@index([accountId])
|
||||
@@index([locationId, isoYear, isoWeek])
|
||||
}
|
||||
|
||||
enum ScheduleStatus {
|
||||
DRAFT
|
||||
VALIDATED
|
||||
PUBLISHED
|
||||
}
|
||||
|
||||
model Shift {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
weeklyScheduleId String
|
||||
/// Null = besoin non couvert, affiché sur sa propre ligne.
|
||||
membershipId String?
|
||||
|
||||
/// Regroupement dans la grille uniquement. Les durées se calculent depuis
|
||||
/// les instants — la nuit du changement d'heure, 22 h–06 h dure 7 h ou 9 h.
|
||||
localDate DateTime @db.Date
|
||||
startAt DateTime @db.Timestamptz
|
||||
endAt DateTime @db.Timestamptz
|
||||
|
||||
breakMinutes Int @default(0)
|
||||
actualStartAt DateTime? @db.Timestamptz
|
||||
actualEndAt DateTime? @db.Timestamptz
|
||||
actualBreakMinutes Int?
|
||||
|
||||
labelId String?
|
||||
mealCount Int @default(0)
|
||||
isValidated Boolean @default(false)
|
||||
validatedAt DateTime?
|
||||
validatedBy String?
|
||||
note String?
|
||||
version Int @default(0)
|
||||
|
||||
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([accountId])
|
||||
@@index([weeklyScheduleId, localDate])
|
||||
@@index([membershipId, localDate])
|
||||
}
|
||||
|
||||
/// Repos de journée — hebdomadaire ou compensateur. Les pauses *dans* un
|
||||
/// créneau sont `Shift.breakMinutes`.
|
||||
model Rest {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
weeklyScheduleId String
|
||||
membershipId String
|
||||
localDate DateTime @db.Date
|
||||
restType RestType
|
||||
minutes Int?
|
||||
|
||||
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([accountId])
|
||||
}
|
||||
|
||||
enum RestType {
|
||||
WEEKLY_REST
|
||||
COMPENSATORY_REST
|
||||
}
|
||||
|
||||
/// Ligne « Notes et événements » de la grille.
|
||||
model DailyNote {
|
||||
id String @id @default(cuid())
|
||||
accountId String
|
||||
weeklyScheduleId String
|
||||
localDate DateTime @db.Date
|
||||
content String
|
||||
|
||||
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([accountId])
|
||||
}
|
||||
+287
@@ -9,6 +9,13 @@ import {
|
||||
PERMISSION_DEFINITIONS,
|
||||
SYSTEM_ROLES,
|
||||
} from '../src/domain/access/permissions';
|
||||
import {
|
||||
isoWeekOf,
|
||||
previousIsoWeek,
|
||||
weekDates,
|
||||
zonedInstant,
|
||||
} from '../src/domain/planning/week';
|
||||
import { POSTE_CODES, POSTE_LABELS } from '../src/lib/design/postes';
|
||||
|
||||
/**
|
||||
* Jeu de données de départ — PLAN.md §11.
|
||||
@@ -240,6 +247,165 @@ async function main() {
|
||||
}
|
||||
console.log(` ${contractSpecs.length} contrats`);
|
||||
|
||||
|
||||
console.log('→ Étiquettes de planning');
|
||||
for (const [index, code] of POSTE_CODES.entries()) {
|
||||
await prisma.label.upsert({
|
||||
where: { accountId_code: { accountId: account.id, code: code.toUpperCase() } },
|
||||
update: { name: POSTE_LABELS[code], paletteKey: code, position: index },
|
||||
create: {
|
||||
accountId: account.id,
|
||||
code: code.toUpperCase(),
|
||||
name: POSTE_LABELS[code],
|
||||
paletteKey: code,
|
||||
position: index,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` ${POSTE_CODES.length} étiquettes`);
|
||||
|
||||
console.log('→ Intitulés de poste');
|
||||
const jobTitles = [
|
||||
'Responsable de magasin',
|
||||
'Adjoint·e de direction',
|
||||
'Vendeur·se conseil',
|
||||
'Hôte·sse de caisse',
|
||||
'Employé·e de réserve',
|
||||
];
|
||||
const jobTitleIds = new Map<string, string>();
|
||||
for (const name of jobTitles) {
|
||||
const created = await prisma.jobTitle.upsert({
|
||||
where: { accountId_name: { accountId: account.id, name } },
|
||||
update: {},
|
||||
create: { accountId: account.id, name },
|
||||
});
|
||||
jobTitleIds.set(name, created.id);
|
||||
}
|
||||
|
||||
console.log('→ Salariés sans compte applicatif');
|
||||
// La majorité d'une équipe de vente ne se connecte jamais à l'outil : ces
|
||||
// salariés existent en planning et en paie, sans identifiants.
|
||||
const staff = [
|
||||
{ number: 'E0005', first: 'Sofia', last: 'Marchetti', job: 'Vendeur·se conseil', hours: 35, team: 0, location: 'loc-nantes' },
|
||||
{ number: 'E0006', first: 'Yanis', last: 'Trabelsi', job: 'Vendeur·se conseil', hours: 30, team: 0, location: 'loc-nantes' },
|
||||
{ number: 'E0007', first: 'Léa', last: 'Nguyen', job: 'Hôte·sse de caisse', hours: 24, team: 1, location: 'loc-nantes' },
|
||||
{ number: 'E0008', first: 'Marius', last: 'Kowalski', job: 'Hôte·sse de caisse', hours: 35, team: 1, location: 'loc-nantes' },
|
||||
{ number: 'E0009', first: 'Awa', last: 'Diallo', job: 'Employé·e de réserve', hours: 35, team: 2, location: 'loc-nantes' },
|
||||
{ number: 'E0010', first: 'Théo', last: 'Berger', job: 'Vendeur·se conseil', hours: 28, team: 0, location: 'loc-rennes' },
|
||||
{ number: 'E0011', first: 'Clara', last: 'Fontaine', job: 'Hôte·sse de caisse', hours: 35, team: 1, location: 'loc-rennes' },
|
||||
{ number: 'E0012', first: 'Noé', last: 'Perrin', job: 'Employé·e de réserve', hours: 20, team: 2, location: 'loc-rennes' },
|
||||
] as const;
|
||||
|
||||
for (const person of staff) {
|
||||
const membership = await prisma.membership.upsert({
|
||||
where: {
|
||||
accountId_employeeNumber: {
|
||||
accountId: account.id,
|
||||
employeeNumber: person.number,
|
||||
},
|
||||
},
|
||||
update: { status: 'ACTIVE' },
|
||||
create: {
|
||||
accountId: account.id,
|
||||
roleId: roleIds.get('employee') as string,
|
||||
employeeNumber: person.number,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.employeeProfile.upsert({
|
||||
where: { membershipId: membership.id },
|
||||
update: { firstName: person.first, lastName: person.last },
|
||||
create: {
|
||||
membershipId: membership.id,
|
||||
accountId: account.id,
|
||||
firstName: person.first,
|
||||
lastName: person.last,
|
||||
city: person.location === 'loc-rennes' ? 'Rennes' : 'Nantes',
|
||||
},
|
||||
});
|
||||
|
||||
const existing = await prisma.userContract.findFirst({
|
||||
where: { membershipId: membership.id },
|
||||
});
|
||||
if (!existing) {
|
||||
await prisma.userContract.create({
|
||||
data: {
|
||||
accountId: account.id,
|
||||
membershipId: membership.id,
|
||||
locationId: person.location,
|
||||
contractType: 'CDI',
|
||||
startDate: new Date('2024-09-02'),
|
||||
workTimeArrangement: 'HOURLY',
|
||||
weeklyHours: person.hours,
|
||||
jobTitleId: jobTitleIds.get(person.job) ?? null,
|
||||
monthlySalary: 1900,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.teamMember.upsert({
|
||||
where: {
|
||||
teamId_membershipId: {
|
||||
teamId: `${person.location}-${person.team}`,
|
||||
membershipId: membership.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
accountId: account.id,
|
||||
teamId: `${person.location}-${person.team}`,
|
||||
membershipId: membership.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
console.log(` ${staff.length} salariés`);
|
||||
|
||||
console.log('→ Rattachements des titulaires de comptes');
|
||||
// Les quatre comptes applicatifs planifient aussi : sans rattachement, ils
|
||||
// n'apparaîtraient pas dans la grille.
|
||||
const accountHolders = [
|
||||
{ number: 'E0001', team: 'loc-nantes-0', job: 'Responsable de magasin' },
|
||||
{ number: 'E0002', team: 'loc-nantes-0', job: 'Adjoint·e de direction' },
|
||||
{ number: 'E0003', team: 'loc-rennes-0', job: 'Responsable de magasin' },
|
||||
{ number: 'E0004', team: 'loc-nantes-1', job: 'Hôte·sse de caisse' },
|
||||
] as const;
|
||||
|
||||
for (const holder of accountHolders) {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
accountId_employeeNumber: {
|
||||
accountId: account.id,
|
||||
employeeNumber: holder.number,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!membership) continue;
|
||||
|
||||
await prisma.userContract.updateMany({
|
||||
where: { membershipId: membership.id },
|
||||
data: { jobTitleId: jobTitleIds.get(holder.job) ?? null },
|
||||
});
|
||||
|
||||
await prisma.teamMember.upsert({
|
||||
where: {
|
||||
teamId_membershipId: {
|
||||
teamId: holder.team,
|
||||
membershipId: membership.id,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
accountId: account.id,
|
||||
teamId: holder.team,
|
||||
membershipId: membership.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log('→ Plannings');
|
||||
await seedPlanning(account.id);
|
||||
|
||||
console.log('→ Durées de conservation');
|
||||
const retention = [
|
||||
['Shift', 12, 'creation', 'Décompte des horaires : 1 an minimum (matrice n° 21).'],
|
||||
@@ -274,6 +440,127 @@ async function main() {
|
||||
console.log(`\nMot de passe de démonstration : ${DEMO_PASSWORD}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux semaines de planning : la précédente publiée, la courante en brouillon.
|
||||
*
|
||||
* Semer les deux états est délibéré — c'est la seule façon de voir en un coup
|
||||
* d'œil que la publication est bien **par équipe** et que les créneaux d'une
|
||||
* semaine publiée se distinguent d'un brouillon.
|
||||
*/
|
||||
async function seedPlanning(accountId: string): Promise<void> {
|
||||
const current = isoWeekOf(new Date());
|
||||
const previous = previousIsoWeek(current);
|
||||
|
||||
const teams = await prisma.team.findMany({
|
||||
where: { accountId },
|
||||
orderBy: [{ locationId: 'asc' }, { position: 'asc' }],
|
||||
});
|
||||
const locations = await prisma.location.findMany({ where: { accountId } });
|
||||
const timezoneOf = new Map(locations.map((l) => [l.id, l.timezone]));
|
||||
|
||||
const labels = await prisma.label.findMany({ where: { accountId } });
|
||||
const labelOf = new Map(labels.map((label) => [label.paletteKey, label.id]));
|
||||
|
||||
/** Poste dominant de l'équipe, par position. */
|
||||
const teamPoste = ['vte', 'cai', 'res'];
|
||||
|
||||
/** Trois plages types du commerce de détail. */
|
||||
const patterns = [
|
||||
{ start: '09:00', end: '17:00', pause: 60 },
|
||||
{ start: '11:00', end: '19:00', pause: 60 },
|
||||
{ start: '13:00', end: '20:00', pause: 30 },
|
||||
];
|
||||
|
||||
let shiftsCreated = 0;
|
||||
|
||||
for (const week of [previous, current]) {
|
||||
const dates = weekDates(week);
|
||||
|
||||
for (const team of teams) {
|
||||
const timezone = timezoneOf.get(team.locationId) ?? 'Europe/Paris';
|
||||
const members = await prisma.teamMember.findMany({
|
||||
where: { teamId: team.id },
|
||||
orderBy: { position: 'asc' },
|
||||
});
|
||||
if (members.length === 0) continue;
|
||||
|
||||
const schedule = await prisma.weeklySchedule.upsert({
|
||||
where: {
|
||||
teamId_isoYear_isoWeek: {
|
||||
teamId: team.id,
|
||||
isoYear: week.isoYear,
|
||||
isoWeek: week.isoWeek,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
accountId,
|
||||
teamId: team.id,
|
||||
locationId: team.locationId,
|
||||
isoYear: week.isoYear,
|
||||
isoWeek: week.isoWeek,
|
||||
status: week === previous ? 'PUBLISHED' : 'DRAFT',
|
||||
publishedAt: week === previous ? new Date() : null,
|
||||
},
|
||||
});
|
||||
|
||||
const already = await prisma.shift.count({
|
||||
where: { weeklyScheduleId: schedule.id },
|
||||
});
|
||||
if (already > 0) continue;
|
||||
|
||||
const poste = teamPoste[team.position] ?? 'vte';
|
||||
const labelId = labelOf.get(poste) ?? null;
|
||||
|
||||
for (const [index, member] of members.entries()) {
|
||||
const pattern = patterns[index % patterns.length] as (typeof patterns)[number];
|
||||
// Deux jours de repos glissants : la semaine de six jours consécutifs
|
||||
// est justement ce que les règles de convention interdisent.
|
||||
const rest = new Set([(index * 2) % 7, ((index * 2) + 1) % 7]);
|
||||
|
||||
for (const [dayIndex, date] of dates.entries()) {
|
||||
if (rest.has(dayIndex)) continue;
|
||||
await prisma.shift.create({
|
||||
data: {
|
||||
accountId,
|
||||
weeklyScheduleId: schedule.id,
|
||||
membershipId: member.membershipId,
|
||||
localDate: new Date(`${date}T00:00:00Z`),
|
||||
startAt: zonedInstant(date, pattern.start, timezone),
|
||||
endAt: zonedInstant(date, pattern.end, timezone),
|
||||
breakMinutes: pattern.pause,
|
||||
labelId,
|
||||
isValidated: week === previous,
|
||||
},
|
||||
});
|
||||
shiftsCreated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Un besoin non couvert par équipe sur la semaine courante : c'est l'état
|
||||
// normal d'un planning en construction, et la ligne dédiée doit s'afficher.
|
||||
if (week === current) {
|
||||
const saturday = dates[5] as string;
|
||||
await prisma.shift.create({
|
||||
data: {
|
||||
accountId,
|
||||
weeklyScheduleId: schedule.id,
|
||||
membershipId: null,
|
||||
localDate: new Date(`${saturday}T00:00:00Z`),
|
||||
startAt: zonedInstant(saturday, '10:00', timezone),
|
||||
endAt: zonedInstant(saturday, '18:00', timezone),
|
||||
breakMinutes: 60,
|
||||
labelId,
|
||||
},
|
||||
});
|
||||
shiftsCreated += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` ${shiftsCreated} créneaux sur 2 semaines`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
|
||||
Executable
+41
@@ -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"
|
||||
@@ -1,48 +1,105 @@
|
||||
import { DayTimeline, type DayLane } from '@/components/planning/DayTimeline';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { DayTimeline } from '@/components/planning/DayTimeline';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { UNASSIGNED_ROW, WEEK_ROWS } from '@/lib/demo/semaine';
|
||||
import { getDayBoard } from '@/server/planning/queries';
|
||||
|
||||
export const metadata = { title: 'Planning · mercredi 12 août · PlanFlow' };
|
||||
export const metadata = { title: 'Planning · jour · PlanFlow' };
|
||||
|
||||
/** Mercredi = index 2 dans la semaine de démonstration. */
|
||||
const DAY_INDEX = 2;
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ jour?: string; etablissement?: string }>;
|
||||
}
|
||||
|
||||
export default function JourPage() {
|
||||
const lanes: DayLane[] = WEEK_ROWS.map((row) => ({
|
||||
employee: row.employee,
|
||||
shifts: row.days[DAY_INDEX] ?? [],
|
||||
})).filter((lane) => lane.shifts.length > 0);
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const unassigned = UNASSIGNED_ROW.days[DAY_INDEX] ?? [];
|
||||
if (unassigned.length > 0) {
|
||||
lanes.push({
|
||||
employee: UNASSIGNED_ROW.employee,
|
||||
shifts: unassigned,
|
||||
unassigned: true,
|
||||
});
|
||||
export default async function JourPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const isoDate =
|
||||
params.jour && ISO_DATE.test(params.jour)
|
||||
? params.jour
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
|
||||
const board = await getDayBoard(isoDate, params.etablissement);
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Planning · jour"
|
||||
subtitle="Aucun établissement n'est encore créé."
|
||||
/>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
const href = (jour: string, etablissement = board.location.id) =>
|
||||
`/planning/jour?jour=${jour}&etablissement=${etablissement}`;
|
||||
const present = board.lanes.filter((lane) => !lane.unassigned).length;
|
||||
const open = board.lanes.find((lane) => lane.unassigned)?.shifts.length ?? 0;
|
||||
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Planning · mercredi 12 août"
|
||||
subtitle="Nantes Atlantis · amplitude 06:00 – 21:00"
|
||||
title={`Planning · ${board.label}`}
|
||||
subtitle={`${board.location.name} · amplitude ${String(board.fromHour).padStart(2, '0')}:00 – ${String(board.toHour % 24).padStart(2, '0')}:00`}
|
||||
actions={
|
||||
<>
|
||||
<Button>Imprimer la journée</Button>
|
||||
<Button variant="primary">Ajouter un créneau</Button>
|
||||
<Link
|
||||
href={href(board.previousDate)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
← Jour précédent
|
||||
</Link>
|
||||
<Link
|
||||
href={href(board.nextDate)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
Jour suivant →
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="accent">{lanes.length} salariés présents</Badge>
|
||||
<Badge tone="warn">Creux de couverture à 13 h</Badge>
|
||||
{board.locations.map((location) => (
|
||||
<Link
|
||||
key={location.id}
|
||||
href={href(board.isoDate, location.id)}
|
||||
aria-current={
|
||||
location.id === board.location.id ? 'page' : undefined
|
||||
}
|
||||
className={
|
||||
location.id === board.location.id
|
||||
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
|
||||
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
|
||||
}
|
||||
>
|
||||
{location.name}
|
||||
</Link>
|
||||
))}
|
||||
<Badge tone="accent">
|
||||
{present} salarié{present > 1 ? 's' : ''} présent
|
||||
{present > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
{open > 0 ? (
|
||||
<Badge tone="warn">
|
||||
{open} besoin{open > 1 ? 's' : ''} non couvert{open > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DayTimeline lanes={lanes} fromHour={6} toHour={21} />
|
||||
{board.lanes.length === 0 ? (
|
||||
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
|
||||
Personne n’est planifié ce jour-là.
|
||||
</p>
|
||||
) : (
|
||||
<DayTimeline
|
||||
lanes={board.lanes}
|
||||
fromHour={board.fromHour}
|
||||
toHour={board.toHour}
|
||||
/>
|
||||
)}
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,120 @@
|
||||
import { WeekGrid } from '@/components/planning/WeekGrid';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { TeamSection } from '@/components/planning/TeamSection';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
|
||||
import { POSTE_CODES, POSTE_LABELS, posteTokens } from '@/lib/design/postes';
|
||||
import { UNASSIGNED_ROW, WEEK_DAYS, WEEK_LABEL, WEEK_ROWS } from '@/lib/demo/semaine';
|
||||
import { requireSession } from '@/server/context';
|
||||
import { getWeekBoard } from '@/server/planning/queries';
|
||||
|
||||
export const metadata = { title: 'Planning · semaine 33 · PlanFlow' };
|
||||
export const metadata = { title: 'Planning · semaine · PlanFlow' };
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
|
||||
}
|
||||
|
||||
export default async function SemainePage({ searchParams }: PageProps) {
|
||||
const params = await searchParams;
|
||||
const session = await requireSession();
|
||||
|
||||
// Semaine courante par défaut : ouvrir le planning sur une semaine arbitraire
|
||||
// obligerait à naviguer avant de voir quoi que ce soit d'utile.
|
||||
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
|
||||
const board = await getWeekBoard(week, params.etablissement);
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Planning"
|
||||
subtitle="Aucun établissement n'est encore créé."
|
||||
/>
|
||||
<p className="text-sm text-ink-2">
|
||||
Créez un établissement dans{' '}
|
||||
<Link href="/reglages/etablissements" className="underline">
|
||||
Réglages · Établissements
|
||||
</Link>{' '}
|
||||
pour commencer à planifier.
|
||||
</p>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
const canEdit = can(session.actor, 'planning.create');
|
||||
const canPublish = can(session.actor, 'planning.publish');
|
||||
const href = (semaine: string, etablissement = board.location.id) =>
|
||||
`/planning/semaine?semaine=${semaine}&etablissement=${etablissement}`;
|
||||
|
||||
export default function SemainePage() {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Planning · semaine 33"
|
||||
subtitle={`Nantes Atlantis · ${WEEK_LABEL.split('·')[1]?.trim()} · ${WEEK_ROWS.length} salariés planifiés`}
|
||||
title={`Planning · semaine ${week.isoWeek}`}
|
||||
subtitle={`${board.location.name} · ${board.label.split('·')[1]?.trim()} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
|
||||
actions={
|
||||
<>
|
||||
<Button>Dupliquer S‑32</Button>
|
||||
<Button>Imprimer</Button>
|
||||
<Button variant="primary">Publier la semaine</Button>
|
||||
<Link
|
||||
href={href(board.previousParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
← Semaine précédente
|
||||
</Link>
|
||||
<Link
|
||||
href={href(board.nextParam)}
|
||||
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
|
||||
>
|
||||
Semaine suivante →
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="warn">2 alertes de convention</Badge>
|
||||
<Badge tone="info">Samedi non publié</Badge>
|
||||
<Badge tone="neutral">5 besoins non couverts</Badge>
|
||||
{board.locations.map((location) => (
|
||||
<Link
|
||||
key={location.id}
|
||||
href={href(board.weekParam, location.id)}
|
||||
aria-current={
|
||||
location.id === board.location.id ? 'page' : undefined
|
||||
}
|
||||
className={
|
||||
location.id === board.location.id
|
||||
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
|
||||
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
|
||||
}
|
||||
>
|
||||
{location.name}
|
||||
</Link>
|
||||
))}
|
||||
{board.totals.unassigned > 0 ? (
|
||||
<Badge tone="warn">
|
||||
{board.totals.unassigned} besoin
|
||||
{board.totals.unassigned > 1 ? 's' : ''} non couvert
|
||||
{board.totals.unassigned > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<WeekGrid
|
||||
days={WEEK_DAYS}
|
||||
rows={WEEK_ROWS}
|
||||
unassignedRow={UNASSIGNED_ROW}
|
||||
/>
|
||||
{board.sections.length === 0 ? (
|
||||
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
|
||||
Cet établissement n’a pas encore d’équipe.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{board.sections.map((section) => (
|
||||
<TeamSection
|
||||
key={section.teamId}
|
||||
section={section}
|
||||
days={board.headings}
|
||||
dates={board.dates}
|
||||
labels={board.labels}
|
||||
weekParam={board.weekParam}
|
||||
canEdit={canEdit}
|
||||
canPublish={canPublish}
|
||||
/>
|
||||
))}
|
||||
|
||||
<PosteLegend />
|
||||
</PageBody>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { posteShort, posteTokens } from '@/lib/design/postes';
|
||||
import { cx } from '@/lib/cx';
|
||||
import { clock, initials, type DemoEmployee, type DemoShift } from '@/lib/demo/types';
|
||||
import type { DayLane } from '@/server/planning/queries';
|
||||
|
||||
export interface DayLane {
|
||||
employee: DemoEmployee;
|
||||
shifts: DemoShift[];
|
||||
unassigned?: boolean;
|
||||
/** « 09:00 » depuis des minutes après minuit ; 25 h devient 01:00. */
|
||||
function clock(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60) % 24;
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export type { DayLane };
|
||||
|
||||
export interface DayTimelineProps {
|
||||
lanes: DayLane[];
|
||||
/** Amplitude affichée, en heures locales. */
|
||||
@@ -32,7 +34,9 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
|
||||
return lanes.filter(
|
||||
(lane) =>
|
||||
!lane.unassigned &&
|
||||
lane.shifts.some((shift) => shift.start < end && shift.end > start),
|
||||
lane.shifts.some(
|
||||
(shift) => shift.startMinutes < end && shift.endMinutes > start,
|
||||
),
|
||||
).length;
|
||||
});
|
||||
const peak = Math.max(1, ...coverage);
|
||||
@@ -95,7 +99,7 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
|
||||
|
||||
{lanes.map((lane) => (
|
||||
<div
|
||||
key={lane.employee.id}
|
||||
key={lane.id}
|
||||
className={cx(
|
||||
'flex border-b border-line-1 last:border-b-0',
|
||||
lane.unassigned && 'bg-surface-2',
|
||||
@@ -111,7 +115,7 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
|
||||
: 'bg-surface-3 text-ink-2',
|
||||
)}
|
||||
>
|
||||
{lane.unassigned ? '?' : initials(lane.employee)}
|
||||
{lane.initials}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
@@ -120,12 +124,10 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
|
||||
lane.unassigned ? 'text-ink-3' : 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{lane.unassigned
|
||||
? 'Non assigné'
|
||||
: `${lane.employee.firstName} ${lane.employee.lastName}`}
|
||||
{lane.name}
|
||||
</span>
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
{lane.employee.job}
|
||||
{lane.job}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -140,29 +142,29 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
|
||||
/>
|
||||
))}
|
||||
|
||||
{lane.shifts.map((shift, index) => {
|
||||
{lane.shifts.map((shift) => {
|
||||
const tokens = posteTokens(shift.poste);
|
||||
const ghost = shift.state === 'unassigned';
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
key={shift.id}
|
||||
className="absolute top-1 bottom-1 flex items-center gap-1.5 overflow-hidden rounded-2 px-2 text-micro"
|
||||
style={{
|
||||
left: offset(shift.start),
|
||||
width: `calc(${offset(shift.end)} - ${offset(shift.start)})`,
|
||||
left: offset(shift.startMinutes),
|
||||
width: `calc(${offset(shift.endMinutes)} - ${offset(shift.startMinutes)})`,
|
||||
background: ghost ? 'transparent' : tokens.bg,
|
||||
color: ghost ? 'var(--color-ink-2)' : tokens.fg,
|
||||
border: ghost
|
||||
? `1px dashed ${tokens.edge}`
|
||||
: '1px solid transparent',
|
||||
}}
|
||||
title={`${posteShort(shift.poste)} · ${clock(shift.start)}–${clock(shift.end)}`}
|
||||
title={`${posteShort(shift.poste)} · ${clock(shift.startMinutes)}–${clock(shift.endMinutes)}`}
|
||||
>
|
||||
<span className="font-semibold tracking-wide">
|
||||
{posteShort(shift.poste)}
|
||||
</span>
|
||||
<span className="tnum truncate opacity-90">
|
||||
{clock(shift.start)}–{clock(shift.end)}
|
||||
{clock(shift.startMinutes)}–{clock(shift.endMinutes)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useEffect, useState } from 'react';
|
||||
|
||||
import { WeekGrid } from '@/components/planning/WeekGrid';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { formatMinutes } from '@/domain/counters/week';
|
||||
import { displayName, type BoardRow } from '@/domain/planning/board';
|
||||
import {
|
||||
createShiftAction,
|
||||
deleteShiftAction,
|
||||
publishWeekAction,
|
||||
unpublishWeekAction,
|
||||
type PlanningActionState,
|
||||
} from '@/server/planning/actions';
|
||||
import type { BoardLabel, BoardSection } from '@/server/planning/queries';
|
||||
|
||||
export interface TeamSectionProps {
|
||||
section: BoardSection;
|
||||
days: string[];
|
||||
dates: string[];
|
||||
labels: BoardLabel[];
|
||||
weekParam: string;
|
||||
/** Faux quand la capacité de modification manque : la grille reste lisible. */
|
||||
canEdit: boolean;
|
||||
canPublish: boolean;
|
||||
}
|
||||
|
||||
const empty: PlanningActionState = {};
|
||||
|
||||
/**
|
||||
* Une équipe, une grille, un état de publication.
|
||||
*
|
||||
* La publication est **par équipe** : l'audit montre un bouton « Dépublier »
|
||||
* sur chaque section, pas un pour l'établissement. Le regrouper ferait
|
||||
* republier la caisse quand on corrige la réserve.
|
||||
*/
|
||||
export function TeamSection({
|
||||
section,
|
||||
days,
|
||||
dates,
|
||||
labels,
|
||||
weekParam,
|
||||
canEdit,
|
||||
canPublish,
|
||||
}: TeamSectionProps) {
|
||||
const [target, setTarget] = useState<{
|
||||
membershipId: string | null;
|
||||
dayIndex: number;
|
||||
} | null>(null);
|
||||
|
||||
const published = section.status === 'PUBLISHED';
|
||||
const plannedMinutes = section.rows.reduce(
|
||||
(total, row) => total + row.counters.plannedMinutes,
|
||||
0,
|
||||
);
|
||||
|
||||
if (section.hidden) {
|
||||
// Le brouillon n'est pas seulement masqué : il n'a pas été chargé. Dire
|
||||
// « pas encore publié » est honnête et n'apprend rien de son contenu.
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-ink-1">
|
||||
{section.teamName}
|
||||
</h2>
|
||||
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
|
||||
Le planning de cette équipe n’est pas encore publié.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base font-semibold text-ink-1">
|
||||
{section.teamName}
|
||||
</h2>
|
||||
<Badge tone={published ? 'ok' : 'warn'}>
|
||||
{published ? 'Publiée' : 'Brouillon'}
|
||||
</Badge>
|
||||
<span className="text-xs text-ink-3">
|
||||
{formatMinutes(plannedMinutes)} planifiées
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{canPublish ? (
|
||||
<PublishControl
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
version={section.version}
|
||||
published={published}
|
||||
/>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<WeekGrid
|
||||
days={days}
|
||||
dates={dates}
|
||||
rows={section.rows}
|
||||
unassignedRow={section.unassignedRow}
|
||||
{...(canEdit
|
||||
? {
|
||||
cellAction: (row: BoardRow, dayIndex: number) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setTarget({ membershipId: row.membershipId, dayIndex })
|
||||
}
|
||||
className="mt-auto rounded-2 border border-dashed border-line-2 px-1 py-0.5 text-micro text-ink-3 opacity-0 transition-opacity hover:border-accent hover:text-accent focus-visible:opacity-100 group-hover/cell:opacity-100"
|
||||
>
|
||||
+ Créneau
|
||||
<span className="sr-only">
|
||||
{` pour ${row.unassigned ? 'un besoin non couvert' : displayName(row)} le ${days[dayIndex] ?? ''}`}
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
shiftAction: (shiftId: string) => (
|
||||
<DeleteShift shiftId={shiftId} />
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
|
||||
{target ? (
|
||||
<ShiftComposer
|
||||
teamId={section.teamId}
|
||||
weekParam={weekParam}
|
||||
labels={labels}
|
||||
rows={section.rows}
|
||||
localDate={dates[target.dayIndex] ?? ''}
|
||||
dayLabel={days[target.dayIndex] ?? ''}
|
||||
membershipId={target.membershipId}
|
||||
onClose={() => setTarget(null)}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PublishControl({
|
||||
teamId,
|
||||
weekParam,
|
||||
version,
|
||||
published,
|
||||
}: {
|
||||
teamId: string;
|
||||
weekParam: string;
|
||||
version: number;
|
||||
published: boolean;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(
|
||||
published ? unpublishWeekAction : publishWeekAction,
|
||||
empty,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex items-center gap-2">
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="week" value={weekParam} />
|
||||
{/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */}
|
||||
<input type="hidden" name="expectedVersion" value={version} />
|
||||
{state.error ? (
|
||||
<span role="alert" className="text-xs text-danger">
|
||||
{state.error}
|
||||
</span>
|
||||
) : null}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant={published ? 'secondary' : 'primary'}
|
||||
disabled={pending}
|
||||
>
|
||||
{published ? 'Dépublier' : 'Publier'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteShift({ shiftId }: { shiftId: string }) {
|
||||
const [state, formAction, pending] = useActionState(deleteShiftAction, empty);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="absolute -top-1 -right-1">
|
||||
<input type="hidden" name="shiftId" value={shiftId} />
|
||||
{/* Volontairement pas désactivé pendant l'envoi : la suppression fait
|
||||
disparaître le créneau, donc le bouton, et un état désactivé
|
||||
transitoire ne protège de rien tout en rendant la cible instable. */}
|
||||
<button
|
||||
type="submit"
|
||||
aria-busy={pending}
|
||||
title={state.error ?? 'Supprimer le créneau'}
|
||||
className="flex size-4 items-center justify-center rounded-full border border-line-2 bg-surface text-micro leading-none text-ink-3 opacity-0 hover:border-danger hover:text-danger focus-visible:opacity-100 group-hover/cell:opacity-100"
|
||||
>
|
||||
<span aria-hidden>×</span>
|
||||
<span className="sr-only">Supprimer le créneau</span>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function ShiftComposer({
|
||||
teamId,
|
||||
weekParam,
|
||||
labels,
|
||||
rows,
|
||||
localDate,
|
||||
dayLabel,
|
||||
membershipId,
|
||||
onClose,
|
||||
}: {
|
||||
teamId: string;
|
||||
weekParam: string;
|
||||
labels: BoardLabel[];
|
||||
rows: BoardRow[];
|
||||
localDate: string;
|
||||
dayLabel: string;
|
||||
membershipId: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState(createShiftAction, empty);
|
||||
|
||||
// La grille est déjà revalidée côté serveur ; refermer le panneau évite de
|
||||
// reposter le même créneau par inadvertance.
|
||||
useEffect(() => {
|
||||
if (state.ok) onClose();
|
||||
}, [state.ok, onClose]);
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
|
||||
>
|
||||
<input type="hidden" name="teamId" value={teamId} />
|
||||
<input type="hidden" name="week" value={weekParam} />
|
||||
<input type="hidden" name="localDate" value={localDate} />
|
||||
|
||||
<Field label="Jour">
|
||||
<span className="flex h-8 items-center px-1 text-sm text-ink-2">
|
||||
{dayLabel}
|
||||
</span>
|
||||
</Field>
|
||||
|
||||
<Field label="Salarié" htmlFor="composer-membership">
|
||||
<select
|
||||
id="composer-membership"
|
||||
name="membershipId"
|
||||
defaultValue={membershipId ?? ''}
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
<option value="">Besoin non couvert</option>
|
||||
{rows.map((row) => (
|
||||
<option key={row.membershipId} value={row.membershipId ?? ''}>
|
||||
{displayName(row)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Début" htmlFor="composer-start">
|
||||
<input
|
||||
id="composer-start"
|
||||
name="start"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="09:00"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Fin" htmlFor="composer-end">
|
||||
<input
|
||||
id="composer-end"
|
||||
name="end"
|
||||
type="time"
|
||||
required
|
||||
defaultValue="17:00"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Pause (min)" htmlFor="composer-break">
|
||||
<input
|
||||
id="composer-break"
|
||||
name="breakMinutes"
|
||||
type="number"
|
||||
min={0}
|
||||
max={600}
|
||||
step={5}
|
||||
defaultValue={0}
|
||||
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Poste" htmlFor="composer-label">
|
||||
<select
|
||||
id="composer-label"
|
||||
name="labelId"
|
||||
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
|
||||
>
|
||||
{labels.map((label) => (
|
||||
<option key={label.id} value={label.id}>
|
||||
{label.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" variant="primary" size="sm" disabled={pending}>
|
||||
Ajouter
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onClose}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{state.error ? (
|
||||
<p role="alert" className="w-full text-xs text-danger">
|
||||
{state.error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
import { AbsenceBar } from '@/components/planning/AbsenceBar';
|
||||
import { CounterStrip } from '@/components/planning/CounterStrip';
|
||||
import { ShiftChip } from '@/components/planning/ShiftChip';
|
||||
import { computeWeekCounters } from '@/domain/counters/week';
|
||||
import { cx } from '@/lib/cx';
|
||||
import {
|
||||
fullName,
|
||||
initials,
|
||||
timeRange,
|
||||
type DemoWeekRow,
|
||||
} from '@/lib/demo/types';
|
||||
displayName,
|
||||
initialsOf,
|
||||
type BoardRow,
|
||||
} from '@/domain/planning/board';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
export interface WeekGridProps {
|
||||
days: readonly string[];
|
||||
rows: DemoWeekRow[];
|
||||
unassignedRow?: DemoWeekRow;
|
||||
dates: readonly string[];
|
||||
rows: BoardRow[];
|
||||
unassignedRow?: BoardRow | null;
|
||||
/** Rendu de la case d'un jour : sert à greffer l'ajout d'un créneau. */
|
||||
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
|
||||
/** Rendu attaché à un créneau : suppression, édition. */
|
||||
shiftAction?: (shiftId: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,14 +27,41 @@ export interface WeekGridProps {
|
||||
* majoré et repos compensateur — et les distinguer d'un coup d'œil évite de les
|
||||
* planifier par inadvertance.
|
||||
*/
|
||||
export function WeekGrid({ days, rows, unassignedRow }: WeekGridProps) {
|
||||
export function WeekGrid({
|
||||
days,
|
||||
dates,
|
||||
rows,
|
||||
unassignedRow,
|
||||
cellAction,
|
||||
shiftAction,
|
||||
}: WeekGridProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<div className="min-w-[1040px]">
|
||||
<GridHeader days={days} />
|
||||
{unassignedRow ? <Row row={unassignedRow} days={days} /> : null}
|
||||
{unassignedRow ? (
|
||||
<Row
|
||||
row={unassignedRow}
|
||||
days={days}
|
||||
dates={dates}
|
||||
{...(cellAction ? { cellAction } : {})}
|
||||
{...(shiftAction ? { shiftAction } : {})}
|
||||
/>
|
||||
) : null}
|
||||
{rows.length === 0 && !unassignedRow ? (
|
||||
<p className="px-4 py-6 text-sm text-ink-3">
|
||||
Aucun salarié rattaché à cette équipe.
|
||||
</p>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
<Row key={row.employee.id} row={row} days={days} />
|
||||
<Row
|
||||
key={row.membershipId ?? 'unassigned'}
|
||||
row={row}
|
||||
days={days}
|
||||
dates={dates}
|
||||
{...(cellAction ? { cellAction } : {})}
|
||||
{...(shiftAction ? { shiftAction } : {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,9 +91,21 @@ function GridHeader({ days }: { days: readonly string[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
|
||||
function Row({
|
||||
row,
|
||||
days,
|
||||
dates,
|
||||
cellAction,
|
||||
shiftAction,
|
||||
}: {
|
||||
row: BoardRow;
|
||||
days: readonly string[];
|
||||
dates: readonly string[];
|
||||
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
|
||||
shiftAction?: (shiftId: string) => React.ReactNode;
|
||||
}) {
|
||||
const counters = computeWeekCounters(row.counters);
|
||||
const name = row.unassigned ? 'Non assigné' : fullName(row.employee);
|
||||
const name = row.unassigned ? 'Non assigné' : displayName(row);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -86,7 +128,7 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
|
||||
aria-hidden
|
||||
className="flex size-6 items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
|
||||
>
|
||||
{initials(row.employee)}
|
||||
{initialsOf(row)}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
@@ -99,57 +141,43 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
|
||||
{name}
|
||||
</span>
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
{row.employee.job}
|
||||
{row.job}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<CounterStrip
|
||||
counters={counters}
|
||||
forfaitJours={row.employee.forfaitJours ?? false}
|
||||
unassigned={row.unassigned ?? false}
|
||||
forfaitJours={row.forfaitJours}
|
||||
unassigned={row.unassigned}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative grid flex-1 grid-cols-7">
|
||||
{row.absence ? (
|
||||
<AbsenceBar
|
||||
kind={row.absence.kind}
|
||||
label={row.absence.label}
|
||||
duration={`${row.absence.span} j`}
|
||||
startDay={row.absence.startDay}
|
||||
span={row.absence.span}
|
||||
className="top-1.5"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{days.map((day, index) => {
|
||||
const shifts = row.days[index] ?? [];
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
key={dates[index] ?? day}
|
||||
className={cx(
|
||||
'flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
|
||||
'group/cell flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
|
||||
index > 4 && 'bg-surface-2',
|
||||
row.absence &&
|
||||
index >= row.absence.startDay &&
|
||||
index < row.absence.startDay + row.absence.span &&
|
||||
'pt-7',
|
||||
)}
|
||||
>
|
||||
{shifts.map((shift, position) => (
|
||||
<ShiftChip
|
||||
key={`${day}-${position}`}
|
||||
poste={shift.poste}
|
||||
time={timeRange(shift)}
|
||||
state={shift.state ?? 'published'}
|
||||
{...(shift.delta ? { delta: shift.delta } : {})}
|
||||
{...(shift.alert ? { alert: shift.alert } : {})}
|
||||
{...(shift.breakMinutes
|
||||
? { breakMinutes: shift.breakMinutes }
|
||||
: {})}
|
||||
/>
|
||||
{shifts.map((shift) => (
|
||||
<div key={shift.id} className="relative">
|
||||
<ShiftChip
|
||||
poste={shift.poste}
|
||||
time={shift.time}
|
||||
state={shift.state}
|
||||
{...(shift.breakMinutes
|
||||
? { breakMinutes: shift.breakMinutes }
|
||||
: {})}
|
||||
/>
|
||||
{shiftAction?.(shift.id)}
|
||||
</div>
|
||||
))}
|
||||
{cellAction?.(row, index)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Mise en forme de la grille hebdomadaire.
|
||||
*
|
||||
* Volontairement séparé de l'accès aux données : ranger sept jours de créneaux
|
||||
* en colonnes est la partie où l'on se trompe (fuseau, semaine à cheval,
|
||||
* créneau de nuit), et elle se teste sans base.
|
||||
*/
|
||||
|
||||
import type { ShiftState } from '@/components/planning/ShiftChip';
|
||||
import type { WeekCounters } from '@/domain/counters/week';
|
||||
import { shiftMinutes } from '@/domain/counters/week';
|
||||
import { zonedClock, zonedDate } from '@/domain/planning/week';
|
||||
import type { PosteCode } from '@/lib/design/postes';
|
||||
|
||||
export interface BoardShiftInput {
|
||||
id: string;
|
||||
membershipId: string | null;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
breakMinutes: number;
|
||||
poste: PosteCode;
|
||||
isValidated: boolean;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface BoardShift {
|
||||
id: string;
|
||||
poste: PosteCode;
|
||||
/** « 09:00–17:00 », en heure locale de l'établissement. */
|
||||
time: string;
|
||||
minutes: number;
|
||||
breakMinutes: number;
|
||||
state: ShiftState;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface BoardRow {
|
||||
/** `null` sur la ligne des besoins non couverts. */
|
||||
membershipId: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
job: string;
|
||||
forfaitJours: boolean;
|
||||
counters: WeekCounters;
|
||||
/** Sept cases, lundi → dimanche. */
|
||||
days: BoardShift[][];
|
||||
unassigned: boolean;
|
||||
}
|
||||
|
||||
export interface RowPerson {
|
||||
membershipId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
job: string;
|
||||
forfaitJours: boolean;
|
||||
contractMinutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Range les créneaux d'une semaine en lignes × sept colonnes.
|
||||
*
|
||||
* Le jour de rattachement se déduit de `startAt` **vu depuis le fuseau de
|
||||
* l'établissement**, pas de la colonne `localDate` : celle-ci est un cache de
|
||||
* regroupement, et un créneau déplacé sans la mettre à jour se rangerait au
|
||||
* mauvais endroit. Ici, les deux ne peuvent pas diverger.
|
||||
*/
|
||||
export function buildRows(
|
||||
people: RowPerson[],
|
||||
shifts: BoardShiftInput[],
|
||||
weekDates: string[],
|
||||
timeZone: string,
|
||||
isPublished: boolean,
|
||||
): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
|
||||
const columnOf = new Map(weekDates.map((date, index) => [date, index]));
|
||||
|
||||
const emptyDays = (): BoardShift[][] =>
|
||||
Array.from({ length: 7 }, () => [] as BoardShift[]);
|
||||
|
||||
const byMember = new Map<string, BoardShift[][]>();
|
||||
const minutesByMember = new Map<string, number>();
|
||||
const sundaysByMember = new Map<string, Set<number>>();
|
||||
const unassignedDays = emptyDays();
|
||||
let hasUnassigned = false;
|
||||
|
||||
for (const shift of shifts) {
|
||||
const column = columnOf.get(zonedDate(shift.startAt, timeZone));
|
||||
// Un créneau hors des sept jours ne peut pas s'afficher : le taire vaut
|
||||
// mieux que de l'écraser sur lundi, où il fausserait le compteur.
|
||||
if (column === undefined) continue;
|
||||
|
||||
const minutes = shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes);
|
||||
const view: BoardShift = {
|
||||
id: shift.id,
|
||||
poste: shift.poste,
|
||||
time: `${zonedClock(shift.startAt, timeZone)}–${zonedClock(shift.endAt, timeZone)}`,
|
||||
minutes,
|
||||
breakMinutes: shift.breakMinutes,
|
||||
state: shiftState(shift.isValidated, isPublished, shift.membershipId),
|
||||
note: shift.note,
|
||||
};
|
||||
|
||||
if (!shift.membershipId) {
|
||||
unassignedDays[column]?.push(view);
|
||||
hasUnassigned = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let days = byMember.get(shift.membershipId);
|
||||
if (!days) {
|
||||
days = emptyDays();
|
||||
byMember.set(shift.membershipId, days);
|
||||
}
|
||||
days[column]?.push(view);
|
||||
minutesByMember.set(
|
||||
shift.membershipId,
|
||||
(minutesByMember.get(shift.membershipId) ?? 0) + minutes,
|
||||
);
|
||||
if (column === 6) {
|
||||
const sundays =
|
||||
sundaysByMember.get(shift.membershipId) ?? new Set<number>();
|
||||
sundays.add(column);
|
||||
sundaysByMember.set(shift.membershipId, sundays);
|
||||
}
|
||||
}
|
||||
|
||||
for (const days of byMember.values()) {
|
||||
for (const column of days) column.sort(byStartTime);
|
||||
}
|
||||
for (const column of unassignedDays) column.sort(byStartTime);
|
||||
|
||||
const rows = people.map((person) => {
|
||||
const days = byMember.get(person.membershipId) ?? emptyDays();
|
||||
const worked = days.filter((column) => column.length > 0).length;
|
||||
return {
|
||||
membershipId: person.membershipId,
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
job: person.job,
|
||||
forfaitJours: person.forfaitJours,
|
||||
unassigned: false,
|
||||
days,
|
||||
counters: {
|
||||
contractMinutes: person.forfaitJours ? 0 : person.contractMinutes,
|
||||
plannedMinutes: minutesByMember.get(person.membershipId) ?? 0,
|
||||
// Les absences arrivent au lot suivant ; le compteur existe déjà pour
|
||||
// que la formule d'écart ne change pas quand elles se brancheront.
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
|
||||
restDays: 7 - worked,
|
||||
},
|
||||
} satisfies BoardRow;
|
||||
});
|
||||
|
||||
const unassignedRow: BoardRow | null = hasUnassigned
|
||||
? {
|
||||
membershipId: null,
|
||||
firstName: 'Non',
|
||||
lastName: 'assigné',
|
||||
job: 'Besoins à couvrir',
|
||||
forfaitJours: false,
|
||||
unassigned: true,
|
||||
days: unassignedDays,
|
||||
counters: {
|
||||
contractMinutes: 0,
|
||||
plannedMinutes: unassignedDays
|
||||
.flat()
|
||||
.reduce((total, shift) => total + shift.minutes, 0),
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 0,
|
||||
restDays: 0,
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
||||
return { rows, unassignedRow };
|
||||
}
|
||||
|
||||
function byStartTime(a: BoardShift, b: BoardShift): number {
|
||||
return a.time.localeCompare(b.time);
|
||||
}
|
||||
|
||||
/**
|
||||
* État visuel d'un créneau.
|
||||
*
|
||||
* L'ordre des tests compte : un besoin non couvert reste affiché en fantôme
|
||||
* même sur une semaine publiée, sinon il se confondrait avec un créneau réel.
|
||||
*/
|
||||
export function shiftState(
|
||||
isValidated: boolean,
|
||||
isPublished: boolean,
|
||||
membershipId: string | null,
|
||||
): ShiftState {
|
||||
if (!membershipId) return 'unassigned';
|
||||
if (isValidated) return 'validated';
|
||||
return isPublished ? 'published' : 'draft';
|
||||
}
|
||||
|
||||
export function initialsOf(row: BoardRow): string {
|
||||
return `${row.firstName.charAt(0)}${row.lastName.charAt(0)}`.toUpperCase();
|
||||
}
|
||||
|
||||
export function displayName(row: BoardRow): string {
|
||||
return `${row.firstName} ${row.lastName}`;
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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' }],
|
||||
],
|
||||
};
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError, can, type Actor } from '@/domain/access/authorize';
|
||||
import { shiftMinutes } from '@/domain/counters/week';
|
||||
import {
|
||||
parseWeekParam,
|
||||
weekBounds,
|
||||
zonedInstant,
|
||||
} from '@/domain/planning/week';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
import type { ScopedClient } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Écritures du planning.
|
||||
*
|
||||
* Deux règles gouvernent ce fichier :
|
||||
*
|
||||
* 1. **Les instants font foi.** Le formulaire envoie une date civile et deux
|
||||
* heures locales ; ils sont convertis en instants dans le fuseau de
|
||||
* l'établissement avant toute écriture. `localDate` n'est qu'un cache de
|
||||
* regroupement, jamais une source de vérité.
|
||||
* 2. **Publier est une décision, la modifier ensuite en est une autre.**
|
||||
* Toucher à une semaine publiée exige une capacité distincte : un salarié a
|
||||
* organisé sa semaine sur ce qu'il a lu.
|
||||
*/
|
||||
|
||||
export interface PlanningActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
class ValidationError extends Error {}
|
||||
|
||||
const HOUR = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
|
||||
const shiftInput = z.object({
|
||||
teamId: z.string().min(1),
|
||||
week: z.string().min(1),
|
||||
membershipId: z.string().optional(),
|
||||
localDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date invalide'),
|
||||
start: z.string().regex(HOUR, 'Heure de début invalide'),
|
||||
end: z.string().regex(HOUR, 'Heure de fin invalide'),
|
||||
breakMinutes: z.coerce.number().int().min(0).max(600).default(0),
|
||||
labelId: z.string().optional(),
|
||||
note: z.string().trim().max(500).optional(),
|
||||
});
|
||||
|
||||
async function loadTeamContext(db: ScopedClient, teamId: string) {
|
||||
const team = await db.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { id: true, name: true, locationId: true },
|
||||
});
|
||||
if (!team) throw new AuthorizationError('planning.create');
|
||||
|
||||
const location = await db.location.findUnique({
|
||||
where: { id: team.locationId },
|
||||
select: { id: true, timezone: true },
|
||||
});
|
||||
if (!location) throw new AuthorizationError('planning.create');
|
||||
|
||||
return { team, location };
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée ou remplace la semaine de l'équipe.
|
||||
*
|
||||
* `upsert` plutôt que `create` : la semaine naît au premier créneau posé, et
|
||||
* deux managers qui commencent la même semaine en même temps ne doivent pas
|
||||
* produire deux enregistrements — la contrainte d'unicité s'en charge.
|
||||
*/
|
||||
async function ensureSchedule(
|
||||
db: ScopedClient,
|
||||
teamId: string,
|
||||
locationId: string,
|
||||
isoYear: number,
|
||||
isoWeek: number,
|
||||
) {
|
||||
return db.weeklySchedule.upsert({
|
||||
where: { teamId_isoYear_isoWeek: { teamId, isoYear, isoWeek } },
|
||||
update: {},
|
||||
create: { teamId, locationId, isoYear, isoWeek } as never,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createShiftAction(
|
||||
_previous: PlanningActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlanningActionState> {
|
||||
const parsed = shiftInput.safeParse({
|
||||
teamId: formData.get('teamId'),
|
||||
week: formData.get('week'),
|
||||
membershipId: formData.get('membershipId') || undefined,
|
||||
localDate: formData.get('localDate'),
|
||||
start: formData.get('start'),
|
||||
end: formData.get('end'),
|
||||
breakMinutes: formData.get('breakMinutes') || 0,
|
||||
labelId: formData.get('labelId') || undefined,
|
||||
note: formData.get('note') || undefined,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const week = parseWeekParam(parsed.data.week);
|
||||
if (!week) return { error: 'Semaine invalide.' };
|
||||
|
||||
try {
|
||||
await mutate('planning.create', async (db, actor) => {
|
||||
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
|
||||
|
||||
const startAt = zonedInstant(
|
||||
parsed.data.localDate,
|
||||
parsed.data.start,
|
||||
location.timezone,
|
||||
);
|
||||
let endAt = zonedInstant(
|
||||
parsed.data.localDate,
|
||||
parsed.data.end,
|
||||
location.timezone,
|
||||
);
|
||||
// Fin avant début = créneau de nuit : il finit le lendemain. Le refuser
|
||||
// interdirait de planifier un inventaire 22 h–02 h.
|
||||
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
|
||||
|
||||
const worked = shiftMinutes(startAt, endAt, parsed.data.breakMinutes);
|
||||
if (worked <= 0) {
|
||||
throw new ValidationError(
|
||||
'La pause dépasse la durée du créneau — rien ne serait travaillé.',
|
||||
);
|
||||
}
|
||||
|
||||
const { from, to } = weekBounds(week, location.timezone);
|
||||
if (startAt < from || startAt >= to) {
|
||||
throw new ValidationError(
|
||||
"Ce créneau ne commence pas dans la semaine affichée.",
|
||||
);
|
||||
}
|
||||
|
||||
const schedule = await ensureSchedule(
|
||||
db,
|
||||
team.id,
|
||||
location.id,
|
||||
week.isoYear,
|
||||
week.isoWeek,
|
||||
);
|
||||
|
||||
if (schedule.status === 'PUBLISHED') {
|
||||
assertMayEditPublished(actor);
|
||||
}
|
||||
|
||||
if (parsed.data.membershipId) {
|
||||
await assertNoOverlap(db, parsed.data.membershipId, startAt, endAt, null);
|
||||
}
|
||||
|
||||
const created = await db.shift.create({
|
||||
data: {
|
||||
weeklyScheduleId: schedule.id,
|
||||
membershipId: parsed.data.membershipId ?? null,
|
||||
localDate: new Date(`${parsed.data.localDate}T00:00:00Z`),
|
||||
startAt,
|
||||
endAt,
|
||||
breakMinutes: parsed.data.breakMinutes,
|
||||
labelId: parsed.data.labelId ?? null,
|
||||
note: parsed.data.note ?? null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'planning.shift.create',
|
||||
entityType: 'Shift',
|
||||
entityId: created.id,
|
||||
after: {
|
||||
teamId: team.id,
|
||||
membershipId: created.membershipId,
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
minutes: worked,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, 'Vous ne pouvez pas créer de créneau ici.');
|
||||
}
|
||||
|
||||
revalidatePath('/planning/semaine');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const moveInput = z.object({
|
||||
shiftId: z.string().min(1),
|
||||
membershipId: z.string().optional(),
|
||||
localDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date invalide'),
|
||||
start: z.string().regex(HOUR, 'Heure de début invalide'),
|
||||
end: z.string().regex(HOUR, 'Heure de fin invalide'),
|
||||
breakMinutes: z.coerce.number().int().min(0).max(600).default(0),
|
||||
});
|
||||
|
||||
export async function updateShiftAction(
|
||||
_previous: PlanningActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlanningActionState> {
|
||||
const parsed = moveInput.safeParse({
|
||||
shiftId: formData.get('shiftId'),
|
||||
membershipId: formData.get('membershipId') || undefined,
|
||||
localDate: formData.get('localDate'),
|
||||
start: formData.get('start'),
|
||||
end: formData.get('end'),
|
||||
breakMinutes: formData.get('breakMinutes') || 0,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('planning.edit', async (db, actor) => {
|
||||
const shift = await db.shift.findUnique({
|
||||
where: { id: parsed.data.shiftId },
|
||||
include: { schedule: true },
|
||||
});
|
||||
if (!shift) throw new AuthorizationError('planning.edit');
|
||||
|
||||
const location = await db.location.findUnique({
|
||||
where: { id: shift.schedule.locationId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
if (!location) throw new AuthorizationError('planning.edit');
|
||||
|
||||
if (shift.schedule.status === 'PUBLISHED') {
|
||||
assertMayEditPublished(actor);
|
||||
}
|
||||
|
||||
const startAt = zonedInstant(
|
||||
parsed.data.localDate,
|
||||
parsed.data.start,
|
||||
location.timezone,
|
||||
);
|
||||
let endAt = zonedInstant(
|
||||
parsed.data.localDate,
|
||||
parsed.data.end,
|
||||
location.timezone,
|
||||
);
|
||||
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
|
||||
|
||||
if (shiftMinutes(startAt, endAt, parsed.data.breakMinutes) <= 0) {
|
||||
throw new ValidationError(
|
||||
'La pause dépasse la durée du créneau — rien ne serait travaillé.',
|
||||
);
|
||||
}
|
||||
|
||||
const membershipId = parsed.data.membershipId ?? null;
|
||||
if (membershipId) {
|
||||
await assertNoOverlap(db, membershipId, startAt, endAt, shift.id);
|
||||
}
|
||||
|
||||
await db.shift.update({
|
||||
where: { id: shift.id },
|
||||
data: {
|
||||
membershipId,
|
||||
localDate: new Date(`${parsed.data.localDate}T00:00:00Z`),
|
||||
startAt,
|
||||
endAt,
|
||||
breakMinutes: parsed.data.breakMinutes,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'planning.shift.update',
|
||||
entityType: 'Shift',
|
||||
entityId: shift.id,
|
||||
before: {
|
||||
membershipId: shift.membershipId,
|
||||
startAt: shift.startAt.toISOString(),
|
||||
endAt: shift.endAt.toISOString(),
|
||||
},
|
||||
after: {
|
||||
membershipId,
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, 'Vous ne pouvez pas modifier ce créneau.');
|
||||
}
|
||||
|
||||
revalidatePath('/planning/semaine');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteShiftAction(
|
||||
_previous: PlanningActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlanningActionState> {
|
||||
const shiftId = String(formData.get('shiftId') ?? '');
|
||||
if (!shiftId) return { error: 'Créneau introuvable.' };
|
||||
|
||||
try {
|
||||
await mutate('planning.delete', async (db, actor) => {
|
||||
const shift = await db.shift.findUnique({
|
||||
where: { id: shiftId },
|
||||
include: { schedule: true },
|
||||
});
|
||||
if (!shift) throw new AuthorizationError('planning.delete');
|
||||
|
||||
if (shift.schedule.status === 'PUBLISHED') {
|
||||
assertMayEditPublished(actor);
|
||||
}
|
||||
|
||||
// La trace est écrite **avant** la suppression : après, l'identifiant ne
|
||||
// désigne plus rien, et l'état supprimé serait perdu.
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'planning.shift.delete',
|
||||
entityType: 'Shift',
|
||||
entityId: shift.id,
|
||||
before: {
|
||||
membershipId: shift.membershipId,
|
||||
startAt: shift.startAt.toISOString(),
|
||||
endAt: shift.endAt.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
await db.shift.delete({ where: { id: shift.id } });
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(error, 'Vous ne pouvez pas supprimer ce créneau.');
|
||||
}
|
||||
|
||||
revalidatePath('/planning/semaine');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const publishInput = z.object({
|
||||
teamId: z.string().min(1),
|
||||
week: z.string().min(1),
|
||||
expectedVersion: z.coerce.number().int().min(0),
|
||||
});
|
||||
|
||||
export async function publishWeekAction(
|
||||
_previous: PlanningActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlanningActionState> {
|
||||
return setPublication(formData, true);
|
||||
}
|
||||
|
||||
export async function unpublishWeekAction(
|
||||
_previous: PlanningActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlanningActionState> {
|
||||
return setPublication(formData, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publie ou dépublie la semaine d'une **équipe**.
|
||||
*
|
||||
* Le verrou optimiste porte sur `version` : deux managers sur la même grille
|
||||
* est le cas normal, pas l'exception. Sans lui, le second écraserait la
|
||||
* décision du premier sans que personne ne le voie (PLAN.md §3.4).
|
||||
*/
|
||||
async function setPublication(
|
||||
formData: FormData,
|
||||
publish: boolean,
|
||||
): Promise<PlanningActionState> {
|
||||
const parsed = publishInput.safeParse({
|
||||
teamId: formData.get('teamId'),
|
||||
week: formData.get('week'),
|
||||
expectedVersion: formData.get('expectedVersion') ?? 0,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const week = parseWeekParam(parsed.data.week);
|
||||
if (!week) return { error: 'Semaine invalide.' };
|
||||
|
||||
try {
|
||||
await mutate(publish ? 'planning.publish' : 'planning.unpublish', async (db, actor) => {
|
||||
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
|
||||
const schedule = await ensureSchedule(
|
||||
db,
|
||||
team.id,
|
||||
location.id,
|
||||
week.isoYear,
|
||||
week.isoWeek,
|
||||
);
|
||||
|
||||
if (publish) {
|
||||
const count = await db.shift.count({
|
||||
where: { weeklyScheduleId: schedule.id },
|
||||
});
|
||||
if (count === 0) {
|
||||
throw new ValidationError(
|
||||
'Cette semaine ne contient aucun créneau : rien à publier.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db.weeklySchedule.updateMany({
|
||||
where: { id: schedule.id, version: parsed.data.expectedVersion },
|
||||
data: {
|
||||
status: publish ? 'PUBLISHED' : 'DRAFT',
|
||||
publishedAt: publish ? new Date() : null,
|
||||
publishedBy: publish ? actor.membershipId : null,
|
||||
version: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
throw new ValidationError(
|
||||
'Cette semaine a été modifiée entre-temps. Rechargez la page avant de recommencer.',
|
||||
);
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: publish ? 'planning.publish' : 'planning.unpublish',
|
||||
entityType: 'WeeklySchedule',
|
||||
entityId: schedule.id,
|
||||
before: { status: schedule.status, version: schedule.version },
|
||||
after: {
|
||||
status: publish ? 'PUBLISHED' : 'DRAFT',
|
||||
teamId: team.id,
|
||||
isoYear: week.isoYear,
|
||||
isoWeek: week.isoWeek,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
return toState(
|
||||
error,
|
||||
publish
|
||||
? 'Vous ne pouvez pas publier cette semaine.'
|
||||
: 'Vous ne pouvez pas dépublier cette semaine.',
|
||||
);
|
||||
}
|
||||
|
||||
revalidatePath('/planning/semaine');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deux créneaux qui se chevauchent pour le même salarié sont impossibles dans
|
||||
* la réalité, et compteraient deux fois en heures. Le contrôle est en base et
|
||||
* en transaction — un contrôle côté formulaire ne survit pas à deux managers
|
||||
* qui planifient en même temps.
|
||||
*/
|
||||
async function assertNoOverlap(
|
||||
db: ScopedClient,
|
||||
membershipId: string,
|
||||
startAt: Date,
|
||||
endAt: Date,
|
||||
ignoreShiftId: string | null,
|
||||
): Promise<void> {
|
||||
const clash = await db.shift.findFirst({
|
||||
where: {
|
||||
membershipId,
|
||||
startAt: { lt: endAt },
|
||||
endAt: { gt: startAt },
|
||||
...(ignoreShiftId ? { id: { not: ignoreShiftId } } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (clash) {
|
||||
throw new ValidationError(
|
||||
'Ce salarié a déjà un créneau qui recouvre cette plage.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Une semaine publiée n'est pas gelée, mais la toucher est une seconde
|
||||
* décision : le salarié a organisé sa semaine sur ce qu'il a lu. D'où une
|
||||
* capacité distincte de `planning.edit`, que le rôle manager n'a pas par
|
||||
* défaut.
|
||||
*/
|
||||
function assertMayEditPublished(actor: Actor): void {
|
||||
if (!can(actor, 'planning.edit_published')) {
|
||||
throw new ValidationError(
|
||||
'Cette semaine est publiée. Dépubliez-la, ou demandez le droit de modifier une semaine publiée.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toState(error: unknown, denied: string): PlanningActionState {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) return { error: denied };
|
||||
throw error;
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
import {
|
||||
buildRows,
|
||||
shiftState,
|
||||
type BoardRow,
|
||||
type BoardShiftInput,
|
||||
} from '@/domain/planning/board';
|
||||
import type { ShiftState } from '@/components/planning/ShiftChip';
|
||||
import {
|
||||
dayHeadings,
|
||||
formatWeekParam,
|
||||
nextIsoWeek,
|
||||
previousIsoWeek,
|
||||
weekBounds,
|
||||
weekDates,
|
||||
weekLabel,
|
||||
zonedClock,
|
||||
zonedMidnight,
|
||||
type IsoWeek,
|
||||
} from '@/domain/planning/week';
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import { query } from '@/server/context';
|
||||
import { POSTE_CODES, type PosteCode } from '@/lib/design/postes';
|
||||
import type { ScopedClient } from '@/server/tenant';
|
||||
|
||||
/**
|
||||
* Lecture de la grille hebdomadaire.
|
||||
*
|
||||
* Une section par équipe : c'est la maille de publication constatée dans
|
||||
* l'audit — chaque section porte son propre bouton « Dépublier », pas
|
||||
* l'établissement entier.
|
||||
*/
|
||||
|
||||
export interface BoardSection {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
/** Vrai quand la semaine existe mais reste masquée faute de capacité. */
|
||||
hidden: boolean;
|
||||
scheduleId: string | null;
|
||||
status: 'DRAFT' | 'VALIDATED' | 'PUBLISHED';
|
||||
version: number;
|
||||
publishedAt: Date | null;
|
||||
rows: BoardRow[];
|
||||
unassignedRow: BoardRow | null;
|
||||
}
|
||||
|
||||
export interface BoardLabel {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
poste: PosteCode;
|
||||
}
|
||||
|
||||
export interface WeekBoard {
|
||||
location: { id: string; name: string; timezone: string };
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
week: IsoWeek;
|
||||
label: string;
|
||||
headings: string[];
|
||||
dates: string[];
|
||||
previousParam: string;
|
||||
nextParam: string;
|
||||
weekParam: string;
|
||||
sections: BoardSection[];
|
||||
labels: BoardLabel[];
|
||||
totals: { plannedMinutes: number; shifts: number; unassigned: number };
|
||||
}
|
||||
|
||||
/** Poste par défaut quand le créneau ne porte pas d'étiquette. */
|
||||
const FALLBACK_POSTE: PosteCode = 'vte';
|
||||
|
||||
function posteOf(paletteKey: string | null | undefined): PosteCode {
|
||||
if (!paletteKey) return FALLBACK_POSTE;
|
||||
return (POSTE_CODES as readonly string[]).includes(paletteKey)
|
||||
? (paletteKey as PosteCode)
|
||||
: FALLBACK_POSTE;
|
||||
}
|
||||
|
||||
export async function listLocations(): Promise<
|
||||
Array<{ id: string; name: string; timezone: string }>
|
||||
> {
|
||||
return query('planning.view', async (db) => {
|
||||
return db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true, timezone: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function getWeekBoard(
|
||||
week: IsoWeek,
|
||||
locationId?: string,
|
||||
): Promise<WeekBoard | null> {
|
||||
return query(
|
||||
'planning.view',
|
||||
async (db, actor) => {
|
||||
// Un salarié ne voit que ce qui est publié : un brouillon est une
|
||||
// intention de manager, pas une information sur laquelle organiser sa
|
||||
// semaine. La restriction est **serveur** — masquer côté écran laisserait
|
||||
// les créneaux dans la charge utile.
|
||||
const seesDrafts = can(actor, 'planning.view_unpublished');
|
||||
|
||||
const locations = await db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true, timezone: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
const location =
|
||||
locations.find((candidate) => candidate.id === locationId) ??
|
||||
locations[0];
|
||||
if (!location) return null;
|
||||
|
||||
const dates = weekDates(week);
|
||||
const { from, to } = weekBounds(week, location.timezone);
|
||||
|
||||
const teams = await db.team.findMany({
|
||||
where: { locationId: location.id, archivedAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
const schedules = await db.weeklySchedule.findMany({
|
||||
where: {
|
||||
teamId: { in: teams.map((team) => team.id) },
|
||||
isoYear: week.isoYear,
|
||||
isoWeek: week.isoWeek,
|
||||
...(seesDrafts ? {} : { status: 'PUBLISHED' }),
|
||||
},
|
||||
});
|
||||
const scheduleByTeam = new Map(
|
||||
schedules.map((schedule) => [schedule.teamId, schedule]),
|
||||
);
|
||||
|
||||
const shifts = await db.shift.findMany({
|
||||
where: {
|
||||
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
|
||||
// Bornes en instants : un créneau de nuit du dimanche appartient à
|
||||
// cette semaine par son début, quel que soit son jour de fin.
|
||||
startAt: { gte: from, lt: to },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
weeklyScheduleId: true,
|
||||
membershipId: true,
|
||||
startAt: true,
|
||||
endAt: true,
|
||||
breakMinutes: true,
|
||||
isValidated: true,
|
||||
note: true,
|
||||
labelId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const labels = await db.label.findMany({
|
||||
where: { archivedAt: null },
|
||||
orderBy: { position: 'asc' },
|
||||
select: { id: true, code: true, name: true, paletteKey: true },
|
||||
});
|
||||
const paletteById = new Map(
|
||||
labels.map((label) => [label.id, label.paletteKey]),
|
||||
);
|
||||
|
||||
const people = await loadPeople(db, teams.map((team) => team.id));
|
||||
|
||||
const sections = teams.map((team) => {
|
||||
const schedule = scheduleByTeam.get(team.id) ?? null;
|
||||
const isPublished = schedule?.status === 'PUBLISHED';
|
||||
const hidden = !seesDrafts && !schedule;
|
||||
const teamShifts: BoardShiftInput[] = shifts
|
||||
.filter((shift) => shift.weeklyScheduleId === schedule?.id)
|
||||
.map((shift) => ({
|
||||
id: shift.id,
|
||||
membershipId: shift.membershipId,
|
||||
startAt: shift.startAt,
|
||||
endAt: shift.endAt,
|
||||
breakMinutes: shift.breakMinutes,
|
||||
poste: posteOf(paletteById.get(shift.labelId ?? '')),
|
||||
isValidated: shift.isValidated,
|
||||
note: shift.note,
|
||||
}));
|
||||
|
||||
const { rows, unassignedRow } = buildRows(
|
||||
people.get(team.id) ?? [],
|
||||
teamShifts,
|
||||
dates,
|
||||
location.timezone,
|
||||
isPublished,
|
||||
);
|
||||
|
||||
return {
|
||||
teamId: team.id,
|
||||
teamName: team.name,
|
||||
hidden,
|
||||
scheduleId: schedule?.id ?? null,
|
||||
status: schedule?.status ?? 'DRAFT',
|
||||
version: schedule?.version ?? 0,
|
||||
publishedAt: schedule?.publishedAt ?? null,
|
||||
rows,
|
||||
unassignedRow,
|
||||
} satisfies BoardSection;
|
||||
});
|
||||
|
||||
const plannedMinutes = sections
|
||||
.flatMap((section) => section.rows)
|
||||
.reduce((total, row) => total + row.counters.plannedMinutes, 0);
|
||||
|
||||
return {
|
||||
location,
|
||||
locations: locations.map(({ id, name }) => ({ id, name })),
|
||||
week,
|
||||
label: weekLabel(week),
|
||||
headings: dayHeadings(week),
|
||||
dates,
|
||||
weekParam: formatWeekParam(week),
|
||||
previousParam: formatWeekParam(previousIsoWeek(week)),
|
||||
nextParam: formatWeekParam(nextIsoWeek(week)),
|
||||
sections,
|
||||
labels: labels.map((label) => ({
|
||||
id: label.id,
|
||||
code: label.code,
|
||||
name: label.name,
|
||||
poste: posteOf(label.paletteKey),
|
||||
})),
|
||||
totals: {
|
||||
plannedMinutes,
|
||||
shifts: shifts.filter((shift) => shift.membershipId).length,
|
||||
unassigned: shifts.filter((shift) => !shift.membershipId).length,
|
||||
},
|
||||
} satisfies WeekBoard;
|
||||
},
|
||||
locationId ? { locationId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Salariés rattachés à chaque équipe, avec leur durée contractuelle. */
|
||||
async function loadPeople(
|
||||
db: ScopedClient,
|
||||
teamIds: string[],
|
||||
): Promise<Map<string, Awaited<ReturnType<typeof toPerson>>[]>> {
|
||||
const assignments = await db.teamMember.findMany({
|
||||
where: { teamId: { in: teamIds } },
|
||||
orderBy: [{ position: 'asc' }],
|
||||
include: {
|
||||
membership: {
|
||||
include: {
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
contracts: {
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { startDate: 'desc' },
|
||||
take: 1,
|
||||
select: {
|
||||
weeklyHours: true,
|
||||
workTimeArrangement: true,
|
||||
jobTitleId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const jobTitleIds = [
|
||||
...new Set(
|
||||
assignments
|
||||
.flatMap((assignment) => assignment.membership.contracts)
|
||||
.map((contract) => contract.jobTitleId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
const jobTitles = await db.jobTitle.findMany({
|
||||
where: { id: { in: jobTitleIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const jobTitleById = new Map(jobTitles.map((job) => [job.id, job.name]));
|
||||
|
||||
const byTeam = new Map<string, Awaited<ReturnType<typeof toPerson>>[]>();
|
||||
for (const assignment of assignments) {
|
||||
const list = byTeam.get(assignment.teamId) ?? [];
|
||||
list.push(toPerson(assignment, jobTitleById));
|
||||
byTeam.set(assignment.teamId, list);
|
||||
}
|
||||
return byTeam;
|
||||
}
|
||||
|
||||
function toPerson(
|
||||
assignment: {
|
||||
membershipId: string;
|
||||
membership: {
|
||||
employeeNumber: string;
|
||||
profile: { firstName: string; lastName: string } | null;
|
||||
contracts: Array<{
|
||||
weeklyHours: { toString(): string };
|
||||
workTimeArrangement: string;
|
||||
jobTitleId: string | null;
|
||||
}>;
|
||||
};
|
||||
},
|
||||
jobTitleById: Map<string, string>,
|
||||
) {
|
||||
const contract = assignment.membership.contracts[0];
|
||||
const forfaitJours = contract?.workTimeArrangement === 'FORFAIT_JOURS';
|
||||
return {
|
||||
membershipId: assignment.membershipId,
|
||||
firstName: assignment.membership.profile?.firstName ?? '',
|
||||
lastName:
|
||||
assignment.membership.profile?.lastName ??
|
||||
assignment.membership.employeeNumber,
|
||||
job: contract?.jobTitleId
|
||||
? (jobTitleById.get(contract.jobTitleId) ?? 'Poste à préciser')
|
||||
: 'Poste à préciser',
|
||||
forfaitJours,
|
||||
contractMinutes: contract
|
||||
? Math.round(Number(contract.weeklyHours.toString()) * 60)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue jour : qui est présent, et quand.
|
||||
*
|
||||
* Elle répond à une question différente de la vue semaine — « qui tient la
|
||||
* caisse à 14 h » plutôt que « qui fait combien d'heures » — d'où une requête
|
||||
* distincte plutôt qu'un filtrage de la grille hebdomadaire.
|
||||
*/
|
||||
|
||||
export interface DayLaneShift {
|
||||
id: string;
|
||||
poste: PosteCode;
|
||||
/** Minutes depuis minuit local ; peut dépasser 1440 sur un créneau de nuit. */
|
||||
startMinutes: number;
|
||||
endMinutes: number;
|
||||
state: ShiftState;
|
||||
}
|
||||
|
||||
export interface DayLane {
|
||||
id: string;
|
||||
name: string;
|
||||
job: string;
|
||||
initials: string;
|
||||
unassigned: boolean;
|
||||
shifts: DayLaneShift[];
|
||||
}
|
||||
|
||||
export interface DayBoard {
|
||||
location: { id: string; name: string; timezone: string };
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
isoDate: string;
|
||||
label: string;
|
||||
previousDate: string;
|
||||
nextDate: string;
|
||||
lanes: DayLane[];
|
||||
fromHour: number;
|
||||
toHour: number;
|
||||
}
|
||||
|
||||
const DAY_FORMAT = new Intl.DateTimeFormat('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
function shiftDate(isoDate: string, days: number): string {
|
||||
const base = new Date(`${isoDate}T00:00:00Z`);
|
||||
return new Date(base.getTime() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export async function getDayBoard(
|
||||
isoDate: string,
|
||||
locationId?: string,
|
||||
): Promise<DayBoard | null> {
|
||||
return query(
|
||||
'planning.view',
|
||||
async (db, actor) => {
|
||||
const seesDrafts = can(actor, 'planning.view_unpublished');
|
||||
|
||||
const locations = await db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true, timezone: true },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const location =
|
||||
locations.find((candidate) => candidate.id === locationId) ??
|
||||
locations[0];
|
||||
if (!location) return null;
|
||||
|
||||
const from = zonedMidnight(isoDate, location.timezone);
|
||||
const to = zonedMidnight(shiftDate(isoDate, 1), location.timezone);
|
||||
|
||||
const teams = await db.team.findMany({
|
||||
where: { locationId: location.id, archivedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
const schedules = await db.weeklySchedule.findMany({
|
||||
where: {
|
||||
teamId: { in: teams.map((team) => team.id) },
|
||||
...(seesDrafts ? {} : { status: 'PUBLISHED' }),
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
const statusById = new Map(
|
||||
schedules.map((schedule) => [schedule.id, schedule.status]),
|
||||
);
|
||||
|
||||
const shifts = await db.shift.findMany({
|
||||
where: {
|
||||
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
|
||||
startAt: { gte: from, lt: to },
|
||||
},
|
||||
orderBy: { startAt: 'asc' },
|
||||
});
|
||||
|
||||
const labels = await db.label.findMany({
|
||||
select: { id: true, paletteKey: true },
|
||||
});
|
||||
const paletteById = new Map(
|
||||
labels.map((label) => [label.id, label.paletteKey]),
|
||||
);
|
||||
|
||||
const memberIds = [
|
||||
...new Set(
|
||||
shifts
|
||||
.map((shift) => shift.membershipId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
];
|
||||
const members = await db.membership.findMany({
|
||||
where: { id: { in: memberIds } },
|
||||
include: {
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
contracts: {
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: { startDate: 'desc' },
|
||||
take: 1,
|
||||
select: { jobTitleId: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
const jobTitles = await db.jobTitle.findMany({
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const jobTitleById = new Map(jobTitles.map((job) => [job.id, job.name]));
|
||||
|
||||
const laneById = new Map<string, DayLane>();
|
||||
for (const member of members) {
|
||||
const jobTitleId = member.contracts[0]?.jobTitleId;
|
||||
laneById.set(member.id, {
|
||||
id: member.id,
|
||||
name: `${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
|
||||
job: jobTitleId
|
||||
? (jobTitleById.get(jobTitleId) ?? 'Poste à préciser')
|
||||
: 'Poste à préciser',
|
||||
initials:
|
||||
`${member.profile?.firstName?.charAt(0) ?? ''}${member.profile?.lastName?.charAt(0) ?? ''}`.toUpperCase(),
|
||||
unassigned: false,
|
||||
shifts: [],
|
||||
});
|
||||
}
|
||||
|
||||
const unassignedLane: DayLane = {
|
||||
id: 'unassigned',
|
||||
name: 'Non assigné',
|
||||
job: 'Besoins à couvrir',
|
||||
initials: '?',
|
||||
unassigned: true,
|
||||
shifts: [],
|
||||
};
|
||||
|
||||
for (const shift of shifts) {
|
||||
const startMinutes = localMinutes(shift.startAt, location.timezone);
|
||||
let endMinutes = localMinutes(shift.endAt, location.timezone);
|
||||
// Un créneau de nuit finit « avant » son début en heure murale : le
|
||||
// reporter au lendemain garde la barre continue au lieu de l'inverser.
|
||||
if (endMinutes <= startMinutes) endMinutes += 24 * 60;
|
||||
|
||||
const isPublished =
|
||||
statusById.get(shift.weeklyScheduleId) === 'PUBLISHED';
|
||||
const lane = shift.membershipId
|
||||
? laneById.get(shift.membershipId)
|
||||
: unassignedLane;
|
||||
lane?.shifts.push({
|
||||
id: shift.id,
|
||||
poste: posteOf(paletteById.get(shift.labelId ?? '')),
|
||||
startMinutes,
|
||||
endMinutes,
|
||||
state: shiftState(shift.isValidated, isPublished, shift.membershipId),
|
||||
});
|
||||
}
|
||||
|
||||
const lanes = [...laneById.values()]
|
||||
.filter((lane) => lane.shifts.length > 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'fr'));
|
||||
if (unassignedLane.shifts.length > 0) lanes.push(unassignedLane);
|
||||
|
||||
// L'amplitude s'ajuste à la journée réelle : figer 06 h–21 h couperait
|
||||
// un inventaire de nuit, et étirerait une journée creuse.
|
||||
const all = lanes.flatMap((lane) => lane.shifts);
|
||||
const fromHour = all.length
|
||||
? Math.max(0, Math.floor(Math.min(...all.map((s) => s.startMinutes)) / 60) - 1)
|
||||
: 7;
|
||||
const toHour = all.length
|
||||
? Math.min(30, Math.ceil(Math.max(...all.map((s) => s.endMinutes)) / 60) + 1)
|
||||
: 21;
|
||||
|
||||
return {
|
||||
location,
|
||||
locations: locations.map(({ id, name }) => ({ id, name })),
|
||||
isoDate,
|
||||
label: DAY_FORMAT.format(new Date(`${isoDate}T00:00:00Z`)),
|
||||
previousDate: shiftDate(isoDate, -1),
|
||||
nextDate: shiftDate(isoDate, 1),
|
||||
lanes,
|
||||
fromHour,
|
||||
toHour: Math.max(toHour, fromHour + 4),
|
||||
} satisfies DayBoard;
|
||||
},
|
||||
locationId ? { locationId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
/** Minutes depuis minuit, en heure locale de l'établissement. */
|
||||
function localMinutes(instant: Date, timeZone: string): number {
|
||||
const [hours, minutes] = zonedClock(instant, timeZone).split(':').map(Number);
|
||||
return (hours ?? 0) * 60 + (minutes ?? 0);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -45,7 +45,7 @@ test('connexion, navigation, puis déconnexion', async ({ page }) => {
|
||||
|
||||
await page.getByRole('link', { name: 'Plannings' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /Planning · semaine 33/ }),
|
||||
page.getByRole('heading', { name: /Planning · semaine \d+/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Déconnexion' }).click();
|
||||
|
||||
@@ -6,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();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Parcours du planning : poser un créneau, publier, dépublier.
|
||||
*
|
||||
* Le test travaille sur une semaine **lointaine** plutôt que la semaine
|
||||
* courante : celle du seed change à chaque exécution, et un test qui dépend de
|
||||
* la date du jour finit par échouer un lundi de janvier sans que personne ne
|
||||
* sache pourquoi.
|
||||
*
|
||||
* La base de test n'est pas remise à zéro entre deux passages : la semaine est
|
||||
* donc vidée au début, sans quoi le second passage partirait d'un état publié
|
||||
* ou déjà rempli.
|
||||
*/
|
||||
|
||||
const WEEK = '2027-W20';
|
||||
|
||||
function venteSection(page: Page): Locator {
|
||||
return page
|
||||
.locator('section')
|
||||
.filter({ has: page.getByRole('heading', { name: 'Vente' }) });
|
||||
}
|
||||
|
||||
async function resetWeek(page: Page): Promise<void> {
|
||||
await page.goto(`/planning/semaine?semaine=${WEEK}`);
|
||||
const section = venteSection(page);
|
||||
await expect(section).toBeVisible();
|
||||
|
||||
const unpublish = section.getByRole('button', { name: 'Dépublier' });
|
||||
if (await unpublish.isVisible()) {
|
||||
await unpublish.click();
|
||||
await expect(section.getByText('Brouillon')).toBeVisible();
|
||||
}
|
||||
|
||||
const remove = section.getByRole('button', { name: 'Supprimer le créneau' });
|
||||
for (let guard = 0; guard < 30; guard += 1) {
|
||||
const before = await remove.count();
|
||||
if (before === 0) break;
|
||||
await remove.first().click();
|
||||
// Compter plutôt que regarder un libellé : c'est la disparition du
|
||||
// créneau qui est attendue, et elle est observable sans ambiguïté.
|
||||
await expect(remove).toHaveCount(before - 1);
|
||||
}
|
||||
await expect(remove).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function addShift(
|
||||
page: Page,
|
||||
section: Locator,
|
||||
times: { start: string; end: string; pause?: string },
|
||||
): Promise<Locator> {
|
||||
const row = section
|
||||
.locator('div')
|
||||
.filter({ hasText: /Camille Ferrand/ })
|
||||
.first();
|
||||
await row.getByRole('button', { name: /\+ Créneau/ }).first().click();
|
||||
|
||||
const composer = page.locator('form').filter({ hasText: 'Poste' }).first();
|
||||
await composer.getByLabel('Début').fill(times.start);
|
||||
await composer.getByLabel('Fin').fill(times.end);
|
||||
await composer.getByLabel('Pause (min)').fill(times.pause ?? '0');
|
||||
await composer.getByRole('button', { name: 'Ajouter' }).click();
|
||||
return composer;
|
||||
}
|
||||
|
||||
test('un manager pose un créneau, publie, puis dépublie', async ({ page }) => {
|
||||
await resetWeek(page);
|
||||
const section = venteSection(page);
|
||||
|
||||
// Publier une semaine vide n'a pas de sens : rien à annoncer aux salariés.
|
||||
await section.getByRole('button', { name: 'Publier' }).click();
|
||||
await expect(section.getByText(/ne contient aucun créneau/)).toBeVisible();
|
||||
|
||||
await addShift(page, section, {
|
||||
start: '08:30',
|
||||
end: '16:30',
|
||||
pause: '45',
|
||||
});
|
||||
|
||||
await expect(section.getByText('08:30–16:30').first()).toBeVisible();
|
||||
// 8 h d'amplitude moins 45 min de pause : le compteur doit dire 7 h 15,
|
||||
// pas 8 h. C'est la déduction de la pause qui est vérifiée ici.
|
||||
await expect(section.getByText('7 h 15').first()).toBeVisible();
|
||||
|
||||
// Un créneau qui recouvre le premier compterait deux fois en heures. Le
|
||||
// refus vient du serveur, en transaction, pas du formulaire.
|
||||
const composer = await addShift(page, section, {
|
||||
start: '12:00',
|
||||
end: '20:00',
|
||||
});
|
||||
await expect(composer.getByText(/recouvre cette plage/)).toBeVisible();
|
||||
await composer.getByRole('button', { name: 'Annuler' }).click();
|
||||
|
||||
await section.getByRole('button', { name: 'Publier' }).click();
|
||||
await expect(section.getByText('Publiée')).toBeVisible();
|
||||
|
||||
await section.getByRole('button', { name: 'Dépublier' }).click();
|
||||
await expect(section.getByText('Brouillon')).toBeVisible();
|
||||
});
|
||||
|
||||
test('la navigation de semaine change la grille', async ({ page }) => {
|
||||
await page.goto(`/planning/semaine?semaine=${WEEK}`);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Planning · semaine 20' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Semaine suivante →' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Planning · semaine 21' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: '← Semaine précédente' }).click();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Planning · semaine 20' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildRows,
|
||||
shiftState,
|
||||
type BoardShiftInput,
|
||||
type RowPerson,
|
||||
} from '@/domain/planning/board';
|
||||
import { weekDates, zonedInstant } from '@/domain/planning/week';
|
||||
|
||||
const TZ = 'Europe/Paris';
|
||||
const WEEK = { isoYear: 2026, isoWeek: 33 };
|
||||
const DATES = weekDates(WEEK);
|
||||
|
||||
const person = (over: Partial<RowPerson> = {}): RowPerson => ({
|
||||
membershipId: 'm1',
|
||||
firstName: 'Sofia',
|
||||
lastName: 'Marchetti',
|
||||
job: 'Vendeuse conseil',
|
||||
forfaitJours: false,
|
||||
contractMinutes: 35 * 60,
|
||||
...over,
|
||||
});
|
||||
|
||||
const shift = (
|
||||
date: string,
|
||||
start: string,
|
||||
end: string,
|
||||
over: Partial<BoardShiftInput> = {},
|
||||
): BoardShiftInput => ({
|
||||
id: `${date}-${start}`,
|
||||
membershipId: 'm1',
|
||||
startAt: zonedInstant(date, start, TZ),
|
||||
endAt: zonedInstant(date, end, TZ),
|
||||
breakMinutes: 0,
|
||||
poste: 'vte',
|
||||
isValidated: false,
|
||||
note: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('buildRows', () => {
|
||||
it('range chaque créneau dans la colonne de son jour local', () => {
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[shift('2026-08-12', '09:00', '17:00')],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(rows[0]?.days[2]).toHaveLength(1);
|
||||
expect(rows[0]?.days[2]?.[0]?.time).toBe('09:00–17:00');
|
||||
expect(rows[0]?.days.flat()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('affiche un salarié sans créneau', () => {
|
||||
// Un planning se construit à partir d'une grille vide : masquer les
|
||||
// salariés non planifiés rendrait l'écran inutilisable le lundi matin.
|
||||
const { rows } = buildRows([person()], [], DATES, TZ, false);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.counters.plannedMinutes).toBe(0);
|
||||
expect(rows[0]?.counters.restDays).toBe(7);
|
||||
});
|
||||
|
||||
it('déduit la pause du temps planifié', () => {
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[shift('2026-08-10', '09:00', '17:00', { breakMinutes: 60 })],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
expect(rows[0]?.counters.plannedMinutes).toBe(7 * 60);
|
||||
});
|
||||
|
||||
it('compte la nuit du changement d’heure à sa vraie durée', () => {
|
||||
// 25 octobre 2026, retour à l'heure d'hiver : 22 h–06 h dure neuf heures.
|
||||
const dates = weekDates({ isoYear: 2026, isoWeek: 43 });
|
||||
const startAt = zonedInstant('2026-10-24', '22:00', TZ);
|
||||
const endAt = zonedInstant('2026-10-25', '06:00', TZ);
|
||||
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[
|
||||
{
|
||||
id: 's',
|
||||
membershipId: 'm1',
|
||||
startAt,
|
||||
endAt,
|
||||
breakMinutes: 0,
|
||||
poste: 'inv',
|
||||
isValidated: false,
|
||||
note: null,
|
||||
},
|
||||
],
|
||||
dates,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(rows[0]?.counters.plannedMinutes).toBe(9 * 60);
|
||||
// Le créneau se range au jour de son **début**, samedi.
|
||||
expect(rows[0]?.days[5]).toHaveLength(1);
|
||||
expect(rows[0]?.days[6]).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('sépare les besoins non couverts sur leur propre ligne', () => {
|
||||
const { rows, unassignedRow } = buildRows(
|
||||
[person()],
|
||||
[
|
||||
shift('2026-08-15', '10:00', '18:00', {
|
||||
id: 'open',
|
||||
membershipId: null,
|
||||
}),
|
||||
],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(rows[0]?.days.flat()).toHaveLength(0);
|
||||
expect(unassignedRow?.days[5]).toHaveLength(1);
|
||||
expect(unassignedRow?.days[5]?.[0]?.state).toBe('unassigned');
|
||||
// Pas de contrat sur cette ligne : la comparer à 35 h n'aurait aucun sens.
|
||||
expect(unassignedRow?.counters.contractMinutes).toBe(0);
|
||||
});
|
||||
|
||||
it('n’ouvre pas de ligne « non assigné » quand tout est couvert', () => {
|
||||
const { unassignedRow } = buildRows(
|
||||
[person()],
|
||||
[shift('2026-08-10', '09:00', '17:00')],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
expect(unassignedRow).toBeNull();
|
||||
});
|
||||
|
||||
it('compte les dimanches travaillés', () => {
|
||||
// Le dimanche du maire ouvre droit à majoration **et** repos compensateur :
|
||||
// le compteur doit être juste, pas approximatif.
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[
|
||||
shift('2026-08-16', '10:00', '18:00'),
|
||||
shift('2026-08-14', '10:00', '18:00'),
|
||||
],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
expect(rows[0]?.counters.sundaysWorked).toBe(1);
|
||||
expect(rows[0]?.counters.restDays).toBe(5);
|
||||
});
|
||||
|
||||
it('ignore un créneau hors de la semaine affichée', () => {
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[shift('2026-08-24', '09:00', '17:00')],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
// Le taire vaut mieux que de l'écraser sur lundi, où il fausserait le
|
||||
// compteur sans que rien ne le signale.
|
||||
expect(rows[0]?.counters.plannedMinutes).toBe(0);
|
||||
});
|
||||
|
||||
it('neutralise la durée contractuelle au forfait jours', () => {
|
||||
const { rows } = buildRows(
|
||||
[person({ forfaitJours: true, contractMinutes: 35 * 60 })],
|
||||
[],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
expect(rows[0]?.counters.contractMinutes).toBe(0);
|
||||
});
|
||||
|
||||
it('ordonne les créneaux d’une même journée', () => {
|
||||
const { rows } = buildRows(
|
||||
[person()],
|
||||
[
|
||||
shift('2026-08-10', '14:00', '19:00'),
|
||||
shift('2026-08-10', '07:00', '12:00'),
|
||||
],
|
||||
DATES,
|
||||
TZ,
|
||||
false,
|
||||
);
|
||||
expect(rows[0]?.days[0]?.map((entry) => entry.time)).toEqual([
|
||||
'07:00–12:00',
|
||||
'14:00–19:00',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shiftState', () => {
|
||||
it('distingue brouillon, publié et validé', () => {
|
||||
expect(shiftState(false, false, 'm1')).toBe('draft');
|
||||
expect(shiftState(false, true, 'm1')).toBe('published');
|
||||
expect(shiftState(true, true, 'm1')).toBe('validated');
|
||||
});
|
||||
|
||||
it('garde le besoin non couvert reconnaissable même publié', () => {
|
||||
expect(shiftState(false, true, null)).toBe('unassigned');
|
||||
});
|
||||
});
|
||||
|
||||
describe('zonedInstant', () => {
|
||||
it('vise la bonne heure locale été comme hiver', () => {
|
||||
expect(zonedInstant('2026-08-10', '09:00', TZ).toISOString()).toBe(
|
||||
'2026-08-10T07:00:00.000Z',
|
||||
);
|
||||
expect(zonedInstant('2026-01-05', '09:00', TZ).toISOString()).toBe(
|
||||
'2026-01-05T08:00:00.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('reste juste le jour du changement d’heure', () => {
|
||||
// 29 mars 2026 : minuit est en UTC+1, 09 h en UTC+2. Ajouter neuf heures à
|
||||
// minuit donnerait 10 h locales.
|
||||
expect(zonedInstant('2026-03-29', '09:00', TZ).toISOString()).toBe(
|
||||
'2026-03-29T07:00:00.000Z',
|
||||
);
|
||||
expect(zonedInstant('2026-10-25', '09:00', TZ).toISOString()).toBe(
|
||||
'2026-10-25T08:00:00.000Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user