WP-04: planning sur données réelles — grille semaine, vue jour, publication par équipe

Le planning quitte le module de démonstration : la grille lit `WeeklySchedule`
et `Shift`, et les quatre derniers fichiers de `src/lib/demo/` qui portaient des
salariés fictifs disparaissent.

Modèle
- `TeamMember` : rattachement d'un salarié à une équipe, distinct de
  `MembershipScope` (qui dit ce qu'un manager a le droit de voir). Sans lui, un
  salarié sans créneau n'apparaîtrait pas dans la grille — c'est-à-dire l'état
  de départ de toute semaine en construction.
- RLS sur `WeeklySchedule`, `Shift`, `Rest`, `DailyNote` et `TeamMember`.

Temps
- `src/domain/planning/week.ts` : repérage par couple année ISO + semaine ISO,
  jamais par date de début. Le lundi 29 décembre 2025 appartient à la semaine 1
  de 2026 ; une clé fondée sur la date ferait apparaître deux semaines 1.
- `zonedInstant` / `zonedMidnight` corrigent le décalage mesuré **à l'instant
  visé**. Le 29 mars 2026, minuit est en UTC+1 et 09 h en UTC+2 : ajouter neuf
  heures à minuit donnerait 10 h locales.
- La semaine du retour à l'heure d'hiver dure 169 h, celle du passage à l'heure
  d'été 167 — vérifié par test.

Écritures
- Création, modification, suppression de créneau ; publication et dépublication
  **par équipe**, avec verrou optimiste sur `version` : deux managers sur la
  même grille est le cas normal, pas l'exception.
- Chevauchement refusé en transaction, pas seulement dans le formulaire.
- Modifier une semaine publiée exige `planning.edit_published`, capacité que le
  rôle manager n'a pas : un salarié a organisé sa semaine sur ce qu'il a lu.
- Toute mutation laisse une entrée d'audit ; la suppression écrit sa trace
  **avant** l'effacement, sinon l'état supprimé serait perdu.

Lecture
- `planning.view_unpublished` filtre en base : sans cette capacité, les
  brouillons ne sont pas chargés du tout. Un test vérifie que les horaires
  n'apparaissent pas dans le HTML servi — un masquage CSS les y laisserait.
- Vue jour reconstruite sur les mêmes données, amplitude déduite de la journée
  réelle plutôt que figée à 06 h–21 h.

Vérification
- 26 tests unitaires sur le repérage des semaines et la mise en grille.
- Parcours e2e : poser un créneau, refus de chevauchement, publier, dépublier ;
  et ce que voient un salarié et un manager sur la même semaine.
- `scripts/dev-db.sh` : la base de développement est éphémère dans cet
  environnement, la remonter ne doit pas être une redécouverte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
Claude committed 2026-08-07 23:43:48 +00:00
1 parent e9d459366d
commit 023f236881
27 files changed
+3373 -674

No files matched your search

+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])
}