Merge pull request #11 — WP-04 planning sur données réelles

WP-04 — Planning sur données réelles
This commit is contained in:
LogiFlow authored and GitHub committed 2026-08-08 09:21:18 +02:00
commit f8d84c25b9
36 files changed
+4700 -680

No files matched your search

+2 -2
View File
@@ -40,12 +40,12 @@ export default defineConfig({
// Parcours d'authentification : doit partir d'un navigateur vierge.
{
name: 'anonyme',
testMatch: /(auth|reglages)\.spec\.ts/,
testMatch: /(auth|reglages|acces)\.spec\.ts/,
use: { ...devices['Desktop Chrome'], ...chromiumOverride },
},
{
name: 'chromium',
testIgnore: /(auth\.setup|auth\.spec|reglages\.spec)\.ts/,
testIgnore: /(auth\.setup|auth\.spec|reglages\.spec|acces\.spec)\.ts/,
dependencies: ['setup'],
use: {
...devices['Desktop Chrome'],
@@ -0,0 +1,102 @@
-- CreateEnum
CREATE TYPE "ScheduleStatus" AS ENUM ('DRAFT', 'VALIDATED', 'PUBLISHED');
-- CreateEnum
CREATE TYPE "RestType" AS ENUM ('WEEKLY_REST', 'COMPENSATORY_REST');
-- CreateTable
CREATE TABLE "WeeklySchedule" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"isoYear" INTEGER NOT NULL,
"isoWeek" INTEGER NOT NULL,
"status" "ScheduleStatus" NOT NULL DEFAULT 'DRAFT',
"publishedAt" TIMESTAMP(3),
"publishedBy" TEXT,
"version" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "WeeklySchedule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Shift" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"weeklyScheduleId" TEXT NOT NULL,
"membershipId" TEXT,
"localDate" DATE NOT NULL,
"startAt" TIMESTAMPTZ NOT NULL,
"endAt" TIMESTAMPTZ NOT NULL,
"breakMinutes" INTEGER NOT NULL DEFAULT 0,
"actualStartAt" TIMESTAMPTZ,
"actualEndAt" TIMESTAMPTZ,
"actualBreakMinutes" INTEGER,
"labelId" TEXT,
"mealCount" INTEGER NOT NULL DEFAULT 0,
"isValidated" BOOLEAN NOT NULL DEFAULT false,
"validatedAt" TIMESTAMP(3),
"validatedBy" TEXT,
"note" TEXT,
"version" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "Shift_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rest" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"weeklyScheduleId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"localDate" DATE NOT NULL,
"restType" "RestType" NOT NULL,
"minutes" INTEGER,
CONSTRAINT "Rest_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DailyNote" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"weeklyScheduleId" TEXT NOT NULL,
"localDate" DATE NOT NULL,
"content" TEXT NOT NULL,
CONSTRAINT "DailyNote_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WeeklySchedule_accountId_idx" ON "WeeklySchedule"("accountId");
-- CreateIndex
CREATE INDEX "WeeklySchedule_locationId_isoYear_isoWeek_idx" ON "WeeklySchedule"("locationId", "isoYear", "isoWeek");
-- CreateIndex
CREATE UNIQUE INDEX "WeeklySchedule_teamId_isoYear_isoWeek_key" ON "WeeklySchedule"("teamId", "isoYear", "isoWeek");
-- CreateIndex
CREATE INDEX "Shift_accountId_idx" ON "Shift"("accountId");
-- CreateIndex
CREATE INDEX "Shift_weeklyScheduleId_localDate_idx" ON "Shift"("weeklyScheduleId", "localDate");
-- CreateIndex
CREATE INDEX "Shift_membershipId_localDate_idx" ON "Shift"("membershipId", "localDate");
-- CreateIndex
CREATE INDEX "Rest_accountId_idx" ON "Rest"("accountId");
-- CreateIndex
CREATE INDEX "DailyNote_accountId_idx" ON "DailyNote"("accountId");
-- AddForeignKey
ALTER TABLE "Shift" ADD CONSTRAINT "Shift_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rest" ADD CONSTRAINT "Rest_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "DailyNote" ADD CONSTRAINT "DailyNote_weeklyScheduleId_fkey" FOREIGN KEY ("weeklyScheduleId") REFERENCES "WeeklySchedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,25 @@
-- Étend l'isolation aux tables du planning.
--
-- La grille est la table la plus lue de l'application : c'est aussi celle où
-- une fuite inter-comptes serait la plus visible. Le test « toute table portant
-- accountId est protégée » échoue sans ceci.
DO $$
DECLARE
t text;
BEGIN
FOREACH t IN ARRAY ARRAY['WeeklySchedule', 'Shift', 'Rest', 'DailyNote']
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', t);
EXECUTE format(
'CREATE POLICY tenant_isolation ON %I USING ("accountId" = planflow_current_account())',
t
);
EXECUTE format(
'CREATE POLICY tenant_insert ON %I FOR INSERT WITH CHECK ("accountId" = planflow_current_account())',
t
);
END LOOP;
END;
$$;
@@ -0,0 +1,34 @@
-- CreateTable
CREATE TABLE "TeamMember" (
"id" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"isPrimary" BOOLEAN NOT NULL DEFAULT true,
"position" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TeamMember_accountId_idx" ON "TeamMember"("accountId");
-- CreateIndex
CREATE INDEX "TeamMember_membershipId_idx" ON "TeamMember"("membershipId");
-- CreateIndex
CREATE UNIQUE INDEX "TeamMember_teamId_membershipId_key" ON "TeamMember"("teamId", "membershipId");
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Isolation : `TeamMember` porte accountId, donc la politique est exigible.
ALTER TABLE "TeamMember" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "TeamMember" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON "TeamMember"
USING ("accountId" = planflow_current_account());
CREATE POLICY tenant_insert ON "TeamMember"
FOR INSERT WITH CHECK ("accountId" = planflow_current_account());
+128
View File
@@ -66,11 +66,35 @@ model Team {
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
scopes MembershipScope[]
members TeamMember[]
@@index([accountId])
@@index([locationId])
}
/// Rattachement d'un salarié à une équipe.
///
/// Distinct de `MembershipScope`, qui dit ce qu'un manager a le droit de voir.
/// Celui-ci dit où un salarié **apparaît dans la grille** — y compris une
/// semaine où il n'a aucun créneau, ce qui est justement l'état de départ
/// quand on construit un planning.
model TeamMember {
id String @id @default(cuid())
accountId String
teamId String
membershipId String
/// Équipe d'affectation principale ; les autres sont des renforts.
isPrimary Boolean @default(true)
position Int @default(0)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
@@unique([teamId, membershipId])
@@index([accountId])
@@index([membershipId])
}
// ============================================================================
// Identité
// ============================================================================
@@ -122,6 +146,7 @@ model Membership {
profile EmployeeProfile?
contracts UserContract[]
workPermits WorkPermit[]
teams TeamMember[]
@@unique([accountId, employeeNumber])
@@index([accountId])
@@ -545,3 +570,106 @@ model WorkloadReview {
@@index([accountId])
@@index([userContractId])
}
// ============================================================================
// Planning — PLAN.md §4.4, WP-04
// ============================================================================
/// Semaine de planning d'une équipe.
///
/// La publication est **par équipe** : l'audit montre un bouton « Dépublier »
/// sur chaque section de la grille, pas un seul pour l'établissement.
model WeeklySchedule {
id String @id @default(cuid())
accountId String
locationId String
teamId String
isoYear Int
isoWeek Int
status ScheduleStatus @default(DRAFT)
publishedAt DateTime?
publishedBy String?
/// Verrou optimiste : la grille s'édite à plusieurs (PLAN.md §3.4).
version Int @default(0)
shifts Shift[]
rests Rest[]
dailyNotes DailyNote[]
@@unique([teamId, isoYear, isoWeek])
@@index([accountId])
@@index([locationId, isoYear, isoWeek])
}
enum ScheduleStatus {
DRAFT
VALIDATED
PUBLISHED
}
model Shift {
id String @id @default(cuid())
accountId String
weeklyScheduleId String
/// Null = besoin non couvert, affiché sur sa propre ligne.
membershipId String?
/// Regroupement dans la grille uniquement. Les durées se calculent depuis
/// les instants — la nuit du changement d'heure, 22 h–06 h dure 7 h ou 9 h.
localDate DateTime @db.Date
startAt DateTime @db.Timestamptz
endAt DateTime @db.Timestamptz
breakMinutes Int @default(0)
actualStartAt DateTime? @db.Timestamptz
actualEndAt DateTime? @db.Timestamptz
actualBreakMinutes Int?
labelId String?
mealCount Int @default(0)
isValidated Boolean @default(false)
validatedAt DateTime?
validatedBy String?
note String?
version Int @default(0)
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
@@index([accountId])
@@index([weeklyScheduleId, localDate])
@@index([membershipId, localDate])
}
/// Repos de journée — hebdomadaire ou compensateur. Les pauses *dans* un
/// créneau sont `Shift.breakMinutes`.
model Rest {
id String @id @default(cuid())
accountId String
weeklyScheduleId String
membershipId String
localDate DateTime @db.Date
restType RestType
minutes Int?
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
@@index([accountId])
}
enum RestType {
WEEKLY_REST
COMPENSATORY_REST
}
/// Ligne « Notes et événements » de la grille.
model DailyNote {
id String @id @default(cuid())
accountId String
weeklyScheduleId String
localDate DateTime @db.Date
content String
schedule WeeklySchedule @relation(fields: [weeklyScheduleId], references: [id], onDelete: Cascade)
@@index([accountId])
}
+287
View File
@@ -9,6 +9,13 @@ import {
PERMISSION_DEFINITIONS,
SYSTEM_ROLES,
} from '../src/domain/access/permissions';
import {
isoWeekOf,
previousIsoWeek,
weekDates,
zonedInstant,
} from '../src/domain/planning/week';
import { POSTE_CODES, POSTE_LABELS } from '../src/lib/design/postes';
/**
* Jeu de données de départ — PLAN.md §11.
@@ -240,6 +247,165 @@ async function main() {
}
console.log(` ${contractSpecs.length} contrats`);
console.log('→ Étiquettes de planning');
for (const [index, code] of POSTE_CODES.entries()) {
await prisma.label.upsert({
where: { accountId_code: { accountId: account.id, code: code.toUpperCase() } },
update: { name: POSTE_LABELS[code], paletteKey: code, position: index },
create: {
accountId: account.id,
code: code.toUpperCase(),
name: POSTE_LABELS[code],
paletteKey: code,
position: index,
},
});
}
console.log(` ${POSTE_CODES.length} étiquettes`);
console.log('→ Intitulés de poste');
const jobTitles = [
'Responsable de magasin',
'Adjoint·e de direction',
'Vendeur·se conseil',
'Hôte·sse de caisse',
'Employé·e de réserve',
];
const jobTitleIds = new Map<string, string>();
for (const name of jobTitles) {
const created = await prisma.jobTitle.upsert({
where: { accountId_name: { accountId: account.id, name } },
update: {},
create: { accountId: account.id, name },
});
jobTitleIds.set(name, created.id);
}
console.log('→ Salariés sans compte applicatif');
// La majorité d'une équipe de vente ne se connecte jamais à l'outil : ces
// salariés existent en planning et en paie, sans identifiants.
const staff = [
{ number: 'E0005', first: 'Sofia', last: 'Marchetti', job: 'Vendeur·se conseil', hours: 35, team: 0, location: 'loc-nantes' },
{ number: 'E0006', first: 'Yanis', last: 'Trabelsi', job: 'Vendeur·se conseil', hours: 30, team: 0, location: 'loc-nantes' },
{ number: 'E0007', first: 'Léa', last: 'Nguyen', job: 'Hôte·sse de caisse', hours: 24, team: 1, location: 'loc-nantes' },
{ number: 'E0008', first: 'Marius', last: 'Kowalski', job: 'Hôte·sse de caisse', hours: 35, team: 1, location: 'loc-nantes' },
{ number: 'E0009', first: 'Awa', last: 'Diallo', job: 'Employé·e de réserve', hours: 35, team: 2, location: 'loc-nantes' },
{ number: 'E0010', first: 'Théo', last: 'Berger', job: 'Vendeur·se conseil', hours: 28, team: 0, location: 'loc-rennes' },
{ number: 'E0011', first: 'Clara', last: 'Fontaine', job: 'Hôte·sse de caisse', hours: 35, team: 1, location: 'loc-rennes' },
{ number: 'E0012', first: 'Noé', last: 'Perrin', job: 'Employé·e de réserve', hours: 20, team: 2, location: 'loc-rennes' },
] as const;
for (const person of staff) {
const membership = await prisma.membership.upsert({
where: {
accountId_employeeNumber: {
accountId: account.id,
employeeNumber: person.number,
},
},
update: { status: 'ACTIVE' },
create: {
accountId: account.id,
roleId: roleIds.get('employee') as string,
employeeNumber: person.number,
status: 'ACTIVE',
},
});
await prisma.employeeProfile.upsert({
where: { membershipId: membership.id },
update: { firstName: person.first, lastName: person.last },
create: {
membershipId: membership.id,
accountId: account.id,
firstName: person.first,
lastName: person.last,
city: person.location === 'loc-rennes' ? 'Rennes' : 'Nantes',
},
});
const existing = await prisma.userContract.findFirst({
where: { membershipId: membership.id },
});
if (!existing) {
await prisma.userContract.create({
data: {
accountId: account.id,
membershipId: membership.id,
locationId: person.location,
contractType: 'CDI',
startDate: new Date('2024-09-02'),
workTimeArrangement: 'HOURLY',
weeklyHours: person.hours,
jobTitleId: jobTitleIds.get(person.job) ?? null,
monthlySalary: 1900,
},
});
}
await prisma.teamMember.upsert({
where: {
teamId_membershipId: {
teamId: `${person.location}-${person.team}`,
membershipId: membership.id,
},
},
update: {},
create: {
accountId: account.id,
teamId: `${person.location}-${person.team}`,
membershipId: membership.id,
},
});
}
console.log(` ${staff.length} salariés`);
console.log('→ Rattachements des titulaires de comptes');
// Les quatre comptes applicatifs planifient aussi : sans rattachement, ils
// n'apparaîtraient pas dans la grille.
const accountHolders = [
{ number: 'E0001', team: 'loc-nantes-0', job: 'Responsable de magasin' },
{ number: 'E0002', team: 'loc-nantes-0', job: 'Adjoint·e de direction' },
{ number: 'E0003', team: 'loc-rennes-0', job: 'Responsable de magasin' },
{ number: 'E0004', team: 'loc-nantes-1', job: 'Hôte·sse de caisse' },
] as const;
for (const holder of accountHolders) {
const membership = await prisma.membership.findUnique({
where: {
accountId_employeeNumber: {
accountId: account.id,
employeeNumber: holder.number,
},
},
});
if (!membership) continue;
await prisma.userContract.updateMany({
where: { membershipId: membership.id },
data: { jobTitleId: jobTitleIds.get(holder.job) ?? null },
});
await prisma.teamMember.upsert({
where: {
teamId_membershipId: {
teamId: holder.team,
membershipId: membership.id,
},
},
update: {},
create: {
accountId: account.id,
teamId: holder.team,
membershipId: membership.id,
},
});
}
console.log('→ Plannings');
await seedPlanning(account.id);
console.log('→ Durées de conservation');
const retention = [
['Shift', 12, 'creation', 'Décompte des horaires : 1 an minimum (matrice n° 21).'],
@@ -274,6 +440,127 @@ async function main() {
console.log(`\nMot de passe de démonstration : ${DEMO_PASSWORD}`);
}
/**
* Deux semaines de planning : la précédente publiée, la courante en brouillon.
*
* Semer les deux états est délibéré — c'est la seule façon de voir en un coup
* d'œil que la publication est bien **par équipe** et que les créneaux d'une
* semaine publiée se distinguent d'un brouillon.
*/
async function seedPlanning(accountId: string): Promise<void> {
const current = isoWeekOf(new Date());
const previous = previousIsoWeek(current);
const teams = await prisma.team.findMany({
where: { accountId },
orderBy: [{ locationId: 'asc' }, { position: 'asc' }],
});
const locations = await prisma.location.findMany({ where: { accountId } });
const timezoneOf = new Map(locations.map((l) => [l.id, l.timezone]));
const labels = await prisma.label.findMany({ where: { accountId } });
const labelOf = new Map(labels.map((label) => [label.paletteKey, label.id]));
/** Poste dominant de l'équipe, par position. */
const teamPoste = ['vte', 'cai', 'res'];
/** Trois plages types du commerce de détail. */
const patterns = [
{ start: '09:00', end: '17:00', pause: 60 },
{ start: '11:00', end: '19:00', pause: 60 },
{ start: '13:00', end: '20:00', pause: 30 },
];
let shiftsCreated = 0;
for (const week of [previous, current]) {
const dates = weekDates(week);
for (const team of teams) {
const timezone = timezoneOf.get(team.locationId) ?? 'Europe/Paris';
const members = await prisma.teamMember.findMany({
where: { teamId: team.id },
orderBy: { position: 'asc' },
});
if (members.length === 0) continue;
const schedule = await prisma.weeklySchedule.upsert({
where: {
teamId_isoYear_isoWeek: {
teamId: team.id,
isoYear: week.isoYear,
isoWeek: week.isoWeek,
},
},
update: {},
create: {
accountId,
teamId: team.id,
locationId: team.locationId,
isoYear: week.isoYear,
isoWeek: week.isoWeek,
status: week === previous ? 'PUBLISHED' : 'DRAFT',
publishedAt: week === previous ? new Date() : null,
},
});
const already = await prisma.shift.count({
where: { weeklyScheduleId: schedule.id },
});
if (already > 0) continue;
const poste = teamPoste[team.position] ?? 'vte';
const labelId = labelOf.get(poste) ?? null;
for (const [index, member] of members.entries()) {
const pattern = patterns[index % patterns.length] as (typeof patterns)[number];
// Deux jours de repos glissants : la semaine de six jours consécutifs
// est justement ce que les règles de convention interdisent.
const rest = new Set([(index * 2) % 7, ((index * 2) + 1) % 7]);
for (const [dayIndex, date] of dates.entries()) {
if (rest.has(dayIndex)) continue;
await prisma.shift.create({
data: {
accountId,
weeklyScheduleId: schedule.id,
membershipId: member.membershipId,
localDate: new Date(`${date}T00:00:00Z`),
startAt: zonedInstant(date, pattern.start, timezone),
endAt: zonedInstant(date, pattern.end, timezone),
breakMinutes: pattern.pause,
labelId,
isValidated: week === previous,
},
});
shiftsCreated += 1;
}
}
// Un besoin non couvert par équipe sur la semaine courante : c'est l'état
// normal d'un planning en construction, et la ligne dédiée doit s'afficher.
if (week === current) {
const saturday = dates[5] as string;
await prisma.shift.create({
data: {
accountId,
weeklyScheduleId: schedule.id,
membershipId: null,
localDate: new Date(`${saturday}T00:00:00Z`),
startAt: zonedInstant(saturday, '10:00', timezone),
endAt: zonedInstant(saturday, '18:00', timezone),
breakMinutes: 60,
labelId,
},
});
shiftsCreated += 1;
}
}
}
console.log(` ${shiftsCreated} créneaux sur 2 semaines`);
}
main()
.catch((error) => {
console.error(error);
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Base PostgreSQL de développement, locale et jetable.
#
# L'environnement d'exécution est éphémère : le répertoire de données disparaît
# entre deux sessions. Ce script est idempotent — il initialise si besoin,
# démarre sinon, et ne touche pas à un serveur déjà en marche.
#
# Le compte de connexion est ici l'administrateur du cluster, donc
# superutilisateur : il **contourne la row-level security** (voir
# src/server/db-guard.ts, qui l'avertit au démarrage et refuse en production).
# C'est acceptable en développement, et les tests d'isolation
# (tests/integration/rls.test.ts) créent de toute façon leur propre rôle
# restreint — sans quoi ils ne prouveraient rien.
set -euo pipefail
PGBIN=${PGBIN:-/usr/lib/postgresql/16/bin}
PGDATA=${PGDATA:-/tmp/planflow-pg}
PGPORT=${PGPORT:-55432}
DB=${DB:-planflow}
ADMIN=${ADMIN:-planflow}
# PostgreSQL refuse de tourner en root ; on passe par le compte système.
as_postgres() { if [ "$(id -u)" = 0 ]; then su postgres -c "$1"; else sh -c "$1"; fi; }
mkdir -p "$PGDATA"
if [ "$(id -u)" = 0 ]; then chown -R postgres "$PGDATA"; fi
if [ ! -s "$PGDATA/PG_VERSION" ]; then
as_postgres "$PGBIN/initdb -D $PGDATA -U $ADMIN --auth=trust --encoding=UTF8 >/dev/null"
fi
if ! as_postgres "$PGBIN/pg_ctl -D $PGDATA status >/dev/null 2>&1"; then
as_postgres "$PGBIN/pg_ctl -D $PGDATA -o '-p $PGPORT -k /tmp -c listen_addresses=127.0.0.1' -l $PGDATA/server.log -w start"
fi
psql() { "$PGBIN/psql" -h 127.0.0.1 -p "$PGPORT" -U "$ADMIN" -v ON_ERROR_STOP=1 "$@"; }
psql -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$DB'" | grep -q 1 ||
psql -d postgres -c "CREATE DATABASE $DB OWNER $ADMIN"
echo "postgres prêt sur 127.0.0.1:$PGPORT — DATABASE_URL=postgresql://$ADMIN@127.0.0.1:$PGPORT/$DB"
+176
View File
@@ -0,0 +1,176 @@
import Link from 'next/link';
import { PrintButton } from '@/components/planning/PrintButton';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { formatMinutes } from '@/domain/counters/week';
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
import { posteShort, posteTokens } from '@/lib/design/postes';
import { cx } from '@/lib/cx';
import { getLabelBoard } from '@/server/planning/views';
export const metadata = { title: 'Planning · étiquettes · PlanFlow' };
interface PageProps {
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
}
export default async function EtiquettesPage({ searchParams }: PageProps) {
const params = await searchParams;
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
const board = await getLabelBoard(week, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Planning · par poste"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
const href = (semaine: string, etablissement = board.location.id) =>
`/planning/etiquettes?semaine=${semaine}&etablissement=${etablissement}`;
return (
<PageBody>
<PageHeader
title={`Planning · par poste · semaine ${week.isoWeek}`}
subtitle={`${board.location.name} · ${board.label.split('·')[1]?.trim()}`}
actions={
<>
<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>
<PrintButton />
</>
}
/>
<div className="flex flex-wrap items-center gap-2" data-print="hide">
{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>
))}
<Link
href={`/planning/semaine?semaine=${board.weekParam}&etablissement=${board.location.id}`}
className="rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2"
>
Voir par salarié →
</Link>
</div>
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<div className="min-w-[1040px]">
<div className="sticky top-0 z-20 flex border-b border-line-2 bg-surface-2">
<div className="w-56 flex-none border-r border-line-1 px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
Poste
</div>
<div className="grid flex-1 grid-cols-7">
{board.headings.map((day, index) => (
<div
key={day}
className={cx(
'border-r border-line-1 px-2 py-2 text-center text-xs font-semibold last:border-r-0',
index > 4 ? 'bg-surface-3 text-ink-2' : 'text-ink-1',
)}
>
{day}
</div>
))}
</div>
</div>
{board.rows.map((row) => {
const tokens = posteTokens(row.poste);
return (
<div
key={row.labelId}
className="flex border-b border-line-1 last:border-b-0"
>
<div className="flex w-56 flex-none items-center gap-2 border-r border-line-1 px-3 py-2">
<span
className="flex-none rounded-1 px-1.5 py-0.5 text-micro font-semibold tracking-wide"
style={{
background: tokens.bg,
color: tokens.fg,
border: `1px solid ${tokens.edge}`,
}}
>
{posteShort(row.poste)}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-ink-1">
{row.name}
</span>
<span className="tnum block text-micro text-ink-3">
{formatMinutes(row.minutes)} sur la semaine
</span>
</span>
</div>
<div className="grid flex-1 grid-cols-7">
{board.headings.map((day, index) => (
<div
key={day}
className={cx(
'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.days[index] ?? []).map((entry) => (
<span
key={entry.id}
className={cx(
'flex min-w-0 flex-col rounded-2 px-1.5 py-1 text-micro',
entry.unassigned &&
'border border-dashed border-line-3 text-ink-3',
)}
style={
entry.unassigned
? undefined
: {
background: tokens.bg,
color: tokens.fg,
}
}
>
<span className="tnum">{entry.time}</span>
<span className="truncate opacity-90">
{entry.who}
</span>
</span>
))}
</div>
))}
</div>
</div>
);
})}
</div>
</div>
</PageBody>
);
}
+84 -25
View File
@@ -1,48 +1,107 @@
import { DayTimeline, type DayLane } from '@/components/planning/DayTimeline';
import Link from 'next/link';
import { DayTimeline } from '@/components/planning/DayTimeline';
import { PrintButton } from '@/components/planning/PrintButton';
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>
<PrintButton label="Imprimer la journée" />
</>
}
/>
<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>
);
}
+177
View File
@@ -0,0 +1,177 @@
import Link from 'next/link';
import { PrintButton } from '@/components/planning/PrintButton';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { formatMinutes } from '@/domain/counters/week';
import { monthOf, parseMonthParam } from '@/domain/planning/month';
import { cx } from '@/lib/cx';
import { getMonthBoard } from '@/server/planning/views';
export const metadata = { title: 'Planning · mois · PlanFlow' };
interface PageProps {
searchParams: Promise<{ mois?: string; etablissement?: string }>;
}
/** Heures d'une journée, en compact : « 7,5 » plutôt que « 7 h 30 ». */
function compact(minutes: number): string {
if (minutes === 0) return '';
const hours = minutes / 60;
return Number.isInteger(hours)
? String(hours)
: hours.toFixed(1).replace('.', ',');
}
export default async function MoisPage({ searchParams }: PageProps) {
const params = await searchParams;
const month = parseMonthParam(params.mois) ?? monthOf(new Date());
const board = await getMonthBoard(month, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Planning · mois"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
const href = (mois: string, etablissement = board.location.id) =>
`/planning/mois?mois=${mois}&etablissement=${etablissement}`;
const total = board.rows.reduce((sum, row) => sum + row.totalMinutes, 0);
return (
<PageBody>
<PageHeader
title={`Planning · ${board.label}`}
subtitle={`${board.location.name} · ${formatMinutes(total)} planifiées sur le mois`}
actions={
<>
<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"
>
← Mois précédent
</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"
>
Mois suivant →
</Link>
<PrintButton />
</>
}
/>
<div className="flex flex-wrap items-center gap-2" data-print="hide">
{board.locations.map((location) => (
<Link
key={location.id}
href={href(board.monthParam, 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>
))}
</div>
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<table className="w-full border-collapse text-xs">
<caption className="sr-only">
Heures planifiées par salarié et par jour, {board.label}
</caption>
<thead>
<tr className="border-b border-line-2 bg-surface-2">
<th
scope="col"
className="sticky left-0 z-10 min-w-44 bg-surface-2 px-3 py-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
Salarié
</th>
{board.days.map((day) => (
<th
key={day.isoDate}
scope="col"
className={cx(
'tnum w-8 px-1 py-2 text-center font-semibold',
day.weekend ? 'bg-surface-3 text-ink-2' : 'text-ink-1',
)}
>
{day.day}
</th>
))}
<th
scope="col"
className="px-3 py-2 text-right text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
Total
</th>
</tr>
</thead>
<tbody>
{board.rows.map((row) => (
<tr
key={row.membershipId}
className="border-b border-line-1 last:border-b-0"
>
<th
scope="row"
className="sticky left-0 z-10 bg-surface px-3 py-1.5 text-left text-sm font-medium text-ink-1"
>
{row.name}
<span className="block text-micro font-normal text-ink-3">
{row.workedDays} jour{row.workedDays > 1 ? 's' : ''}{' '}
travaillé{row.workedDays > 1 ? 's' : ''}
</span>
</th>
{row.minutes.map((minutes, index) => {
const day = board.days[index];
return (
<td
key={day?.isoDate ?? index}
className={cx(
'tnum border-l border-line-1 px-1 py-1.5 text-center',
day?.weekend && 'bg-surface-2',
// Au-delà de 10 h, la journée mérite un regard : c'est
// la borne légale au-dessus de laquelle une dérogation
// devient nécessaire (le contrôle formel arrive avec le
// moteur de règles).
minutes > 600 && 'bg-warn-soft text-warn-soft-ink',
)}
title={
minutes > 0
? `${formatMinutes(minutes)} le ${day?.day ?? ''}`
: undefined
}
>
{compact(minutes)}
</td>
);
})}
<td className="tnum border-l border-line-2 px-3 py-1.5 text-right font-semibold text-ink-1">
{formatMinutes(row.totalMinutes)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{board.rows.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Aucun salarié rattaché à une équipe de cet établissement.
</p>
) : null}
</PageBody>
);
}
+106 -19
View File
@@ -1,38 +1,125 @@
import { WeekGrid } from '@/components/planning/WeekGrid';
import Link from 'next/link';
import { PrintButton } from '@/components/planning/PrintButton';
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 canDuplicate = can(session.actor, 'planning.duplicate');
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>
<PrintButton label="Imprimer la semaine" />
</>
}
/>
<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>
<div className="flex flex-wrap items-center gap-2" data-print="hide">
{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}
previousParam={board.previousParam}
canEdit={canEdit}
canPublish={canPublish}
canDuplicate={canDuplicate}
/>
))}
<PosteLegend />
</PageBody>
+58
View File
@@ -365,3 +365,61 @@ body {
transition-duration: 1ms !important;
}
}
/* ---------------------------------------------------------------------------
Impression
---------------------------------------------------------------------------
Un planning affiché reste affiché ; un planning imprimé est le document que
l'équipe lit dans la réserve. Deux exigences en découlent :
- la couleur ne doit pas être nécessaire (les codes de poste sont déjà
imprimés dans chaque bloc), mais les aplats doivent sortir quand même,
d'où `print-color-adjust: exact` ;
- rien ne doit être coupé : la grille tient en paysage et les lignes ne se
scindent pas entre deux pages.
*/
@media print {
@page {
size: A4 landscape;
margin: 10mm;
}
:root {
/* L'impression part toujours du thème clair : un aplat sombre consomme
l'encre sans rien apporter. */
color-scheme: light;
}
html,
body {
background: #fff;
color: #000;
}
* {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
/* Les zones à défilement horizontal doivent se déplier, sinon la fin de la
semaine n'est tout simplement pas sur la feuille. */
.overflow-x-auto {
overflow: visible !important;
}
.min-w-\[1040px\] {
min-width: 0 !important;
}
[data-print='hide'],
nav,
aside {
display: none !important;
}
section,
tr,
.print-keep {
break-inside: avoid;
}
}
+20 -18
View File
@@ -1,13 +1,15 @@
import { posteShort, posteTokens } from '@/lib/design/postes';
import { cx } from '@/lib/cx';
import { clock, initials, type DemoEmployee, type DemoShift } from '@/lib/demo/types';
import type { DayLane } from '@/server/planning/queries';
export interface DayLane {
employee: DemoEmployee;
shifts: DemoShift[];
unassigned?: boolean;
/** « 09:00 » depuis des minutes après minuit ; 25 h devient 01:00. */
function clock(minutes: number): string {
const hours = Math.floor(minutes / 60) % 24;
return `${String(hours).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
}
export type { DayLane };
export interface DayTimelineProps {
lanes: DayLane[];
/** Amplitude affichée, en heures locales. */
@@ -32,7 +34,9 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
return lanes.filter(
(lane) =>
!lane.unassigned &&
lane.shifts.some((shift) => shift.start < end && shift.end > start),
lane.shifts.some(
(shift) => shift.startMinutes < end && shift.endMinutes > start,
),
).length;
});
const peak = Math.max(1, ...coverage);
@@ -95,7 +99,7 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
{lanes.map((lane) => (
<div
key={lane.employee.id}
key={lane.id}
className={cx(
'flex border-b border-line-1 last:border-b-0',
lane.unassigned && 'bg-surface-2',
@@ -111,7 +115,7 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
: 'bg-surface-3 text-ink-2',
)}
>
{lane.unassigned ? '?' : initials(lane.employee)}
{lane.initials}
</span>
<span className="min-w-0">
<span
@@ -120,12 +124,10 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
lane.unassigned ? 'text-ink-3' : 'text-ink-1',
)}
>
{lane.unassigned
? 'Non assigné'
: `${lane.employee.firstName} ${lane.employee.lastName}`}
{lane.name}
</span>
<span className="block truncate text-micro text-ink-3">
{lane.employee.job}
{lane.job}
</span>
</span>
</div>
@@ -140,29 +142,29 @@ export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
/>
))}
{lane.shifts.map((shift, index) => {
{lane.shifts.map((shift) => {
const tokens = posteTokens(shift.poste);
const ghost = shift.state === 'unassigned';
return (
<div
key={index}
key={shift.id}
className="absolute top-1 bottom-1 flex items-center gap-1.5 overflow-hidden rounded-2 px-2 text-micro"
style={{
left: offset(shift.start),
width: `calc(${offset(shift.end)} - ${offset(shift.start)})`,
left: offset(shift.startMinutes),
width: `calc(${offset(shift.endMinutes)} - ${offset(shift.startMinutes)})`,
background: ghost ? 'transparent' : tokens.bg,
color: ghost ? 'var(--color-ink-2)' : tokens.fg,
border: ghost
? `1px dashed ${tokens.edge}`
: '1px solid transparent',
}}
title={`${posteShort(shift.poste)} · ${clock(shift.start)}–${clock(shift.end)}`}
title={`${posteShort(shift.poste)} · ${clock(shift.startMinutes)}–${clock(shift.endMinutes)}`}
>
<span className="font-semibold tracking-wide">
{posteShort(shift.poste)}
</span>
<span className="tnum truncate opacity-90">
{clock(shift.start)}–{clock(shift.end)}
{clock(shift.startMinutes)}–{clock(shift.endMinutes)}
</span>
</div>
);
+19
View File
@@ -0,0 +1,19 @@
'use client';
import { Button } from '@/components/ui/Button';
/**
* Impression du planning.
*
* Pas de génération PDF côté serveur : le navigateur sait déjà paginer, et sa
* boîte d'impression permet d'enregistrer en PDF. Un moteur de rendu de plus
* serait un second endroit où la grille peut diverger de ce qui est à l'écran.
* La mise en page d'impression vit dans `globals.css`.
*/
export function PrintButton({ label = 'Imprimer' }: { label?: string }) {
return (
<Button type="button" onClick={() => window.print()}>
{label}
</Button>
);
}
+460
View File
@@ -0,0 +1,460 @@
'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,
type BoardShift,
} from '@/domain/planning/board';
import {
createShiftAction,
deleteShiftAction,
duplicateWeekAction,
publishWeekAction,
unpublishWeekAction,
updateShiftAction,
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;
/** Semaine précédente : proposition par défaut de la duplication. */
previousParam: string;
/** Faux quand la capacité de modification manque : la grille reste lisible. */
canEdit: boolean;
canPublish: boolean;
canDuplicate: boolean;
}
const empty: PlanningActionState = {};
/** Cible du panneau d'édition : création dans une case, ou créneau existant. */
type Target =
| { kind: 'create'; membershipId: string | null; dayIndex: number }
| {
kind: 'edit';
shift: BoardShift;
membershipId: string | null;
dayIndex: number;
};
/**
* 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,
previousParam,
canEdit,
canPublish,
canDuplicate,
}: TeamSectionProps) {
const [target, setTarget] = useState<Target | null>(null);
const published = section.status === 'PUBLISHED';
const plannedMinutes = section.rows.reduce(
(total, row) => total + row.counters.plannedMinutes,
0,
);
const isEmpty =
section.rows.every((row) => row.days.flat().length === 0) &&
!section.unassignedRow;
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>
<div className="flex flex-wrap items-center gap-2 print:hidden">
{canDuplicate && isEmpty ? (
<DuplicateControl
teamId={section.teamId}
source={previousParam}
target={weekParam}
/>
) : null}
{canPublish ? (
<PublishControl
teamId={section.teamId}
weekParam={weekParam}
version={section.version}
published={published}
/>
) : null}
</div>
</header>
<WeekGrid
days={days}
dates={dates}
rows={section.rows}
unassignedRow={section.unassignedRow}
{...(canEdit
? {
cellAction: (row: BoardRow, dayIndex: number) => (
<button
type="button"
onClick={() =>
setTarget({
kind: 'create',
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 print:hidden"
>
+ Créneau
<span className="sr-only">
{` pour ${row.unassigned ? 'un besoin non couvert' : displayName(row)} le ${days[dayIndex] ?? ''}`}
</span>
</button>
),
shiftAction: (shift, row, dayIndex) => (
<div className="absolute -top-1 -right-1 flex gap-0.5 print:hidden">
<button
type="button"
title="Modifier le créneau"
onClick={() =>
setTarget({
kind: 'edit',
shift,
membershipId: row.membershipId,
dayIndex,
})
}
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-accent hover:text-accent focus-visible:opacity-100 group-hover/cell:opacity-100"
>
<span aria-hidden>✎</span>
<span className="sr-only">Modifier le créneau</span>
</button>
<DeleteShift shiftId={shift.id} />
</div>
),
}
: {})}
/>
{target ? (
<ShiftComposer
key={target.kind === 'edit' ? target.shift.id : 'create'}
teamId={section.teamId}
weekParam={weekParam}
labels={labels}
rows={section.rows}
localDate={dates[target.dayIndex] ?? ''}
dayLabel={days[target.dayIndex] ?? ''}
target={target}
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>
);
}
/**
* Proposé seulement sur une semaine vide : dupliquer par-dessus un travail
* commencé le détruirait, et l'action refuse de toute façon côté serveur.
*/
function DuplicateControl({
teamId,
source,
target,
}: {
teamId: string;
source: string;
target: string;
}) {
const [state, formAction, pending] = useActionState(
duplicateWeekAction,
empty,
);
return (
<form action={formAction} className="flex items-center gap-2">
<input type="hidden" name="teamId" value={teamId} />
<input type="hidden" name="source" value={source} />
<input type="hidden" name="target" value={target} />
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
</span>
) : null}
<Button type="submit" size="sm" disabled={pending}>
Copier la semaine précédente
</Button>
</form>
);
}
function DeleteShift({ shiftId }: { shiftId: string }) {
const [state, formAction, pending] = useActionState(deleteShiftAction, empty);
return (
<form action={formAction}>
<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>
);
}
/**
* Panneau d'édition d'un créneau, en création comme en modification.
*
* Un seul formulaire pour les deux : déplacer un créneau, c'est changer son
* jour, son salarié ou ses heures — exactement les champs de la création. Deux
* écrans distincts finiraient par diverger.
*/
function ShiftComposer({
teamId,
weekParam,
labels,
rows,
localDate,
dayLabel,
target,
onClose,
}: {
teamId: string;
weekParam: string;
labels: BoardLabel[];
rows: BoardRow[];
localDate: string;
dayLabel: string;
target: Target;
onClose: () => void;
}) {
const editing = target.kind === 'edit';
const [state, formAction, pending] = useActionState(
editing ? updateShiftAction : 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]);
const [start, end] = editing
? target.shift.time.split('–')
: ['09:00', '17:00'];
return (
<form
action={formAction}
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3 print:hidden"
>
{editing ? (
<input type="hidden" name="shiftId" value={target.shift.id} />
) : (
<>
<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={target.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={start}
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={end}
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={editing ? target.shift.breakMinutes : 0}
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
{editing ? null : (
<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}>
{editing ? 'Enregistrer' : '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>
);
}
+101 -51
View File
@@ -1,19 +1,27 @@
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,
type BoardShift,
} 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?: (
shift: BoardShift,
row: BoardRow,
dayIndex: number,
) => React.ReactNode;
}
/**
@@ -24,14 +32,44 @@ 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]">
{/* Rôles ARIA explicites : la grille est faite de div pour la mise en
page, mais elle se lit comme un tableau — un lecteur d'écran doit
pouvoir annoncer « ligne Camille Ferrand, colonne mercredi ». */}
<div role="grid" aria-label="Planning de la semaine" 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>
@@ -40,13 +78,20 @@ export function WeekGrid({ days, rows, unassignedRow }: WeekGridProps) {
function GridHeader({ days }: { days: readonly string[] }) {
return (
<div className="sticky top-0 z-20 flex border-b border-line-2 bg-surface-2">
<div className="w-64 flex-none border-r border-line-1 px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
<div
role="row"
className="sticky top-0 z-20 flex border-b border-line-2 bg-surface-2"
>
<div
role="columnheader"
className="w-64 flex-none border-r border-line-1 px-3 py-2 text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase"
>
Salarié
</div>
<div className="grid flex-1 grid-cols-7">
{days.map((day, index) => (
<div
role="columnheader"
key={day}
className={cx(
'border-r border-line-1 px-2 py-2 text-center text-xs font-semibold last:border-r-0',
@@ -61,18 +106,36 @@ 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?: (
shift: BoardShift,
row: BoardRow,
dayIndex: number,
) => 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
role="row"
data-membership={row.membershipId ?? 'unassigned'}
className={cx(
'flex border-b border-line-1 last:border-b-0',
row.unassigned && 'bg-surface-2',
)}
>
<div className="w-64 flex-none border-r border-line-1 px-3 py-2">
<div role="rowheader" className="w-64 flex-none border-r border-line-1 px-3 py-2">
<div className="flex items-center gap-2">
{row.unassigned ? (
<span
@@ -86,7 +149,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 +162,44 @@ 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}
role="gridcell"
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, row, index)}
</div>
))}
{cellAction?.(row, index)}
</div>
);
})}
+1 -1
View File
@@ -38,7 +38,7 @@ export function AppShell({
return (
<div className="flex min-h-dvh flex-col bg-canvas text-ink-1">
<header className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
<header data-print="hide" className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
<Link
href="/"
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.015em]"
+2
View File
@@ -40,6 +40,8 @@ export const NAVIGATION: NavSection[] = [
items: [
{ id: 'semaine', label: 'Vue semaine', href: '/planning/semaine' },
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' },
{ id: 'etiquettes', label: 'Vue par poste', href: '/planning/etiquettes' },
{ id: 'mois', label: 'Vue mois', href: '/planning/mois' },
{ id: 'besoins', label: 'Besoins non couverts', badge: '5' },
{ id: 'modeles', label: "Modèles d'ouverture" },
],
+204
View File
@@ -0,0 +1,204 @@
/**
* Mise en forme de la grille hebdomadaire.
*
* Volontairement séparé de l'accès aux données : ranger sept jours de créneaux
* en colonnes est la partie où l'on se trompe (fuseau, semaine à cheval,
* créneau de nuit), et elle se teste sans base.
*/
import type { ShiftState } from '@/components/planning/ShiftChip';
import type { WeekCounters } from '@/domain/counters/week';
import { shiftMinutes } from '@/domain/counters/week';
import { zonedClock, zonedDate } from '@/domain/planning/week';
import type { PosteCode } from '@/lib/design/postes';
export interface BoardShiftInput {
id: string;
membershipId: string | null;
startAt: Date;
endAt: Date;
breakMinutes: number;
poste: PosteCode;
isValidated: boolean;
note: string | null;
}
export interface BoardShift {
id: string;
poste: PosteCode;
/** « 09:00–17:00 », en heure locale de l'établissement. */
time: string;
minutes: number;
breakMinutes: number;
state: ShiftState;
note: string | null;
}
export interface BoardRow {
/** `null` sur la ligne des besoins non couverts. */
membershipId: string | null;
firstName: string;
lastName: string;
job: string;
forfaitJours: boolean;
counters: WeekCounters;
/** Sept cases, lundi → dimanche. */
days: BoardShift[][];
unassigned: boolean;
}
export interface RowPerson {
membershipId: string;
firstName: string;
lastName: string;
job: string;
forfaitJours: boolean;
contractMinutes: number;
}
/**
* Range les créneaux d'une semaine en lignes × sept colonnes.
*
* Le jour de rattachement se déduit de `startAt` **vu depuis le fuseau de
* l'établissement**, pas de la colonne `localDate` : celle-ci est un cache de
* regroupement, et un créneau déplacé sans la mettre à jour se rangerait au
* mauvais endroit. Ici, les deux ne peuvent pas diverger.
*/
export function buildRows(
people: RowPerson[],
shifts: BoardShiftInput[],
weekDates: string[],
timeZone: string,
isPublished: boolean,
): { rows: BoardRow[]; unassignedRow: BoardRow | null } {
const columnOf = new Map(weekDates.map((date, index) => [date, index]));
const emptyDays = (): BoardShift[][] =>
Array.from({ length: 7 }, () => [] as BoardShift[]);
const byMember = new Map<string, BoardShift[][]>();
const minutesByMember = new Map<string, number>();
const sundaysByMember = new Map<string, Set<number>>();
const unassignedDays = emptyDays();
let hasUnassigned = false;
for (const shift of shifts) {
const column = columnOf.get(zonedDate(shift.startAt, timeZone));
// Un créneau hors des sept jours ne peut pas s'afficher : le taire vaut
// mieux que de l'écraser sur lundi, où il fausserait le compteur.
if (column === undefined) continue;
const minutes = shiftMinutes(shift.startAt, shift.endAt, shift.breakMinutes);
const view: BoardShift = {
id: shift.id,
poste: shift.poste,
time: `${zonedClock(shift.startAt, timeZone)}–${zonedClock(shift.endAt, timeZone)}`,
minutes,
breakMinutes: shift.breakMinutes,
state: shiftState(shift.isValidated, isPublished, shift.membershipId),
note: shift.note,
};
if (!shift.membershipId) {
unassignedDays[column]?.push(view);
hasUnassigned = true;
continue;
}
let days = byMember.get(shift.membershipId);
if (!days) {
days = emptyDays();
byMember.set(shift.membershipId, days);
}
days[column]?.push(view);
minutesByMember.set(
shift.membershipId,
(minutesByMember.get(shift.membershipId) ?? 0) + minutes,
);
if (column === 6) {
const sundays =
sundaysByMember.get(shift.membershipId) ?? new Set<number>();
sundays.add(column);
sundaysByMember.set(shift.membershipId, sundays);
}
}
for (const days of byMember.values()) {
for (const column of days) column.sort(byStartTime);
}
for (const column of unassignedDays) column.sort(byStartTime);
const rows = people.map((person) => {
const days = byMember.get(person.membershipId) ?? emptyDays();
const worked = days.filter((column) => column.length > 0).length;
return {
membershipId: person.membershipId,
firstName: person.firstName,
lastName: person.lastName,
job: person.job,
forfaitJours: person.forfaitJours,
unassigned: false,
days,
counters: {
contractMinutes: person.forfaitJours ? 0 : person.contractMinutes,
plannedMinutes: minutesByMember.get(person.membershipId) ?? 0,
// Les absences arrivent au lot suivant ; le compteur existe déjà pour
// que la formule d'écart ne change pas quand elles se brancheront.
absenceMinutes: 0,
sundaysWorked: sundaysByMember.get(person.membershipId)?.size ?? 0,
restDays: 7 - worked,
},
} satisfies BoardRow;
});
const unassignedRow: BoardRow | null = hasUnassigned
? {
membershipId: null,
firstName: 'Non',
lastName: 'assigné',
job: 'Besoins à couvrir',
forfaitJours: false,
unassigned: true,
days: unassignedDays,
counters: {
contractMinutes: 0,
plannedMinutes: unassignedDays
.flat()
.reduce((total, shift) => total + shift.minutes, 0),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
},
}
: null;
return { rows, unassignedRow };
}
function byStartTime(a: BoardShift, b: BoardShift): number {
return a.time.localeCompare(b.time);
}
/**
* État visuel d'un créneau.
*
* L'ordre des tests compte : un besoin non couvert reste affiché en fantôme
* même sur une semaine publiée, sinon il se confondrait avec un créneau réel.
*/
export function shiftState(
isValidated: boolean,
isPublished: boolean,
membershipId: string | null,
): ShiftState {
if (!membershipId) return 'unassigned';
if (isValidated) return 'validated';
return isPublished ? 'published' : 'draft';
}
export function initialsOf(row: BoardRow): string {
return `${row.firstName.charAt(0)}${row.lastName.charAt(0)}`.toUpperCase();
}
export function displayName(row: BoardRow): string {
return `${row.firstName} ${row.lastName}`;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Repérage des mois de planning.
*
* Le mois est la maille de la paie ; la semaine est celle du travail. Les deux
* ne s'alignent jamais, et c'est précisément pourquoi la vue mois existe :
* elle montre ce que la vue semaine ne peut pas montrer, à savoir un mois qui
* commence un jeudi.
*/
export interface Month {
year: number;
/** 1 = janvier. */
month: number;
}
const MONTH_NAMES = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
export function monthOf(date: Date): Month {
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1 };
}
export function daysInMonth({ year, month }: Month): number {
// Le jour 0 du mois suivant est le dernier du mois courant : la règle
// bissextile est celle du calendrier, pas une table à maintenir.
return new Date(Date.UTC(year, month, 0)).getUTCDate();
}
/** Les dates civiles du mois, en `YYYY-MM-DD`. */
export function monthDates(month: Month): string[] {
return Array.from({ length: daysInMonth(month) }, (_, index) => {
const day = String(index + 1).padStart(2, '0');
return `${month.year}-${String(month.month).padStart(2, '0')}-${day}`;
});
}
export function previousMonth({ year, month }: Month): Month {
return month === 1 ? { year: year - 1, month: 12 } : { year, month: month - 1 };
}
export function nextMonth({ year, month }: Month): Month {
return month === 12 ? { year: year + 1, month: 1 } : { year, month: month + 1 };
}
/** Numéro du jour de la semaine, 1 = lundi … 7 = dimanche. */
export function isoDayOfWeek(isoDate: string): number {
const day = new Date(`${isoDate}T00:00:00Z`).getUTCDay();
return day === 0 ? 7 : day;
}
export function monthLabel({ year, month }: Month): string {
return `${MONTH_NAMES[month - 1] ?? ''} ${year}`;
}
export function formatMonthParam({ year, month }: Month): string {
return `${year}-${String(month).padStart(2, '0')}`;
}
/** Analyse « 2026-08 » ; renvoie `null` si la forme ou les bornes sont fausses. */
export function parseMonthParam(value: string | undefined): Month | null {
if (!value) return null;
const match = /^(\d{4})-(\d{2})$/.exec(value);
if (!match) return null;
const year = Number(match[1]);
const month = Number(match[2]);
if (month < 1 || month > 12) return null;
return { year, month };
}
+263
View File
@@ -0,0 +1,263 @@
/**
* Repérage des semaines de planning.
*
* Une semaine est désignée par son couple **année ISO + numéro ISO**, jamais
* par une date de début. La raison est pratique : fin décembre, le lundi 29
* décembre 2025 appartient à la semaine 1 de 2026. Stocker « 2025-12-29 »
* comme clé ferait apparaître deux semaines 1 selon le point d'entrée, et la
* contrainte d'unicité `(teamId, isoYear, isoWeek)` ne protégerait plus rien.
*
* Toutes les fonctions de ce module sont pures. Les instants sont calculés en
* UTC pour la clé, et les *jours* de la grille sont produits dans le fuseau de
* l'établissement — les deux ne coïncident pas (PLAN.md §3.3).
*/
export interface IsoWeek {
isoYear: number;
isoWeek: number;
}
const DAY_MS = 86_400_000;
/** Minuit UTC du jour donné, sans dépendre du fuseau de la machine. */
function utcDay(year: number, month: number, day: number): Date {
return new Date(Date.UTC(year, month - 1, day));
}
/**
* Numéro de semaine ISO 8601 d'une date **civile**.
*
* L'algorithme est celui de la norme : on se déplace au jeudi de la semaine,
* dont l'année donne l'année ISO, puis on compte les semaines depuis le
* 4 janvier. Passer par le jeudi est ce qui rend le résultat correct au
* changement d'année, où la semaine chevauche deux millésimes.
*/
export function isoWeekOf(date: Date): IsoWeek {
const target = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
// getUTCDay : 0 = dimanche. En ISO, lundi = 1 et dimanche = 7.
const dayNumber = target.getUTCDay() === 0 ? 7 : target.getUTCDay();
target.setUTCDate(target.getUTCDate() + 4 - dayNumber);
const isoYear = target.getUTCFullYear();
const firstThursday = utcDay(isoYear, 1, 4);
const firstDayNumber =
firstThursday.getUTCDay() === 0 ? 7 : firstThursday.getUTCDay();
firstThursday.setUTCDate(firstThursday.getUTCDate() + 4 - firstDayNumber);
const isoWeek =
1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * DAY_MS));
return { isoYear, isoWeek };
}
/** Lundi (date civile, minuit UTC) de la semaine ISO donnée. */
export function mondayOfIsoWeek({ isoYear, isoWeek }: IsoWeek): Date {
const jan4 = utcDay(isoYear, 1, 4);
const dayNumber = jan4.getUTCDay() === 0 ? 7 : jan4.getUTCDay();
const week1Monday = new Date(jan4.getTime() - (dayNumber - 1) * DAY_MS);
return new Date(week1Monday.getTime() + (isoWeek - 1) * 7 * DAY_MS);
}
/** Nombre de semaines ISO de l'année : 52, ou 53 les années longues. */
export function weeksInIsoYear(isoYear: number): number {
return isoWeekOf(utcDay(isoYear, 12, 28)).isoWeek;
}
/** Semaine précédente, en franchissant correctement les années à 53 semaines. */
export function previousIsoWeek({ isoYear, isoWeek }: IsoWeek): IsoWeek {
if (isoWeek > 1) return { isoYear, isoWeek: isoWeek - 1 };
const year = isoYear - 1;
return { isoYear: year, isoWeek: weeksInIsoYear(year) };
}
export function nextIsoWeek({ isoYear, isoWeek }: IsoWeek): IsoWeek {
if (isoWeek < weeksInIsoYear(isoYear)) {
return { isoYear, isoWeek: isoWeek + 1 };
}
return { isoYear: isoYear + 1, isoWeek: 1 };
}
/** Les sept dates civiles de la semaine, lundi → dimanche, en `YYYY-MM-DD`. */
export function weekDates(week: IsoWeek): string[] {
const monday = mondayOfIsoWeek(week);
return Array.from({ length: 7 }, (_, index) =>
new Date(monday.getTime() + index * DAY_MS).toISOString().slice(0, 10),
);
}
/**
* Bornes absolues de la semaine dans un fuseau donné.
*
* Sert à charger les créneaux : `startAt >= from && startAt < to`. Les bornes
* sont des **instants**, pas des dates civiles — la semaine du passage à
* l'heure d'hiver dure 169 heures, celle du passage à l'heure d'été 167.
*/
export function weekBounds(
week: IsoWeek,
timeZone: string,
): { from: Date; to: Date } {
const dates = weekDates(week);
const first = dates[0];
const monday = zonedMidnight(first ?? '1970-01-01', timeZone);
const nextMonday = zonedMidnight(
new Date(mondayOfIsoWeek(week).getTime() + 7 * DAY_MS)
.toISOString()
.slice(0, 10),
timeZone,
);
return { from: monday, to: nextMonday };
}
/**
* Instant correspondant à minuit local d'une date civile dans un fuseau.
*
* Il n'existe pas d'API native pour cela : on tâtonne à partir de l'UTC, en
* mesurant le décalage réel de ce fuseau **à cet instant-là**, puis on corrige.
* Deux passes suffisent, y compris le jour d'un changement d'heure.
*/
export function zonedMidnight(isoDate: string, timeZone: string): Date {
const [year, month, day] = isoDate.split('-').map(Number);
const naive = Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1);
let instant = naive;
for (let pass = 0; pass < 2; pass += 1) {
instant = naive - offsetAt(new Date(instant), timeZone);
}
return new Date(instant);
}
/** Décalage du fuseau à un instant donné, en millisecondes. */
export function offsetAt(instant: Date, timeZone: string): number {
// `en-US` avec `hourCycle: 'h23'` évite le « 24:00 » que produit `h24` à
// minuit, qui décalerait le calcul d'une journée entière.
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
}).formatToParts(instant);
const get = (type: string) =>
Number(parts.find((part) => part.type === type)?.value ?? '0');
const asUtc = Date.UTC(
get('year'),
get('month') - 1,
get('day'),
get('hour'),
get('minute'),
get('second'),
);
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
}
/**
* Instant correspondant à une heure locale « 09:00 » d'une date civile.
*
* Passe par minuit local puis corrige : le jour d'un changement d'heure, le
* décalage à 09 h n'est pas celui de minuit, et ajouter neuf heures à minuit
* donnerait 08 h ou 10 h locales.
*/
export function zonedInstant(
isoDate: string,
clock: string,
timeZone: string,
): Date {
const [hours, minutes] = clock.split(':').map(Number);
const midnight = zonedMidnight(isoDate, timeZone);
const naive = new Date(
midnight.getTime() + ((hours ?? 0) * 60 + (minutes ?? 0)) * 60_000,
);
const reached = offsetAt(naive, timeZone);
const atMidnight = offsetAt(midnight, timeZone);
return reached === atMidnight
? naive
: new Date(naive.getTime() - (reached - atMidnight));
}
/** Date civile (`YYYY-MM-DD`) d'un instant, vue depuis un fuseau. */
export function zonedDate(instant: Date, timeZone: string): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(instant);
}
/** Heure locale « 09:00 » d'un instant, vue depuis un fuseau. */
export function zonedClock(instant: Date, timeZone: string): string {
return new Intl.DateTimeFormat('fr-FR', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).format(instant);
}
const DAY_LABELS = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.'];
/** « Lun. 10 » — en-tête de colonne de la grille. */
export function dayHeadings(week: IsoWeek): string[] {
return weekDates(week).map((isoDate, index) => {
const label = DAY_LABELS[index] ?? '';
const day = Number(isoDate.slice(8, 10));
return `${label.charAt(0).toUpperCase()}${label.slice(1)} ${day}`;
});
}
const MONTHS = [
'janvier',
'février',
'mars',
'avril',
'mai',
'juin',
'juillet',
'août',
'septembre',
'octobre',
'novembre',
'décembre',
];
/** « Semaine 33 · 10 – 16 août 2026 ». */
export function weekLabel(week: IsoWeek): string {
const dates = weekDates(week);
const first = dates[0] ?? '';
const last = dates[6] ?? '';
const startDay = Number(first.slice(8, 10));
const endDay = Number(last.slice(8, 10));
const startMonth = MONTHS[Number(first.slice(5, 7)) - 1] ?? '';
const endMonth = MONTHS[Number(last.slice(5, 7)) - 1] ?? '';
const endYear = last.slice(0, 4);
const span =
startMonth === endMonth
? `${startDay} – ${endDay} ${endMonth} ${endYear}`
: `${startDay} ${startMonth} – ${endDay} ${endMonth} ${endYear}`;
return `Semaine ${week.isoWeek} · ${span}`;
}
/** Analyse « 2026-W33 » ; renvoie `null` si la forme ou les bornes sont fausses. */
export function parseWeekParam(value: string | undefined): IsoWeek | null {
if (!value) return null;
const match = /^(\d{4})-W(\d{1,2})$/.exec(value);
if (!match) return null;
const isoYear = Number(match[1]);
const isoWeek = Number(match[2]);
if (isoWeek < 1 || isoWeek > weeksInIsoYear(isoYear)) return null;
return { isoYear, isoWeek };
}
export function formatWeekParam({ isoYear, isoWeek }: IsoWeek): string {
return `${isoYear}-W${String(isoWeek).padStart(2, '0')}`;
}
-130
View File
@@ -1,130 +0,0 @@
import type { DemoEmployee } from '@/lib/demo/types';
/** Effectif fictif de l'établissement Nantes Atlantis. */
export const EMPLOYEES: DemoEmployee[] = [
{
id: 'camille-ferrand',
firstName: 'Camille',
lastName: 'Ferrand',
job: 'Responsable univers maison',
poste: 'enc',
contract: 'CDI 39 h',
since: '4 mars 2021',
status: 'actif',
},
{
id: 'jonas-meyer',
firstName: 'Jonas',
lastName: 'Meyer',
job: 'Adjoint magasin',
poste: 'enc',
contract: 'CDI · forfait jours',
since: '12 sept. 2019',
status: 'actif',
forfaitJours: true,
},
{
id: 'remi-chartier',
firstName: 'Rémi',
lastName: 'Chartier',
job: 'Vendeur',
poste: 'vte',
contract: 'CDI 35 h',
since: '2 juin 2022',
status: 'actif',
},
{
id: 'ines-bakhti',
firstName: 'Inès',
lastName: 'Bakhti',
job: 'Hôtesse de caisse',
poste: 'cai',
contract: 'CDI 35 h',
since: '18 janv. 2023',
status: 'actif',
},
{
id: 'nour-zaidi',
firstName: 'Nour',
lastName: 'Zaidi',
job: 'Vendeuse décoration',
poste: 'vte',
contract: 'CDI 30 h',
since: '5 oct. 2020',
status: 'actif',
},
{
id: 'alix-perron',
firstName: 'Alix',
lastName: 'Perron',
job: 'Employé réserve',
poste: 'res',
contract: 'CDI 35 h',
since: '9 mai 2024',
status: 'actif',
},
{
id: 'elsa-tavares',
firstName: 'Elsa',
lastName: 'Tavares',
job: 'Vendeuse',
poste: 'vte',
contract: 'CDD 24 h',
since: '3 févr. 2026',
status: 'essai',
},
{
id: 'malik-ouedraogo',
firstName: 'Malik',
lastName: 'Ouedraogo',
job: 'Employé logistique',
poste: 'liv',
contract: 'CDI 35 h',
since: '21 nov. 2018',
status: 'actif',
},
{
id: 'chloe-vasseur',
firstName: 'Chloé',
lastName: 'Vasseur',
job: 'Vendeuse',
poste: 'vte',
contract: 'CDI 20 h',
since: '14 juil. 2025',
status: 'actif',
},
{
id: 'yanis-bouchard',
firstName: 'Yanis',
lastName: 'Bouchard',
job: 'Vendeur univers maison',
poste: 'vte',
contract: 'CDI 35 h',
since: '17 août 2026',
status: 'essai',
},
{
id: 'sarah-lemoine',
firstName: 'Sarah',
lastName: 'Lemoine',
job: 'Vendeuse décoration',
poste: 'vte',
contract: 'CDI 30 h',
since: '7 sept. 2026',
status: 'essai',
},
{
id: 'hugo-delaunay',
firstName: 'Hugo',
lastName: 'Delaunay',
job: 'Employé retrait web',
poste: 'clc',
contract: 'CDI 28 h',
since: '2 avr. 2023',
status: 'sortie',
},
];
export function findEmployee(id: string): DemoEmployee | undefined {
return EMPLOYEES.find((employee) => employee.id === id);
}
-96
View File
@@ -1,96 +0,0 @@
import type { Tone } from '@/components/ui/Badge';
import type { PosteCode } from '@/lib/design/postes';
export interface FicheShift {
day: string;
poste: PosteCode;
time: string;
worked: string;
state: string;
tone: Tone;
}
export const FICHE_SHIFTS: FicheShift[] = [
{
day: 'Lun. 10 août',
poste: 'enc',
time: '08:00–16:00',
worked: '8 h 00',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mar. 11 août',
poste: 'enc',
time: '08:00–16:00',
worked: '8 h 00',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mer. 12 août',
poste: 'for',
time: '09:00–12:30',
worked: '3 h 30',
state: 'Validé',
tone: 'ok',
},
{
day: 'Mer. 12 août',
poste: 'enc',
time: '13:30–17:00',
worked: '3 h 30',
state: 'Heures réelles manquantes',
tone: 'warn',
},
{
day: 'Jeu. 13 août',
poste: 'enc',
time: '11:00–19:00',
worked: '8 h 00',
state: 'Publié',
tone: 'neutral',
},
];
export interface FicheDocument {
label: string;
date: string;
state: string;
tone: Tone;
}
export const FICHE_DOCUMENTS: FicheDocument[] = [
{
label: 'Contrat de travail — avenant temps partiel',
date: '2 janv. 2026',
state: 'Signé',
tone: 'ok',
},
{
label: 'Entretien professionnel 2026',
date: '18 mai 2026',
state: 'Signé',
tone: 'ok',
},
{
label: 'Attestation mutuelle',
date: '—',
state: 'En attente',
tone: 'warn',
},
];
export interface FicheCounter {
label: string;
value: string;
tone?: Tone;
}
export const FICHE_COUNTERS: FicheCounter[] = [
{ label: 'Contrat', value: '39 h' },
{ label: 'Planifié S33', value: '39 h 00' },
{ label: 'Écart', value: '+0 h 00' },
{ label: 'CP acquis', value: '14,5 j' },
{ label: 'Récupération', value: '4 h 30', tone: 'warn' },
];
-268
View File
@@ -1,268 +0,0 @@
import { findEmployee } from '@/lib/demo/equipe';
import type { DemoEmployee, DemoShift, DemoWeekRow } from '@/lib/demo/types';
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
function pick(id: string): DemoEmployee {
const employee = findEmployee(id);
if (!employee) throw new Error(`Salarié de démonstration inconnu : ${id}`);
return employee;
}
const NONE: DemoShift[] = [];
export const WEEK_DAYS = [
'Lun. 10',
'Mar. 11',
'Mer. 12',
'Jeu. 13',
'Ven. 14',
'Sam. 15',
'Dim. 16',
] as const;
export const WEEK_LABEL = 'Semaine 33 · 10 – 16 août 2026';
export const WEEK_ROWS: DemoWeekRow[] = [
{
employee: pick('camille-ferrand'),
counters: {
contractMinutes: h(39),
plannedMinutes: h(39),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'enc', start: h(8), end: h(16), state: 'validated' }],
[{ poste: 'enc', start: h(8), end: h(16), state: 'validated' }],
[
{ poste: 'for', start: h(9), end: h(12, 30), state: 'validated' },
{ poste: 'enc', start: h(13, 30), end: h(17), state: 'validated' },
],
[{ poste: 'enc', start: h(11), end: h(19), state: 'validated' }],
[{ poste: 'enc', start: h(11), end: h(19), state: 'validated' }],
NONE,
NONE,
],
},
{
employee: pick('jonas-meyer'),
counters: {
contractMinutes: 0,
plannedMinutes: h(38, 30),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
[
{ poste: 'inv', start: h(6, 30), end: h(11), state: 'validated' },
{ poste: 'enc', start: h(11, 30), end: h(15), state: 'validated' },
],
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
NONE,
[{ poste: 'enc', start: h(9), end: h(19), state: 'draft' }],
NONE,
],
},
{
employee: pick('remi-chartier'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(36, 30),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 2,
},
days: [
[
{ poste: 'vte', start: h(9, 30), end: h(13), state: 'validated' },
{ poste: 'cai', start: h(14), end: h(18, 30), state: 'validated' },
],
[
{
poste: 'cai',
start: h(12),
end: h(20),
state: 'alert',
alert: 'Repos < 11 h',
},
],
[{ poste: 'vte', start: h(10), end: h(14), state: 'validated' }],
NONE,
[
{ poste: 'vte', start: h(9), end: h(13), state: 'validated' },
{ poste: 'cab', start: h(14), end: h(18), state: 'validated' },
],
[{ poste: 'cai', start: h(9, 30), end: h(19, 30), state: 'draft' }],
NONE,
],
},
{
employee: pick('ines-bakhti'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(35),
absenceMinutes: 0,
sundaysWorked: 1,
restDays: 2,
},
days: [
[{ poste: 'cai', start: h(9), end: h(17), state: 'validated' }],
[{ poste: 'cai', start: h(9), end: h(17), state: 'validated' }],
NONE,
[{ poste: 'cai', start: h(11, 30), end: h(19, 30), state: 'validated' }],
[
{ poste: 'cai', start: h(9), end: h(14), state: 'validated' },
{ poste: 'clc', start: h(14, 30), end: h(17, 30), state: 'validated' },
],
[
{
poste: 'cai',
start: h(9, 30),
end: h(19, 30),
state: 'alert',
alert: 'Amplitude > 10 h',
},
],
NONE,
],
},
{
employee: pick('nour-zaidi'),
counters: {
contractMinutes: h(30),
plannedMinutes: h(12),
absenceMinutes: h(18),
sundaysWorked: 0,
restDays: 5,
},
absence: { kind: 'cp', label: 'Congés payés', startDay: 0, span: 5 },
days: [
NONE,
NONE,
NONE,
NONE,
NONE,
[{ poste: 'vte', start: h(13), end: h(19), state: 'draft' }],
[{ poste: 'vte', start: h(11), end: h(17), state: 'unpublished' }],
],
},
{
employee: pick('alix-perron'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(33, 15),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 2,
},
days: [
[
{ poste: 'liv', start: h(6), end: h(11), state: 'validated' },
{ poste: 'res', start: h(11, 30), end: h(14, 45), state: 'validated' },
],
[{ poste: 'res', start: h(6), end: h(13), state: 'validated' }],
[{ poste: 'liv', start: h(6), end: h(11), state: 'validated' }],
NONE,
[{ poste: 'res', start: h(6), end: h(13), state: 'validated' }],
[{ poste: 'rss', start: h(7), end: h(13), state: 'draft' }],
NONE,
],
},
{
employee: pick('elsa-tavares'),
counters: {
contractMinutes: h(24),
plannedMinutes: h(25, 30),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 3,
},
days: [
NONE,
NONE,
[{ poste: 'vte', start: h(14), end: h(19), state: 'validated' }],
[{ poste: 'cab', start: h(14), end: h(19), state: 'validated' }],
[{ poste: 'vte', start: h(14), end: h(19, 30), state: 'validated' }],
[{ poste: 'cai', start: h(13, 30), end: h(19, 30), state: 'draft' }],
[{ poste: 'vte', start: h(10), end: h(14), state: 'unpublished' }],
],
},
{
employee: pick('malik-ouedraogo'),
counters: {
contractMinutes: h(35),
plannedMinutes: h(31),
absenceMinutes: h(14),
sundaysWorked: 0,
restDays: 2,
},
absence: { kind: 'maladie', label: 'Arrêt maladie', startDay: 3, span: 2 },
days: [
[
{ poste: 'liv', start: h(5, 30), end: h(10, 30), state: 'validated' },
{ poste: 'res', start: h(11), end: h(14), state: 'validated' },
],
[{ poste: 'rss', start: h(6), end: h(13, 30), state: 'validated' }],
[{ poste: 'inv', start: h(6), end: h(13), state: 'validated' }],
NONE,
NONE,
[{ poste: 'liv', start: h(6), end: h(12, 30), state: 'draft' }],
NONE,
],
},
{
employee: pick('chloe-vasseur'),
counters: {
contractMinutes: h(20),
plannedMinutes: h(21, 15),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 4,
},
days: [
NONE,
NONE,
[{ poste: 'for', start: h(9), end: h(13), state: 'validated' }],
NONE,
[{ poste: 'vte', start: h(15), end: h(19, 30), state: 'validated' }],
[{ poste: 'cai', start: h(11), end: h(18), state: 'draft' }],
[{ poste: 'vte', start: h(11), end: h(15), state: 'unpublished' }],
],
},
];
/** Ligne des besoins sans titulaire, affichée en tête de grille. */
export const UNASSIGNED_ROW: DemoWeekRow = {
employee: {
id: 'non-assigne',
firstName: '',
lastName: 'Non assigné',
job: 'Besoin non couvert',
poste: 'cai',
contract: '—',
since: '—',
status: 'actif',
},
unassigned: true,
counters: {
contractMinutes: 0,
plannedMinutes: h(14),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 0,
},
days: [
NONE,
[{ poste: 'net', start: h(20), end: h(22), state: 'unassigned' }],
NONE,
[{ poste: 'clc', start: h(16), end: h(20), state: 'unassigned' }],
[{ poste: 'cai', start: h(17), end: h(20), state: 'unassigned' }],
[{ poste: 'cai', start: h(14), end: h(19), state: 'unassigned' }],
[{ poste: 'clc', start: h(13), end: h(17), state: 'unassigned' }],
],
};
-63
View File
@@ -1,63 +0,0 @@
import type { AbsenceKind } from '@/components/planning/AbsenceBar';
import type { ShiftState } from '@/components/planning/ShiftChip';
import type { WeekCounters } from '@/domain/counters/week';
import type { PosteCode } from '@/lib/design/postes';
export interface DemoShift {
poste: PosteCode;
/** Début et fin en minutes depuis minuit, jour local. */
start: number;
end: number;
breakMinutes?: number;
state?: ShiftState;
delta?: string;
alert?: string;
}
export interface DemoAbsence {
kind: AbsenceKind;
label: string;
/** 0 = lundi. */
startDay: number;
span: number;
}
export interface DemoEmployee {
id: string;
firstName: string;
lastName: string;
job: string;
poste: PosteCode;
contract: string;
since: string;
status: 'actif' | 'essai' | 'sortie';
forfaitJours?: boolean;
}
export interface DemoWeekRow {
employee: DemoEmployee;
counters: WeekCounters;
/** Sept entrées, lundi → dimanche. */
days: DemoShift[][];
absence?: DemoAbsence;
unassigned?: boolean;
}
export function fullName(employee: DemoEmployee): string {
return `${employee.firstName} ${employee.lastName}`;
}
export function initials(employee: DemoEmployee): string {
return `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`;
}
/** « 09:00 » depuis des minutes après minuit. */
export function clock(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export function timeRange(shift: DemoShift): string {
return `${clock(shift.start)}–${clock(shift.end)}`;
}
+641
View File
@@ -0,0 +1,641 @@
'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,
weekDates,
zonedClock,
zonedDate,
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);
}
const duplicateInput = z.object({
teamId: z.string().min(1),
/** Semaine à copier. */
source: z.string().min(1),
/** Semaine de destination. */
target: z.string().min(1),
});
/**
* Duplique une semaine vers une autre.
*
* C'est le geste le plus fréquent du métier : les plannings de commerce se
* répètent d'une semaine à l'autre, à quelques ajustements près. La copie
* reporte les créneaux **jour à jour** — le lundi source devient le lundi
* cible — et recalcule les instants dans le fuseau de l'établissement, sans
* quoi une copie franchissant un changement d'heure décalerait tout d'une
* heure.
*
* La destination doit être **vide** : écraser silencieusement le travail de
* quelqu'un d'autre est pire que refuser.
*/
export async function duplicateWeekAction(
_previous: PlanningActionState,
formData: FormData,
): Promise<PlanningActionState> {
const parsed = duplicateInput.safeParse({
teamId: formData.get('teamId'),
source: formData.get('source'),
target: formData.get('target'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const source = parseWeekParam(parsed.data.source);
const target = parseWeekParam(parsed.data.target);
if (!source || !target) return { error: 'Semaine invalide.' };
if (source.isoYear === target.isoYear && source.isoWeek === target.isoWeek) {
return { error: 'La semaine source et la semaine cible sont identiques.' };
}
let copied = 0;
try {
await mutate('planning.duplicate', async (db, actor) => {
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
const from = await db.weeklySchedule.findUnique({
where: {
teamId_isoYear_isoWeek: {
teamId: team.id,
isoYear: source.isoYear,
isoWeek: source.isoWeek,
},
},
});
if (!from) {
throw new ValidationError("La semaine à copier n'existe pas.");
}
const shifts = await db.shift.findMany({
where: { weeklyScheduleId: from.id },
});
if (shifts.length === 0) {
throw new ValidationError('La semaine à copier ne contient aucun créneau.');
}
const to = await ensureSchedule(
db,
team.id,
location.id,
target.isoYear,
target.isoWeek,
);
const existing = await db.shift.count({
where: { weeklyScheduleId: to.id },
});
if (existing > 0) {
throw new ValidationError(
'La semaine de destination contient déjà des créneaux. Videz-la avant de dupliquer.',
);
}
const sourceDates = weekDates(source);
const targetDates = weekDates(target);
const dayOf = new Map(sourceDates.map((date, index) => [date, index]));
for (const shift of shifts) {
const column = dayOf.get(zonedDate(shift.startAt, location.timezone));
if (column === undefined) continue;
const targetDate = targetDates[column] as string;
// Report par heure locale, pas par décalage de sept jours : entre
// mars et avril, sept jours d'écart ne redonnent pas la même heure.
const startClock = zonedClock(shift.startAt, location.timezone);
const endClock = zonedClock(shift.endAt, location.timezone);
const startAt = zonedInstant(targetDate, startClock, location.timezone);
let endAt = zonedInstant(targetDate, endClock, location.timezone);
if (endAt <= startAt) endAt = new Date(endAt.getTime() + 86_400_000);
await db.shift.create({
data: {
weeklyScheduleId: to.id,
membershipId: shift.membershipId,
localDate: new Date(`${targetDate}T00:00:00Z`),
startAt,
endAt,
breakMinutes: shift.breakMinutes,
labelId: shift.labelId,
note: shift.note,
} as never,
});
copied += 1;
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'planning.week.duplicate',
entityType: 'WeeklySchedule',
entityId: to.id,
after: {
teamId: team.id,
from: `${source.isoYear}-W${source.isoWeek}`,
to: `${target.isoYear}-W${target.isoWeek}`,
shifts: copied,
},
});
});
} catch (error) {
return toState(error, 'Vous ne pouvez pas dupliquer cette semaine.');
}
revalidatePath('/planning/semaine');
return { ok: true };
}
/**
* Publie ou dépublie la semaine d'une **équipe**.
*
* Le verrou optimiste porte sur `version` : deux managers sur la même grille
* est le cas normal, pas l'exception. Sans lui, le second écraserait la
* décision du premier sans que personne ne le voie (PLAN.md §3.4).
*/
async function setPublication(
formData: FormData,
publish: boolean,
): Promise<PlanningActionState> {
const parsed = publishInput.safeParse({
teamId: formData.get('teamId'),
week: formData.get('week'),
expectedVersion: formData.get('expectedVersion') ?? 0,
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
const week = parseWeekParam(parsed.data.week);
if (!week) return { error: 'Semaine invalide.' };
try {
await mutate(publish ? 'planning.publish' : 'planning.unpublish', async (db, actor) => {
const { team, location } = await loadTeamContext(db, parsed.data.teamId);
const schedule = await ensureSchedule(
db,
team.id,
location.id,
week.isoYear,
week.isoWeek,
);
if (publish) {
const count = await db.shift.count({
where: { weeklyScheduleId: schedule.id },
});
if (count === 0) {
throw new ValidationError(
'Cette semaine ne contient aucun créneau : rien à publier.',
);
}
}
const updated = await db.weeklySchedule.updateMany({
where: { id: schedule.id, version: parsed.data.expectedVersion },
data: {
status: publish ? 'PUBLISHED' : 'DRAFT',
publishedAt: publish ? new Date() : null,
publishedBy: publish ? actor.membershipId : null,
version: { increment: 1 },
},
});
if (updated.count === 0) {
throw new ValidationError(
'Cette semaine a été modifiée entre-temps. Rechargez la page avant de recommencer.',
);
}
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: publish ? 'planning.publish' : 'planning.unpublish',
entityType: 'WeeklySchedule',
entityId: schedule.id,
before: { status: schedule.status, version: schedule.version },
after: {
status: publish ? 'PUBLISHED' : 'DRAFT',
teamId: team.id,
isoYear: week.isoYear,
isoWeek: week.isoWeek,
},
});
});
} catch (error) {
return toState(
error,
publish
? 'Vous ne pouvez pas publier cette semaine.'
: 'Vous ne pouvez pas dépublier cette semaine.',
);
}
revalidatePath('/planning/semaine');
return { ok: true };
}
/**
* Deux créneaux qui se chevauchent pour le même salarié sont impossibles dans
* la réalité, et compteraient deux fois en heures. Le contrôle est en base et
* en transaction — un contrôle côté formulaire ne survit pas à deux managers
* qui planifient en même temps.
*/
async function assertNoOverlap(
db: ScopedClient,
membershipId: string,
startAt: Date,
endAt: Date,
ignoreShiftId: string | null,
): Promise<void> {
const clash = await db.shift.findFirst({
where: {
membershipId,
startAt: { lt: endAt },
endAt: { gt: startAt },
...(ignoreShiftId ? { id: { not: ignoreShiftId } } : {}),
},
select: { id: true },
});
if (clash) {
throw new ValidationError(
'Ce salarié a déjà un créneau qui recouvre cette plage.',
);
}
}
/**
* Une semaine publiée n'est pas gelée, mais la toucher est une seconde
* décision : le salarié a organisé sa semaine sur ce qu'il a lu. D'où une
* capacité distincte de `planning.edit`, que le rôle manager n'a pas par
* défaut.
*/
function assertMayEditPublished(actor: Actor): void {
if (!can(actor, 'planning.edit_published')) {
throw new ValidationError(
'Cette semaine est publiée. Dépubliez-la, ou demandez le droit de modifier une semaine publiée.',
);
}
}
function toState(error: unknown, denied: string): PlanningActionState {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) return { error: denied };
throw error;
}
+526
View File
@@ -0,0 +1,526 @@
import {
buildRows,
shiftState,
type BoardRow,
type BoardShiftInput,
} from '@/domain/planning/board';
import type { ShiftState } from '@/components/planning/ShiftChip';
import {
dayHeadings,
formatWeekParam,
nextIsoWeek,
previousIsoWeek,
weekBounds,
weekDates,
weekLabel,
zonedClock,
zonedMidnight,
type IsoWeek,
} from '@/domain/planning/week';
import { can } from '@/domain/access/authorize';
import { query } from '@/server/context';
import { POSTE_CODES, type PosteCode } from '@/lib/design/postes';
import type { ScopedClient } from '@/server/tenant';
/**
* Lecture de la grille hebdomadaire.
*
* Une section par équipe : c'est la maille de publication constatée dans
* l'audit — chaque section porte son propre bouton « Dépublier », pas
* l'établissement entier.
*/
export interface BoardSection {
teamId: string;
teamName: string;
/** Vrai quand la semaine existe mais reste masquée faute de capacité. */
hidden: boolean;
scheduleId: string | null;
status: 'DRAFT' | 'VALIDATED' | 'PUBLISHED';
version: number;
publishedAt: Date | null;
rows: BoardRow[];
unassignedRow: BoardRow | null;
}
export interface BoardLabel {
id: string;
code: string;
name: string;
poste: PosteCode;
}
export interface WeekBoard {
location: { id: string; name: string; timezone: string };
locations: Array<{ id: string; name: string }>;
week: IsoWeek;
label: string;
headings: string[];
dates: string[];
previousParam: string;
nextParam: string;
weekParam: string;
sections: BoardSection[];
labels: BoardLabel[];
totals: { plannedMinutes: number; shifts: number; unassigned: number };
}
/** Poste par défaut quand le créneau ne porte pas d'étiquette. */
const FALLBACK_POSTE: PosteCode = 'vte';
function posteOf(paletteKey: string | null | undefined): PosteCode {
if (!paletteKey) return FALLBACK_POSTE;
return (POSTE_CODES as readonly string[]).includes(paletteKey)
? (paletteKey as PosteCode)
: FALLBACK_POSTE;
}
export async function listLocations(): Promise<
Array<{ id: string; name: string; timezone: string }>
> {
return query('planning.view', async (db) => {
return db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true, timezone: true },
orderBy: { name: 'asc' },
});
});
}
export async function getWeekBoard(
week: IsoWeek,
locationId?: string,
): Promise<WeekBoard | null> {
return query(
'planning.view',
async (db, actor) => {
// Un salarié ne voit que ce qui est publié : un brouillon est une
// intention de manager, pas une information sur laquelle organiser sa
// semaine. La restriction est **serveur** — masquer côté écran laisserait
// les créneaux dans la charge utile.
const seesDrafts = can(actor, 'planning.view_unpublished');
const locations = await db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true, timezone: true },
orderBy: { name: 'asc' },
});
const location =
locations.find((candidate) => candidate.id === locationId) ??
locations[0];
if (!location) return null;
const dates = weekDates(week);
const { from, to } = weekBounds(week, location.timezone);
const teams = await db.team.findMany({
where: { locationId: location.id, archivedAt: null },
orderBy: { position: 'asc' },
select: { id: true, name: true },
});
const schedules = await db.weeklySchedule.findMany({
where: {
teamId: { in: teams.map((team) => team.id) },
isoYear: week.isoYear,
isoWeek: week.isoWeek,
...(seesDrafts ? {} : { status: 'PUBLISHED' }),
},
});
const scheduleByTeam = new Map(
schedules.map((schedule) => [schedule.teamId, schedule]),
);
const shifts = await db.shift.findMany({
where: {
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
// Bornes en instants : un créneau de nuit du dimanche appartient à
// cette semaine par son début, quel que soit son jour de fin.
startAt: { gte: from, lt: to },
},
select: {
id: true,
weeklyScheduleId: true,
membershipId: true,
startAt: true,
endAt: true,
breakMinutes: true,
isValidated: true,
note: true,
labelId: true,
},
});
const labels = await db.label.findMany({
where: { archivedAt: null },
orderBy: { position: 'asc' },
select: { id: true, code: true, name: true, paletteKey: true },
});
const paletteById = new Map(
labels.map((label) => [label.id, label.paletteKey]),
);
const people = await loadPeople(db, teams.map((team) => team.id));
const sections = teams.map((team) => {
const schedule = scheduleByTeam.get(team.id) ?? null;
const isPublished = schedule?.status === 'PUBLISHED';
const hidden = !seesDrafts && !schedule;
const teamShifts: BoardShiftInput[] = shifts
.filter((shift) => shift.weeklyScheduleId === schedule?.id)
.map((shift) => ({
id: shift.id,
membershipId: shift.membershipId,
startAt: shift.startAt,
endAt: shift.endAt,
breakMinutes: shift.breakMinutes,
poste: posteOf(paletteById.get(shift.labelId ?? '')),
isValidated: shift.isValidated,
note: shift.note,
}));
const { rows, unassignedRow } = buildRows(
people.get(team.id) ?? [],
teamShifts,
dates,
location.timezone,
isPublished,
);
return {
teamId: team.id,
teamName: team.name,
hidden,
scheduleId: schedule?.id ?? null,
status: schedule?.status ?? 'DRAFT',
version: schedule?.version ?? 0,
publishedAt: schedule?.publishedAt ?? null,
rows,
unassignedRow,
} satisfies BoardSection;
});
const plannedMinutes = sections
.flatMap((section) => section.rows)
.reduce((total, row) => total + row.counters.plannedMinutes, 0);
return {
location,
locations: locations.map(({ id, name }) => ({ id, name })),
week,
label: weekLabel(week),
headings: dayHeadings(week),
dates,
weekParam: formatWeekParam(week),
previousParam: formatWeekParam(previousIsoWeek(week)),
nextParam: formatWeekParam(nextIsoWeek(week)),
sections,
labels: labels.map((label) => ({
id: label.id,
code: label.code,
name: label.name,
poste: posteOf(label.paletteKey),
})),
totals: {
plannedMinutes,
shifts: shifts.filter((shift) => shift.membershipId).length,
unassigned: shifts.filter((shift) => !shift.membershipId).length,
},
} satisfies WeekBoard;
},
locationId ? { locationId } : undefined,
);
}
/** Salariés rattachés à chaque équipe, avec leur durée contractuelle. */
async function loadPeople(
db: ScopedClient,
teamIds: string[],
): Promise<Map<string, Awaited<ReturnType<typeof toPerson>>[]>> {
const assignments = await db.teamMember.findMany({
where: { teamId: { in: teamIds } },
orderBy: [{ position: 'asc' }],
include: {
membership: {
include: {
profile: { select: { firstName: true, lastName: true } },
contracts: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
take: 1,
select: {
weeklyHours: true,
workTimeArrangement: true,
jobTitleId: true,
},
},
},
},
},
});
const jobTitleIds = [
...new Set(
assignments
.flatMap((assignment) => assignment.membership.contracts)
.map((contract) => contract.jobTitleId)
.filter((id): id is string => Boolean(id)),
),
];
const jobTitles = await db.jobTitle.findMany({
where: { id: { in: jobTitleIds } },
select: { id: true, name: true },
});
const jobTitleById = new Map(jobTitles.map((job) => [job.id, job.name]));
const byTeam = new Map<string, Awaited<ReturnType<typeof toPerson>>[]>();
for (const assignment of assignments) {
const list = byTeam.get(assignment.teamId) ?? [];
list.push(toPerson(assignment, jobTitleById));
byTeam.set(assignment.teamId, list);
}
return byTeam;
}
function toPerson(
assignment: {
membershipId: string;
membership: {
employeeNumber: string;
profile: { firstName: string; lastName: string } | null;
contracts: Array<{
weeklyHours: { toString(): string };
workTimeArrangement: string;
jobTitleId: string | null;
}>;
};
},
jobTitleById: Map<string, string>,
) {
const contract = assignment.membership.contracts[0];
const forfaitJours = contract?.workTimeArrangement === 'FORFAIT_JOURS';
return {
membershipId: assignment.membershipId,
firstName: assignment.membership.profile?.firstName ?? '',
lastName:
assignment.membership.profile?.lastName ??
assignment.membership.employeeNumber,
job: contract?.jobTitleId
? (jobTitleById.get(contract.jobTitleId) ?? 'Poste à préciser')
: 'Poste à préciser',
forfaitJours,
contractMinutes: contract
? Math.round(Number(contract.weeklyHours.toString()) * 60)
: 0,
};
}
/**
* Vue jour : qui est présent, et quand.
*
* Elle répond à une question différente de la vue semaine — « qui tient la
* caisse à 14 h » plutôt que « qui fait combien d'heures » — d'où une requête
* distincte plutôt qu'un filtrage de la grille hebdomadaire.
*/
export interface DayLaneShift {
id: string;
poste: PosteCode;
/** Minutes depuis minuit local ; peut dépasser 1440 sur un créneau de nuit. */
startMinutes: number;
endMinutes: number;
state: ShiftState;
}
export interface DayLane {
id: string;
name: string;
job: string;
initials: string;
unassigned: boolean;
shifts: DayLaneShift[];
}
export interface DayBoard {
location: { id: string; name: string; timezone: string };
locations: Array<{ id: string; name: string }>;
isoDate: string;
label: string;
previousDate: string;
nextDate: string;
lanes: DayLane[];
fromHour: number;
toHour: number;
}
const DAY_FORMAT = new Intl.DateTimeFormat('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
timeZone: 'UTC',
});
function shiftDate(isoDate: string, days: number): string {
const base = new Date(`${isoDate}T00:00:00Z`);
return new Date(base.getTime() + days * 86_400_000).toISOString().slice(0, 10);
}
export async function getDayBoard(
isoDate: string,
locationId?: string,
): Promise<DayBoard | null> {
return query(
'planning.view',
async (db, actor) => {
const seesDrafts = can(actor, 'planning.view_unpublished');
const locations = await db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true, timezone: true },
orderBy: { name: 'asc' },
});
const location =
locations.find((candidate) => candidate.id === locationId) ??
locations[0];
if (!location) return null;
const from = zonedMidnight(isoDate, location.timezone);
const to = zonedMidnight(shiftDate(isoDate, 1), location.timezone);
const teams = await db.team.findMany({
where: { locationId: location.id, archivedAt: null },
select: { id: true },
});
const schedules = await db.weeklySchedule.findMany({
where: {
teamId: { in: teams.map((team) => team.id) },
...(seesDrafts ? {} : { status: 'PUBLISHED' }),
},
select: { id: true, status: true },
});
const statusById = new Map(
schedules.map((schedule) => [schedule.id, schedule.status]),
);
const shifts = await db.shift.findMany({
where: {
weeklyScheduleId: { in: schedules.map((schedule) => schedule.id) },
startAt: { gte: from, lt: to },
},
orderBy: { startAt: 'asc' },
});
const labels = await db.label.findMany({
select: { id: true, paletteKey: true },
});
const paletteById = new Map(
labels.map((label) => [label.id, label.paletteKey]),
);
const memberIds = [
...new Set(
shifts
.map((shift) => shift.membershipId)
.filter((id): id is string => Boolean(id)),
),
];
const members = await db.membership.findMany({
where: { id: { in: memberIds } },
include: {
profile: { select: { firstName: true, lastName: true } },
contracts: {
where: { status: 'ACTIVE' },
orderBy: { startDate: 'desc' },
take: 1,
select: { jobTitleId: true },
},
},
});
const jobTitles = await db.jobTitle.findMany({
select: { id: true, name: true },
});
const jobTitleById = new Map(jobTitles.map((job) => [job.id, job.name]));
const laneById = new Map<string, DayLane>();
for (const member of members) {
const jobTitleId = member.contracts[0]?.jobTitleId;
laneById.set(member.id, {
id: member.id,
name: `${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
job: jobTitleId
? (jobTitleById.get(jobTitleId) ?? 'Poste à préciser')
: 'Poste à préciser',
initials:
`${member.profile?.firstName?.charAt(0) ?? ''}${member.profile?.lastName?.charAt(0) ?? ''}`.toUpperCase(),
unassigned: false,
shifts: [],
});
}
const unassignedLane: DayLane = {
id: 'unassigned',
name: 'Non assigné',
job: 'Besoins à couvrir',
initials: '?',
unassigned: true,
shifts: [],
};
for (const shift of shifts) {
const startMinutes = localMinutes(shift.startAt, location.timezone);
let endMinutes = localMinutes(shift.endAt, location.timezone);
// Un créneau de nuit finit « avant » son début en heure murale : le
// reporter au lendemain garde la barre continue au lieu de l'inverser.
if (endMinutes <= startMinutes) endMinutes += 24 * 60;
const isPublished =
statusById.get(shift.weeklyScheduleId) === 'PUBLISHED';
const lane = shift.membershipId
? laneById.get(shift.membershipId)
: unassignedLane;
lane?.shifts.push({
id: shift.id,
poste: posteOf(paletteById.get(shift.labelId ?? '')),
startMinutes,
endMinutes,
state: shiftState(shift.isValidated, isPublished, shift.membershipId),
});
}
const lanes = [...laneById.values()]
.filter((lane) => lane.shifts.length > 0)
.sort((a, b) => a.name.localeCompare(b.name, 'fr'));
if (unassignedLane.shifts.length > 0) lanes.push(unassignedLane);
// L'amplitude s'ajuste à la journée réelle : figer 06 h–21 h couperait
// un inventaire de nuit, et étirerait une journée creuse.
const all = lanes.flatMap((lane) => lane.shifts);
const fromHour = all.length
? Math.max(0, Math.floor(Math.min(...all.map((s) => s.startMinutes)) / 60) - 1)
: 7;
const toHour = all.length
? Math.min(30, Math.ceil(Math.max(...all.map((s) => s.endMinutes)) / 60) + 1)
: 21;
return {
location,
locations: locations.map(({ id, name }) => ({ id, name })),
isoDate,
label: DAY_FORMAT.format(new Date(`${isoDate}T00:00:00Z`)),
previousDate: shiftDate(isoDate, -1),
nextDate: shiftDate(isoDate, 1),
lanes,
fromHour,
toHour: Math.max(toHour, fromHour + 4),
} satisfies DayBoard;
},
locationId ? { locationId } : undefined,
);
}
/** Minutes depuis minuit, en heure locale de l'établissement. */
function localMinutes(instant: Date, timeZone: string): number {
const [hours, minutes] = zonedClock(instant, timeZone).split(':').map(Number);
return (hours ?? 0) * 60 + (minutes ?? 0);
}
+393
View File
@@ -0,0 +1,393 @@
import { can } from '@/domain/access/authorize';
import { shiftMinutes } from '@/domain/counters/week';
import {
formatMonthParam,
monthDates,
monthLabel,
nextMonth,
previousMonth,
isoDayOfWeek,
type Month,
} from '@/domain/planning/month';
import {
dayHeadings,
formatWeekParam,
nextIsoWeek,
previousIsoWeek,
weekBounds,
weekDates,
weekLabel,
zonedClock,
zonedDate,
zonedMidnight,
type IsoWeek,
} from '@/domain/planning/week';
import { POSTE_CODES, type PosteCode } from '@/lib/design/postes';
import { query } from '@/server/context';
import type { ScopedClient } from '@/server/tenant';
/**
* Les vues secondaires du planning — étiquettes et mois.
*
* Elles lisent **le même modèle** que la grille hebdomadaire : `Shift` reste
* la seule source. Une vue qui recopierait les créneaux dans sa propre table
* finirait par les afficher autrement, et personne ne saurait laquelle croire.
*/
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;
}
interface LocationRef {
id: string;
name: string;
timezone: string;
}
/** Établissements visibles, avec celui qui est affiché. */
async function resolveLocation(
db: ScopedClient,
locationId: string | undefined,
): Promise<{ location: LocationRef | undefined; locations: LocationRef[] }> {
const locations = await db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true, timezone: true },
orderBy: { name: 'asc' },
});
return {
locations,
location:
locations.find((candidate) => candidate.id === locationId) ??
locations[0],
};
}
async function visibleScheduleIds(
db: ScopedClient,
locationId: string,
seesDrafts: boolean,
week?: IsoWeek,
): Promise<string[]> {
const teams = await db.team.findMany({
where: { locationId, archivedAt: null },
select: { id: true },
});
const schedules = await db.weeklySchedule.findMany({
where: {
teamId: { in: teams.map((team) => team.id) },
...(week ? { isoYear: week.isoYear, isoWeek: week.isoWeek } : {}),
...(seesDrafts ? {} : { status: 'PUBLISHED' }),
},
select: { id: true },
});
return schedules.map((schedule) => schedule.id);
}
// ---------------------------------------------------------------------------
// Vue étiquettes
// ---------------------------------------------------------------------------
export interface LabelBoardEntry {
id: string;
time: string;
who: string;
unassigned: boolean;
}
export interface LabelBoardRow {
labelId: string;
name: string;
poste: PosteCode;
/** Sept cases, lundi → dimanche. */
days: LabelBoardEntry[][];
minutes: number;
}
export interface LabelBoard {
location: LocationRef;
locations: Array<{ id: string; name: string }>;
label: string;
headings: string[];
weekParam: string;
previousParam: string;
nextParam: string;
rows: LabelBoardRow[];
}
/**
* La semaine vue par poste plutôt que par salarié.
*
* C'est la question du responsable d'ouverture : « la caisse est-elle tenue
* samedi ? » — impossible à lire d'un coup d'œil sur une grille dont les
* lignes sont des personnes.
*/
export async function getLabelBoard(
week: IsoWeek,
locationId?: string,
): Promise<LabelBoard | null> {
return query(
'planning.view',
async (db, actor) => {
const seesDrafts = can(actor, 'planning.view_unpublished');
const { location, locations } = await resolveLocation(db, locationId);
if (!location) return null;
const dates = weekDates(week);
const { from, to } = weekBounds(week, location.timezone);
const scheduleIds = await visibleScheduleIds(
db,
location.id,
seesDrafts,
week,
);
const shifts = await db.shift.findMany({
where: {
weeklyScheduleId: { in: scheduleIds },
startAt: { gte: from, lt: to },
},
orderBy: { startAt: 'asc' },
});
const labels = await db.label.findMany({
where: { archivedAt: null },
orderBy: { position: 'asc' },
});
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 } } },
});
const nameById = new Map(
members.map((member) => [
member.id,
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim(),
]),
);
const columnOf = new Map(dates.map((date, index) => [date, index]));
const rowByLabel = new Map<string, LabelBoardRow>();
const rowFor = (labelId: string | null): LabelBoardRow => {
const key = labelId ?? 'sans-etiquette';
const existing = rowByLabel.get(key);
if (existing) return existing;
const label = labels.find((candidate) => candidate.id === labelId);
const created: LabelBoardRow = {
labelId: key,
name: label?.name ?? 'Sans étiquette',
poste: posteOf(label?.paletteKey),
days: Array.from({ length: 7 }, () => [] as LabelBoardEntry[]),
minutes: 0,
};
rowByLabel.set(key, created);
return created;
};
for (const shift of shifts) {
const column = columnOf.get(zonedDate(shift.startAt, location.timezone));
if (column === undefined) continue;
const row = rowFor(shift.labelId);
row.days[column]?.push({
id: shift.id,
time: `${zonedClock(shift.startAt, location.timezone)}–${zonedClock(shift.endAt, location.timezone)}`,
who: shift.membershipId
? (nameById.get(shift.membershipId) ?? 'Salarié')
: 'Non couvert',
unassigned: !shift.membershipId,
});
row.minutes += shiftMinutes(
shift.startAt,
shift.endAt,
shift.breakMinutes,
);
}
// Les postes non planifiés restent affichés : un poste vide toute la
// semaine est une information, pas une ligne à masquer.
for (const label of labels) rowFor(label.id);
const order = new Map(labels.map((label, index) => [label.id, index]));
const rows = [...rowByLabel.values()].sort(
(a, b) =>
(order.get(a.labelId) ?? Number.MAX_SAFE_INTEGER) -
(order.get(b.labelId) ?? Number.MAX_SAFE_INTEGER),
);
return {
location,
locations: locations.map(({ id, name }) => ({ id, name })),
label: weekLabel(week),
headings: dayHeadings(week),
weekParam: formatWeekParam(week),
previousParam: formatWeekParam(previousIsoWeek(week)),
nextParam: formatWeekParam(nextIsoWeek(week)),
rows,
} satisfies LabelBoard;
},
locationId ? { locationId } : undefined,
);
}
// ---------------------------------------------------------------------------
// Vue mois
// ---------------------------------------------------------------------------
export interface MonthDay {
isoDate: string;
day: number;
weekend: boolean;
}
export interface MonthRow {
membershipId: string;
name: string;
/** Minutes travaillées par jour, dans l'ordre de `days`. */
minutes: number[];
totalMinutes: number;
workedDays: number;
}
export interface MonthBoard {
location: LocationRef;
locations: Array<{ id: string; name: string }>;
label: string;
monthParam: string;
previousParam: string;
nextParam: string;
days: MonthDay[];
rows: MonthRow[];
}
/**
* Le mois par salarié.
*
* Vue de contrôle plutôt que d'édition : elle sert à repérer une série de
* journées trop longues ou un salarié sans repos, ce que sept colonnes ne
* laissent pas voir.
*/
export async function getMonthBoard(
month: Month,
locationId?: string,
): Promise<MonthBoard | null> {
return query(
'planning.view',
async (db, actor) => {
const seesDrafts = can(actor, 'planning.view_unpublished');
const { location, locations } = await resolveLocation(db, locationId);
if (!location) return null;
const dates = monthDates(month);
const first = dates[0] as string;
const last = dates[dates.length - 1] as string;
const from = zonedMidnight(first, location.timezone);
const to = zonedMidnight(
new Date(new Date(`${last}T00:00:00Z`).getTime() + 86_400_000)
.toISOString()
.slice(0, 10),
location.timezone,
);
const scheduleIds = await visibleScheduleIds(
db,
location.id,
seesDrafts,
);
const shifts = await db.shift.findMany({
where: {
weeklyScheduleId: { in: scheduleIds },
membershipId: { not: null },
startAt: { gte: from, lt: to },
},
select: {
membershipId: true,
startAt: true,
endAt: true,
breakMinutes: true,
},
});
const teams = await db.team.findMany({
where: { locationId: location.id, archivedAt: null },
select: { id: true },
});
const assignments = await db.teamMember.findMany({
where: { teamId: { in: teams.map((team) => team.id) } },
include: {
membership: {
include: {
profile: { select: { firstName: true, lastName: true } },
},
},
},
});
const columnOf = new Map(dates.map((date, index) => [date, index]));
const rowById = new Map<string, MonthRow>();
for (const assignment of assignments) {
if (rowById.has(assignment.membershipId)) continue;
const profile = assignment.membership.profile;
rowById.set(assignment.membershipId, {
membershipId: assignment.membershipId,
name: `${profile?.firstName ?? ''} ${profile?.lastName ?? assignment.membership.employeeNumber}`.trim(),
minutes: dates.map(() => 0),
totalMinutes: 0,
workedDays: 0,
});
}
for (const shift of shifts) {
if (!shift.membershipId) continue;
const row = rowById.get(shift.membershipId);
if (!row) continue;
const column = columnOf.get(zonedDate(shift.startAt, location.timezone));
if (column === undefined) continue;
const minutes = shiftMinutes(
shift.startAt,
shift.endAt,
shift.breakMinutes,
);
const before = row.minutes[column] ?? 0;
if (before === 0 && minutes > 0) row.workedDays += 1;
row.minutes[column] = before + minutes;
row.totalMinutes += minutes;
}
return {
location,
locations: locations.map(({ id, name }) => ({ id, name })),
label: monthLabel(month),
monthParam: formatMonthParam(month),
previousParam: formatMonthParam(previousMonth(month)),
nextParam: formatMonthParam(nextMonth(month)),
days: dates.map((isoDate) => ({
isoDate,
day: Number(isoDate.slice(8, 10)),
weekend: isoDayOfWeek(isoDate) >= 6,
})),
rows: [...rowById.values()].sort((a, b) =>
a.name.localeCompare(b.name, 'fr'),
),
} satisfies MonthBoard;
},
locationId ? { locationId } : undefined,
);
}
+60
View File
@@ -0,0 +1,60 @@
import { expect, test, type Page } from '@playwright/test';
import {
formatWeekParam,
isoWeekOf,
previousIsoWeek,
} from '../../src/domain/planning/week';
/**
* Ce que chaque rôle voit du planning.
*
* Ces tests se connectent eux-mêmes : ils ne peuvent pas réutiliser la session
* partagée de la direction, d'où le projet « anonyme ».
*/
async function signIn(page: Page, email: string) {
await page.goto('/connexion');
await page.getByLabel('Adresse électronique').fill(email);
await page.getByLabel('Mot de passe').fill('planflow-demo-2026');
await page.getByRole('button', { name: 'Se connecter' }).click();
// Attendre la sortie de l'écran de connexion : naviguer tout de suite
// annulerait la redirection en cours, et le test repartirait déconnecté.
await expect(page).not.toHaveURL(/\/connexion/);
}
const current = isoWeekOf(new Date());
const CURRENT_WEEK = formatWeekParam(current);
const PUBLISHED_WEEK = formatWeekParam(previousIsoWeek(current));
test('un salarié ne voit pas une semaine non publiée', async ({ page }) => {
await signIn(page, 'salarie@example.test');
// Le seed laisse la semaine courante en brouillon et publie la précédente.
await page.goto(`/planning/semaine?semaine=${CURRENT_WEEK}`);
await expect(page.getByText(/n’est pas encore publié/).first()).toBeVisible();
// Le masquage n'est pas cosmétique : les créneaux ne sont pas chargés, donc
// absents du HTML. Un simple `display: none` les y laisserait.
expect(await page.content()).not.toContain('09:00–17:00');
// Ni bouton de publication ni ajout de créneau : les capacités manquent.
await expect(page.getByRole('button', { name: 'Publier' })).toHaveCount(0);
await expect(page.getByRole('button', { name: /\+ Créneau/ })).toHaveCount(0);
});
test('un salarié voit la semaine publiée', async ({ page }) => {
await signIn(page, 'salarie@example.test');
await page.goto(`/planning/semaine?semaine=${PUBLISHED_WEEK}`);
await expect(page.getByText('Publiée').first()).toBeVisible();
await expect(page.getByText('09:00–17:00').first()).toBeVisible();
});
test('un manager voit le brouillon et peut le publier', async ({ page }) => {
await signIn(page, 'manager.nantes@example.test');
await page.goto(`/planning/semaine?semaine=${CURRENT_WEEK}`);
await expect(page.getByText('Brouillon').first()).toBeVisible();
await expect(page.getByRole('button', { name: 'Publier' }).first()).toBeVisible();
});
+1 -1
View File
@@ -45,7 +45,7 @@ test('connexion, navigation, puis déconnexion', async ({ page }) => {
await page.getByRole('link', { name: 'Plannings' }).click();
await expect(
page.getByRole('heading', { name: /Planning · semaine 33/ }),
page.getByRole('heading', { name: /Planning · semaine \d+/ }),
).toBeVisible();
await page.getByRole('button', { name: 'Déconnexion' }).click();
+6 -4
View File
@@ -6,14 +6,16 @@ test('les six écrans se chargent et affichent leur contenu', async ({ page }) =
await page.getByRole('link', { name: 'Plannings' }).click();
await expect(
page.getByRole('heading', { name: /Planning · semaine 33/ }),
page.getByRole('heading', { name: /Planning · semaine \d+/ }),
).toBeVisible();
// La grille doit porter des créneaux, pas seulement son ossature.
await expect(page.getByText('Congés payés').first()).toBeVisible();
// La grille doit porter des créneaux venus de la base, pas seulement son
// ossature : le seed plante deux semaines sur trois équipes.
await expect(page.getByRole('heading', { name: 'Vente' })).toBeVisible();
await expect(page.getByText('09:00–17:00').first()).toBeVisible();
await page.getByRole('link', { name: 'Vue jour' }).click();
await expect(
page.getByRole('heading', { name: /Planning · mercredi 12 août/ }),
page.getByRole('heading', { name: /^Planning · / }),
).toBeVisible();
await page.getByRole('link', { name: 'Équipe', exact: true }).click();
+161
View File
@@ -0,0 +1,161 @@
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.
*/
/**
* Chaque test possède **sa** semaine : la suite tourne en parallèle, et deux
* tests qui videraient puis rempliraient la même grille se marcheraient dessus.
*/
const WEEK = '2027-W20';
const CROSS_VIEW_WEEK = '2027-W22';
function venteSection(page: Page): Locator {
return page
.locator('section')
.filter({ has: page.getByRole('heading', { name: 'Vente' }) });
}
async function resetWeek(page: Page, week = WEEK): 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> {
// Viser la ligne par son rôle ARIA : `locator('div')` remonterait au
// conteneur de la grille, et le premier bouton trouvé serait celui d'un
// autre salarié — une erreur qui ne se voit pas à l'écran.
const row = section
.getByRole('row')
.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();
});
/**
* Les vues lisent le même modèle.
*
* C'est le critère d'acceptation de WP-04 : un créneau posé dans la grille
* hebdomadaire doit apparaître **identique** dans les vues jour, poste et
* mois. Chacune interroge `Shift` directement ; ce test est ce qui empêche
* l'une d'elles de dériver vers son propre calcul.
*/
test('un créneau posé se retrouve dans les quatre vues', async ({ page }) => {
await resetWeek(page, CROSS_VIEW_WEEK);
const section = venteSection(page);
await addShift(page, section, { start: '08:30', end: '16:30', pause: '45' });
await expect(section.getByText('08:30–16:30').first()).toBeVisible();
// Lundi de la semaine 22 de 2027.
const monday = '2027-05-31';
await page.goto(`/planning/jour?jour=${monday}`);
// Chercher le nom dans la chronologie, pas dans l'en-tête de l'application,
// qui affiche déjà « Camille Ferrand, Propriétaire ».
await expect(page.getByTitle(/^ENC · 08:30–16:30$|08:30–16:30/).first()).toBeVisible();
await expect(
page.locator('main').getByText('Camille Ferrand').first(),
).toBeVisible();
await page.goto(`/planning/etiquettes?semaine=${CROSS_VIEW_WEEK}`);
await expect(page.getByText('08:30–16:30').first()).toBeVisible();
await page.goto('/planning/mois?mois=2027-05');
const row = page.getByRole('row').filter({ hasText: 'Camille Ferrand' });
// 8 h d'amplitude moins 45 min de pause : 7,3 h dans la case du jour. Les
// deux vues doivent dire la même durée. `first()` parce que l'autre test de
// ce fichier planifie le même salarié ailleurs dans le mois.
await expect(row.getByText('7,3').first()).toBeVisible();
});
+7 -2
View File
@@ -67,10 +67,13 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
// L'effectif vient de la base, pas du module de démonstration.
await expect(page.getByText('E0001')).toBeVisible();
// Nom unique par exécution : la base de test n'est pas remise à zéro entre
// deux passages, et un nom fixe finirait par désigner plusieurs salariés.
const matricule = `E9${Date.now() % 100000}`;
const nom = `Sanscompte${matricule}`;
const form = page.locator('form').filter({ hasText: 'Ajouter' });
await form.locator('input[name="firstName"]').fill('Sans');
await form.locator('input[name="lastName"]').fill('Compte');
await form.locator('input[name="lastName"]').fill(nom);
await form.locator('input[name="employeeNumber"]').fill(matricule);
await page.getByRole('button', { name: 'Ajouter' }).click();
await expect(page.getByText('Salarié ajouté.')).toBeVisible();
@@ -87,5 +90,7 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => {
).toBeVisible();
// Le nom vit sur le dossier, pas sur le compte : un salarié sans accès
// applicatif doit tout de même figurer nommément au registre du personnel.
await expect(page.getByRole('link', { name: /Sans Compte/ })).toBeVisible();
await expect(
page.getByRole('link', { name: new RegExp(`Sans ${nom}`) }),
).toBeVisible();
});
+231
View File
@@ -0,0 +1,231 @@
import { describe, expect, it } from 'vitest';
import {
buildRows,
shiftState,
type BoardShiftInput,
type RowPerson,
} from '@/domain/planning/board';
import { weekDates, zonedInstant } from '@/domain/planning/week';
const TZ = 'Europe/Paris';
const WEEK = { isoYear: 2026, isoWeek: 33 };
const DATES = weekDates(WEEK);
const person = (over: Partial<RowPerson> = {}): RowPerson => ({
membershipId: 'm1',
firstName: 'Sofia',
lastName: 'Marchetti',
job: 'Vendeuse conseil',
forfaitJours: false,
contractMinutes: 35 * 60,
...over,
});
const shift = (
date: string,
start: string,
end: string,
over: Partial<BoardShiftInput> = {},
): BoardShiftInput => ({
id: `${date}-${start}`,
membershipId: 'm1',
startAt: zonedInstant(date, start, TZ),
endAt: zonedInstant(date, end, TZ),
breakMinutes: 0,
poste: 'vte',
isValidated: false,
note: null,
...over,
});
describe('buildRows', () => {
it('range chaque créneau dans la colonne de son jour local', () => {
const { rows } = buildRows(
[person()],
[shift('2026-08-12', '09:00', '17:00')],
DATES,
TZ,
false,
);
expect(rows[0]?.days[2]).toHaveLength(1);
expect(rows[0]?.days[2]?.[0]?.time).toBe('09:00–17:00');
expect(rows[0]?.days.flat()).toHaveLength(1);
});
it('affiche un salarié sans créneau', () => {
// Un planning se construit à partir d'une grille vide : masquer les
// salariés non planifiés rendrait l'écran inutilisable le lundi matin.
const { rows } = buildRows([person()], [], DATES, TZ, false);
expect(rows).toHaveLength(1);
expect(rows[0]?.counters.plannedMinutes).toBe(0);
expect(rows[0]?.counters.restDays).toBe(7);
});
it('déduit la pause du temps planifié', () => {
const { rows } = buildRows(
[person()],
[shift('2026-08-10', '09:00', '17:00', { breakMinutes: 60 })],
DATES,
TZ,
false,
);
expect(rows[0]?.counters.plannedMinutes).toBe(7 * 60);
});
it('compte la nuit du changement d’heure à sa vraie durée', () => {
// 25 octobre 2026, retour à l'heure d'hiver : 22 h–06 h dure neuf heures.
const dates = weekDates({ isoYear: 2026, isoWeek: 43 });
const startAt = zonedInstant('2026-10-24', '22:00', TZ);
const endAt = zonedInstant('2026-10-25', '06:00', TZ);
const { rows } = buildRows(
[person()],
[
{
id: 's',
membershipId: 'm1',
startAt,
endAt,
breakMinutes: 0,
poste: 'inv',
isValidated: false,
note: null,
},
],
dates,
TZ,
false,
);
expect(rows[0]?.counters.plannedMinutes).toBe(9 * 60);
// Le créneau se range au jour de son **début**, samedi.
expect(rows[0]?.days[5]).toHaveLength(1);
expect(rows[0]?.days[6]).toHaveLength(0);
});
it('sépare les besoins non couverts sur leur propre ligne', () => {
const { rows, unassignedRow } = buildRows(
[person()],
[
shift('2026-08-15', '10:00', '18:00', {
id: 'open',
membershipId: null,
}),
],
DATES,
TZ,
false,
);
expect(rows[0]?.days.flat()).toHaveLength(0);
expect(unassignedRow?.days[5]).toHaveLength(1);
expect(unassignedRow?.days[5]?.[0]?.state).toBe('unassigned');
// Pas de contrat sur cette ligne : la comparer à 35 h n'aurait aucun sens.
expect(unassignedRow?.counters.contractMinutes).toBe(0);
});
it('n’ouvre pas de ligne « non assigné » quand tout est couvert', () => {
const { unassignedRow } = buildRows(
[person()],
[shift('2026-08-10', '09:00', '17:00')],
DATES,
TZ,
false,
);
expect(unassignedRow).toBeNull();
});
it('compte les dimanches travaillés', () => {
// Le dimanche du maire ouvre droit à majoration **et** repos compensateur :
// le compteur doit être juste, pas approximatif.
const { rows } = buildRows(
[person()],
[
shift('2026-08-16', '10:00', '18:00'),
shift('2026-08-14', '10:00', '18:00'),
],
DATES,
TZ,
false,
);
expect(rows[0]?.counters.sundaysWorked).toBe(1);
expect(rows[0]?.counters.restDays).toBe(5);
});
it('ignore un créneau hors de la semaine affichée', () => {
const { rows } = buildRows(
[person()],
[shift('2026-08-24', '09:00', '17:00')],
DATES,
TZ,
false,
);
// Le taire vaut mieux que de l'écraser sur lundi, où il fausserait le
// compteur sans que rien ne le signale.
expect(rows[0]?.counters.plannedMinutes).toBe(0);
});
it('neutralise la durée contractuelle au forfait jours', () => {
const { rows } = buildRows(
[person({ forfaitJours: true, contractMinutes: 35 * 60 })],
[],
DATES,
TZ,
false,
);
expect(rows[0]?.counters.contractMinutes).toBe(0);
});
it('ordonne les créneaux d’une même journée', () => {
const { rows } = buildRows(
[person()],
[
shift('2026-08-10', '14:00', '19:00'),
shift('2026-08-10', '07:00', '12:00'),
],
DATES,
TZ,
false,
);
expect(rows[0]?.days[0]?.map((entry) => entry.time)).toEqual([
'07:00–12:00',
'14:00–19:00',
]);
});
});
describe('shiftState', () => {
it('distingue brouillon, publié et validé', () => {
expect(shiftState(false, false, 'm1')).toBe('draft');
expect(shiftState(false, true, 'm1')).toBe('published');
expect(shiftState(true, true, 'm1')).toBe('validated');
});
it('garde le besoin non couvert reconnaissable même publié', () => {
expect(shiftState(false, true, null)).toBe('unassigned');
});
});
describe('zonedInstant', () => {
it('vise la bonne heure locale été comme hiver', () => {
expect(zonedInstant('2026-08-10', '09:00', TZ).toISOString()).toBe(
'2026-08-10T07:00:00.000Z',
);
expect(zonedInstant('2026-01-05', '09:00', TZ).toISOString()).toBe(
'2026-01-05T08:00:00.000Z',
);
});
it('reste juste le jour du changement d’heure', () => {
// 29 mars 2026 : minuit est en UTC+1, 09 h en UTC+2. Ajouter neuf heures à
// minuit donnerait 10 h locales.
expect(zonedInstant('2026-03-29', '09:00', TZ).toISOString()).toBe(
'2026-03-29T07:00:00.000Z',
);
expect(zonedInstant('2026-10-25', '09:00', TZ).toISOString()).toBe(
'2026-10-25T08:00:00.000Z',
);
});
});
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import {
daysInMonth,
formatMonthParam,
isoDayOfWeek,
monthDates,
monthLabel,
monthOf,
nextMonth,
parseMonthParam,
previousMonth,
} from '@/domain/planning/month';
describe('daysInMonth', () => {
it('suit le calendrier, y compris les années bissextiles', () => {
expect(daysInMonth({ year: 2026, month: 2 })).toBe(28);
expect(daysInMonth({ year: 2028, month: 2 })).toBe(29);
expect(daysInMonth({ year: 2026, month: 4 })).toBe(30);
expect(daysInMonth({ year: 2026, month: 12 })).toBe(31);
});
it('traite 2100 comme non bissextile', () => {
// Divisible par 4 mais pas par 400 : le piège classique d'une table écrite
// à la main.
expect(daysInMonth({ year: 2100, month: 2 })).toBe(28);
expect(daysInMonth({ year: 2000, month: 2 })).toBe(29);
});
});
describe('monthDates', () => {
it('énumère le mois entier', () => {
const dates = monthDates({ year: 2026, month: 2 });
expect(dates).toHaveLength(28);
expect(dates[0]).toBe('2026-02-01');
expect(dates[27]).toBe('2026-02-28');
});
});
describe('navigation', () => {
it('franchit l’année dans les deux sens', () => {
expect(previousMonth({ year: 2026, month: 1 })).toEqual({
year: 2025,
month: 12,
});
expect(nextMonth({ year: 2026, month: 12 })).toEqual({
year: 2027,
month: 1,
});
});
});
describe('isoDayOfWeek', () => {
it('numérote lundi 1 et dimanche 7', () => {
// Le samedi et le dimanche portent un fond distinct dans la vue mois :
// une erreur de numérotation les décalerait d'un jour toute l'année.
expect(isoDayOfWeek('2026-08-10')).toBe(1);
expect(isoDayOfWeek('2026-08-15')).toBe(6);
expect(isoDayOfWeek('2026-08-16')).toBe(7);
});
});
describe('paramètre d’URL', () => {
it('fait un aller-retour', () => {
const month = { year: 2026, month: 8 };
expect(formatMonthParam(month)).toBe('2026-08');
expect(parseMonthParam('2026-08')).toEqual(month);
});
it('rejette ce qui n’est pas un mois', () => {
expect(parseMonthParam('2026-13')).toBeNull();
expect(parseMonthParam('2026-00')).toBeNull();
expect(parseMonthParam('août')).toBeNull();
expect(parseMonthParam(undefined)).toBeNull();
});
});
describe('libellés', () => {
it('nomme le mois en français', () => {
expect(monthLabel({ year: 2026, month: 8 })).toBe('août 2026');
});
it('déduit le mois d’une date', () => {
expect(monthOf(new Date('2026-08-12T00:00:00Z'))).toEqual({
year: 2026,
month: 8,
});
});
});
+215
View File
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest';
import {
dayHeadings,
formatWeekParam,
isoWeekOf,
mondayOfIsoWeek,
nextIsoWeek,
offsetAt,
parseWeekParam,
previousIsoWeek,
weekBounds,
weekDates,
weekLabel,
weeksInIsoYear,
zonedClock,
zonedDate,
zonedMidnight,
} from '@/domain/planning/week';
const utc = (iso: string) => new Date(iso);
describe('isoWeekOf', () => {
it('numérote une semaine ordinaire', () => {
expect(isoWeekOf(utc('2026-08-12T00:00:00Z'))).toEqual({
isoYear: 2026,
isoWeek: 33,
});
});
it('rattache le 1ᵉʳ janvier à l’année ISO précédente quand la norme l’exige', () => {
// 1ᵉʳ janvier 2027 est un vendredi : il appartient à la semaine 53 de 2026.
expect(isoWeekOf(utc('2027-01-01T00:00:00Z'))).toEqual({
isoYear: 2026,
isoWeek: 53,
});
});
it('rattache fin décembre à l’année ISO suivante quand la norme l’exige', () => {
// 29 décembre 2025 est un lundi : semaine 1 de 2026.
expect(isoWeekOf(utc('2025-12-29T00:00:00Z'))).toEqual({
isoYear: 2026,
isoWeek: 1,
});
});
it('reconnaît les années à 53 semaines', () => {
expect(weeksInIsoYear(2026)).toBe(53);
expect(weeksInIsoYear(2025)).toBe(52);
});
});
describe('mondayOfIsoWeek', () => {
it('est l’inverse de isoWeekOf', () => {
for (let week = 1; week <= 52; week += 1) {
const monday = mondayOfIsoWeek({ isoYear: 2026, isoWeek: week });
expect(monday.getUTCDay()).toBe(1);
expect(isoWeekOf(monday)).toEqual({ isoYear: 2026, isoWeek: week });
}
});
it('place la semaine 1 de 2026 au 29 décembre 2025', () => {
expect(
mondayOfIsoWeek({ isoYear: 2026, isoWeek: 1 }).toISOString().slice(0, 10),
).toBe('2025-12-29');
});
});
describe('navigation de semaine', () => {
it('recule sur l’année précédente en respectant sa longueur', () => {
// 2026 compte 53 semaines : reculer depuis la 1 de 2027 doit donner 53,
// pas 52. Une erreur ici fait disparaître une semaine de planning par an.
expect(previousIsoWeek({ isoYear: 2027, isoWeek: 1 })).toEqual({
isoYear: 2026,
isoWeek: 53,
});
expect(previousIsoWeek({ isoYear: 2026, isoWeek: 1 })).toEqual({
isoYear: 2025,
isoWeek: 52,
});
});
it('avance au-delà de la dernière semaine', () => {
expect(nextIsoWeek({ isoYear: 2026, isoWeek: 53 })).toEqual({
isoYear: 2027,
isoWeek: 1,
});
expect(nextIsoWeek({ isoYear: 2025, isoWeek: 52 })).toEqual({
isoYear: 2026,
isoWeek: 1,
});
});
it('fait un aller-retour sans dérive sur toute une année', () => {
let week = { isoYear: 2026, isoWeek: 1 };
for (let step = 0; step < 60; step += 1) week = nextIsoWeek(week);
for (let step = 0; step < 60; step += 1) week = previousIsoWeek(week);
expect(week).toEqual({ isoYear: 2026, isoWeek: 1 });
});
});
describe('weekDates', () => {
it('donne sept jours consécutifs du lundi au dimanche', () => {
expect(weekDates({ isoYear: 2026, isoWeek: 33 })).toEqual([
'2026-08-10',
'2026-08-11',
'2026-08-12',
'2026-08-13',
'2026-08-14',
'2026-08-15',
'2026-08-16',
]);
});
});
describe('fuseau horaire', () => {
it('mesure le décalage de Paris en hiver et en été', () => {
expect(offsetAt(utc('2026-01-15T12:00:00Z'), 'Europe/Paris')).toBe(
3_600_000,
);
expect(offsetAt(utc('2026-07-15T12:00:00Z'), 'Europe/Paris')).toBe(
7_200_000,
);
});
it('place minuit local au bon instant absolu', () => {
expect(zonedMidnight('2026-08-10', 'Europe/Paris').toISOString()).toBe(
'2026-08-09T22:00:00.000Z',
);
expect(zonedMidnight('2026-01-05', 'Europe/Paris').toISOString()).toBe(
'2026-01-04T23:00:00.000Z',
);
});
it('tient au jour même du changement d’heure', () => {
// 25 octobre 2026 : retour à l'heure d'hiver. Minuit local est encore en
// UTC+2 ; une seule passe de correction donnerait une heure de trop.
expect(zonedMidnight('2026-10-25', 'Europe/Paris').toISOString()).toBe(
'2026-10-24T22:00:00.000Z',
);
expect(zonedMidnight('2026-03-29', 'Europe/Paris').toISOString()).toBe(
'2026-03-28T23:00:00.000Z',
);
});
it('mesure une semaine de changement d’heure à sa vraie durée', () => {
// La semaine du passage à l'heure d'hiver dure 169 h, pas 168. Un calcul
// en heures murales la croirait normale et fausserait tout compteur qui
// s'appuierait sur ses bornes.
const autumn = weekBounds({ isoYear: 2026, isoWeek: 43 }, 'Europe/Paris');
expect((autumn.to.getTime() - autumn.from.getTime()) / 3_600_000).toBe(169);
const spring = weekBounds({ isoYear: 2026, isoWeek: 13 }, 'Europe/Paris');
expect((spring.to.getTime() - spring.from.getTime()) / 3_600_000).toBe(167);
});
it('restitue la date et l’heure locales d’un instant', () => {
// 22 h UTC le 9 août, c'est déjà le 10 août à Paris : le créneau de lundi
// matin doit se ranger dans la colonne du lundi.
const instant = utc('2026-08-09T22:00:00Z');
expect(zonedDate(instant, 'Europe/Paris')).toBe('2026-08-10');
expect(zonedClock(instant, 'Europe/Paris')).toBe('00:00');
expect(zonedClock(utc('2026-08-10T07:30:00Z'), 'Europe/Paris')).toBe(
'09:30',
);
});
});
describe('libellés', () => {
it('compose les en-têtes de colonnes', () => {
expect(dayHeadings({ isoYear: 2026, isoWeek: 33 })).toEqual([
'Lun. 10',
'Mar. 11',
'Mer. 12',
'Jeu. 13',
'Ven. 14',
'Sam. 15',
'Dim. 16',
]);
});
it('compose le libellé de semaine', () => {
expect(weekLabel({ isoYear: 2026, isoWeek: 33 })).toBe(
'Semaine 33 · 10 – 16 août 2026',
);
});
it('nomme les deux mois quand la semaine est à cheval', () => {
expect(weekLabel({ isoYear: 2026, isoWeek: 31 })).toBe(
'Semaine 31 · 27 juillet – 2 août 2026',
);
});
});
describe('paramètre d’URL', () => {
it('fait un aller-retour', () => {
const week = { isoYear: 2026, isoWeek: 7 };
expect(parseWeekParam(formatWeekParam(week))).toEqual(week);
expect(formatWeekParam(week)).toBe('2026-W07');
});
it('rejette ce qui n’est pas une semaine', () => {
// Le paramètre vient de l'URL : il est fourni par le visiteur, donc
// toujours suspect. Une semaine 54 ne doit pas atteindre la base.
expect(parseWeekParam('2026-W54')).toBeNull();
expect(parseWeekParam('2026-W00')).toBeNull();
expect(parseWeekParam('août')).toBeNull();
expect(parseWeekParam(undefined)).toBeNull();
});
it('accepte la 53ᵉ semaine d’une année longue', () => {
expect(parseWeekParam('2026-W53')).toEqual({ isoYear: 2026, isoWeek: 53 });
expect(parseWeekParam('2025-W53')).toBeNull();
});
});