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

+82 -25
View File
@@ -1,48 +1,105 @@
import { DayTimeline, type DayLane } from '@/components/planning/DayTimeline';
import Link from 'next/link';
import { DayTimeline } from '@/components/planning/DayTimeline';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { UNASSIGNED_ROW, WEEK_ROWS } from '@/lib/demo/semaine';
import { getDayBoard } from '@/server/planning/queries';
export const metadata = { title: 'Planning · mercredi 12 août · PlanFlow' };
export const metadata = { title: 'Planning · jour · PlanFlow' };
/** Mercredi = index 2 dans la semaine de démonstration. */
const DAY_INDEX = 2;
interface PageProps {
searchParams: Promise<{ jour?: string; etablissement?: string }>;
}
export default function JourPage() {
const lanes: DayLane[] = WEEK_ROWS.map((row) => ({
employee: row.employee,
shifts: row.days[DAY_INDEX] ?? [],
})).filter((lane) => lane.shifts.length > 0);
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const unassigned = UNASSIGNED_ROW.days[DAY_INDEX] ?? [];
if (unassigned.length > 0) {
lanes.push({
employee: UNASSIGNED_ROW.employee,
shifts: unassigned,
unassigned: true,
});
export default async function JourPage({ searchParams }: PageProps) {
const params = await searchParams;
const isoDate =
params.jour && ISO_DATE.test(params.jour)
? params.jour
: new Date().toISOString().slice(0, 10);
const board = await getDayBoard(isoDate, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Planning · jour"
subtitle="Aucun établissement n'est encore créé."
/>
</PageBody>
);
}
const href = (jour: string, etablissement = board.location.id) =>
`/planning/jour?jour=${jour}&etablissement=${etablissement}`;
const present = board.lanes.filter((lane) => !lane.unassigned).length;
const open = board.lanes.find((lane) => lane.unassigned)?.shifts.length ?? 0;
return (
<PageBody>
<PageHeader
title="Planning · mercredi 12 août"
subtitle="Nantes Atlantis · amplitude 06:00 – 21:00"
title={`Planning · ${board.label}`}
subtitle={`${board.location.name} · amplitude ${String(board.fromHour).padStart(2, '0')}:00 – ${String(board.toHour % 24).padStart(2, '0')}:00`}
actions={
<>
<Button>Imprimer la journée</Button>
<Button variant="primary">Ajouter un créneau</Button>
<Link
href={href(board.previousDate)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Jour précédent
</Link>
<Link
href={href(board.nextDate)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Jour suivant →
</Link>
</>
}
/>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="accent">{lanes.length} salariés présents</Badge>
<Badge tone="warn">Creux de couverture à 13 h</Badge>
{board.locations.map((location) => (
<Link
key={location.id}
href={href(board.isoDate, location.id)}
aria-current={
location.id === board.location.id ? 'page' : undefined
}
className={
location.id === board.location.id
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
}
>
{location.name}
</Link>
))}
<Badge tone="accent">
{present} salarié{present > 1 ? 's' : ''} présent
{present > 1 ? 's' : ''}
</Badge>
{open > 0 ? (
<Badge tone="warn">
{open} besoin{open > 1 ? 's' : ''} non couvert{open > 1 ? 's' : ''}
</Badge>
) : null}
</div>
<DayTimeline lanes={lanes} fromHour={6} toHour={21} />
{board.lanes.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Personne n’est planifié ce jour-là.
</p>
) : (
<DayTimeline
lanes={board.lanes}
fromHour={board.fromHour}
toHour={board.toHour}
/>
)}
</PageBody>
);
}
+100 -18
View File
@@ -1,38 +1,120 @@
import { WeekGrid } from '@/components/planning/WeekGrid';
import Link from 'next/link';
import { TeamSection } from '@/components/planning/TeamSection';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { can } from '@/domain/access/authorize';
import { formatMinutes } from '@/domain/counters/week';
import { isoWeekOf, parseWeekParam } from '@/domain/planning/week';
import { POSTE_CODES, POSTE_LABELS, posteTokens } from '@/lib/design/postes';
import { UNASSIGNED_ROW, WEEK_DAYS, WEEK_LABEL, WEEK_ROWS } from '@/lib/demo/semaine';
import { requireSession } from '@/server/context';
import { getWeekBoard } from '@/server/planning/queries';
export const metadata = { title: 'Planning · semaine 33 · PlanFlow' };
export const metadata = { title: 'Planning · semaine · PlanFlow' };
interface PageProps {
searchParams: Promise<{ semaine?: string; etablissement?: string }>;
}
export default async function SemainePage({ searchParams }: PageProps) {
const params = await searchParams;
const session = await requireSession();
// Semaine courante par défaut : ouvrir le planning sur une semaine arbitraire
// obligerait à naviguer avant de voir quoi que ce soit d'utile.
const week = parseWeekParam(params.semaine) ?? isoWeekOf(new Date());
const board = await getWeekBoard(week, params.etablissement);
if (!board) {
return (
<PageBody>
<PageHeader
title="Planning"
subtitle="Aucun établissement n'est encore créé."
/>
<p className="text-sm text-ink-2">
Créez un établissement dans{' '}
<Link href="/reglages/etablissements" className="underline">
Réglages · Établissements
</Link>{' '}
pour commencer à planifier.
</p>
</PageBody>
);
}
const canEdit = can(session.actor, 'planning.create');
const canPublish = can(session.actor, 'planning.publish');
const href = (semaine: string, etablissement = board.location.id) =>
`/planning/semaine?semaine=${semaine}&etablissement=${etablissement}`;
export default function SemainePage() {
return (
<PageBody>
<PageHeader
title="Planning · semaine 33"
subtitle={`Nantes Atlantis · ${WEEK_LABEL.split('·')[1]?.trim()} · ${WEEK_ROWS.length} salariés planifiés`}
title={`Planning · semaine ${week.isoWeek}`}
subtitle={`${board.location.name} · ${board.label.split('·')[1]?.trim()} · ${formatMinutes(board.totals.plannedMinutes)} planifiées`}
actions={
<>
<Button>Dupliquer S‑32</Button>
<Button>Imprimer</Button>
<Button variant="primary">Publier la semaine</Button>
<Link
href={href(board.previousParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
← Semaine précédente
</Link>
<Link
href={href(board.nextParam)}
className="flex h-8 items-center rounded-2 border border-line-3 px-3 text-sm text-ink-1 hover:bg-surface-2"
>
Semaine suivante →
</Link>
</>
}
/>
<div className="flex flex-wrap items-center gap-2">
<Badge tone="warn">2 alertes de convention</Badge>
<Badge tone="info">Samedi non publié</Badge>
<Badge tone="neutral">5 besoins non couverts</Badge>
{board.locations.map((location) => (
<Link
key={location.id}
href={href(board.weekParam, location.id)}
aria-current={
location.id === board.location.id ? 'page' : undefined
}
className={
location.id === board.location.id
? 'rounded-2 border border-accent bg-accent-soft px-2.5 py-1 text-xs font-medium text-accent-soft-ink'
: 'rounded-2 border border-line-2 px-2.5 py-1 text-xs text-ink-2 hover:bg-surface-2'
}
>
{location.name}
</Link>
))}
{board.totals.unassigned > 0 ? (
<Badge tone="warn">
{board.totals.unassigned} besoin
{board.totals.unassigned > 1 ? 's' : ''} non couvert
{board.totals.unassigned > 1 ? 's' : ''}
</Badge>
) : null}
</div>
<WeekGrid
days={WEEK_DAYS}
rows={WEEK_ROWS}
unassignedRow={UNASSIGNED_ROW}
/>
{board.sections.length === 0 ? (
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Cet établissement n’a pas encore d’équipe.
</p>
) : null}
{board.sections.map((section) => (
<TeamSection
key={section.teamId}
section={section}
days={board.headings}
dates={board.dates}
labels={board.labels}
weekParam={board.weekParam}
canEdit={canEdit}
canPublish={canPublish}
/>
))}
<PosteLegend />
</PageBody>