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

+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>
);
+349
View File
@@ -0,0 +1,349 @@
'use client';
import { useActionState, useEffect, useState } from 'react';
import { WeekGrid } from '@/components/planning/WeekGrid';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { formatMinutes } from '@/domain/counters/week';
import { displayName, type BoardRow } from '@/domain/planning/board';
import {
createShiftAction,
deleteShiftAction,
publishWeekAction,
unpublishWeekAction,
type PlanningActionState,
} from '@/server/planning/actions';
import type { BoardLabel, BoardSection } from '@/server/planning/queries';
export interface TeamSectionProps {
section: BoardSection;
days: string[];
dates: string[];
labels: BoardLabel[];
weekParam: string;
/** Faux quand la capacité de modification manque : la grille reste lisible. */
canEdit: boolean;
canPublish: boolean;
}
const empty: PlanningActionState = {};
/**
* Une équipe, une grille, un état de publication.
*
* La publication est **par équipe** : l'audit montre un bouton « Dépublier »
* sur chaque section, pas un pour l'établissement. Le regrouper ferait
* republier la caisse quand on corrige la réserve.
*/
export function TeamSection({
section,
days,
dates,
labels,
weekParam,
canEdit,
canPublish,
}: TeamSectionProps) {
const [target, setTarget] = useState<{
membershipId: string | null;
dayIndex: number;
} | null>(null);
const published = section.status === 'PUBLISHED';
const plannedMinutes = section.rows.reduce(
(total, row) => total + row.counters.plannedMinutes,
0,
);
if (section.hidden) {
// Le brouillon n'est pas seulement masqué : il n'a pas été chargé. Dire
// « pas encore publié » est honnête et n'apprend rien de son contenu.
return (
<section className="flex flex-col gap-3">
<h2 className="text-base font-semibold text-ink-1">
{section.teamName}
</h2>
<p className="rounded-3 border border-line-1 bg-surface p-4 text-sm text-ink-2">
Le planning de cette équipe n’est pas encore publié.
</p>
</section>
);
}
return (
<section className="flex flex-col gap-3">
<header className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-ink-1">
{section.teamName}
</h2>
<Badge tone={published ? 'ok' : 'warn'}>
{published ? 'Publiée' : 'Brouillon'}
</Badge>
<span className="text-xs text-ink-3">
{formatMinutes(plannedMinutes)} planifiées
</span>
</div>
{canPublish ? (
<PublishControl
teamId={section.teamId}
weekParam={weekParam}
version={section.version}
published={published}
/>
) : null}
</header>
<WeekGrid
days={days}
dates={dates}
rows={section.rows}
unassignedRow={section.unassignedRow}
{...(canEdit
? {
cellAction: (row: BoardRow, dayIndex: number) => (
<button
type="button"
onClick={() =>
setTarget({ membershipId: row.membershipId, dayIndex })
}
className="mt-auto rounded-2 border border-dashed border-line-2 px-1 py-0.5 text-micro text-ink-3 opacity-0 transition-opacity hover:border-accent hover:text-accent focus-visible:opacity-100 group-hover/cell:opacity-100"
>
+ Créneau
<span className="sr-only">
{` pour ${row.unassigned ? 'un besoin non couvert' : displayName(row)} le ${days[dayIndex] ?? ''}`}
</span>
</button>
),
shiftAction: (shiftId: string) => (
<DeleteShift shiftId={shiftId} />
),
}
: {})}
/>
{target ? (
<ShiftComposer
teamId={section.teamId}
weekParam={weekParam}
labels={labels}
rows={section.rows}
localDate={dates[target.dayIndex] ?? ''}
dayLabel={days[target.dayIndex] ?? ''}
membershipId={target.membershipId}
onClose={() => setTarget(null)}
/>
) : null}
</section>
);
}
function PublishControl({
teamId,
weekParam,
version,
published,
}: {
teamId: string;
weekParam: string;
version: number;
published: boolean;
}) {
const [state, formAction, pending] = useActionState(
published ? unpublishWeekAction : publishWeekAction,
empty,
);
return (
<form action={formAction} className="flex items-center gap-2">
<input type="hidden" name="teamId" value={teamId} />
<input type="hidden" name="week" value={weekParam} />
{/* Verrou optimiste : la version lue au rendu est renvoyée telle quelle. */}
<input type="hidden" name="expectedVersion" value={version} />
{state.error ? (
<span role="alert" className="text-xs text-danger">
{state.error}
</span>
) : null}
<Button
type="submit"
size="sm"
variant={published ? 'secondary' : 'primary'}
disabled={pending}
>
{published ? 'Dépublier' : 'Publier'}
</Button>
</form>
);
}
function DeleteShift({ shiftId }: { shiftId: string }) {
const [state, formAction, pending] = useActionState(deleteShiftAction, empty);
return (
<form action={formAction} className="absolute -top-1 -right-1">
<input type="hidden" name="shiftId" value={shiftId} />
{/* Volontairement pas désactivé pendant l'envoi : la suppression fait
disparaître le créneau, donc le bouton, et un état désactivé
transitoire ne protège de rien tout en rendant la cible instable. */}
<button
type="submit"
aria-busy={pending}
title={state.error ?? 'Supprimer le créneau'}
className="flex size-4 items-center justify-center rounded-full border border-line-2 bg-surface text-micro leading-none text-ink-3 opacity-0 hover:border-danger hover:text-danger focus-visible:opacity-100 group-hover/cell:opacity-100"
>
<span aria-hidden>×</span>
<span className="sr-only">Supprimer le créneau</span>
</button>
</form>
);
}
function ShiftComposer({
teamId,
weekParam,
labels,
rows,
localDate,
dayLabel,
membershipId,
onClose,
}: {
teamId: string;
weekParam: string;
labels: BoardLabel[];
rows: BoardRow[];
localDate: string;
dayLabel: string;
membershipId: string | null;
onClose: () => void;
}) {
const [state, formAction, pending] = useActionState(createShiftAction, empty);
// La grille est déjà revalidée côté serveur ; refermer le panneau évite de
// reposter le même créneau par inadvertance.
useEffect(() => {
if (state.ok) onClose();
}, [state.ok, onClose]);
return (
<form
action={formAction}
className="flex flex-wrap items-end gap-3 rounded-3 border border-line-2 bg-surface-2 p-3"
>
<input type="hidden" name="teamId" value={teamId} />
<input type="hidden" name="week" value={weekParam} />
<input type="hidden" name="localDate" value={localDate} />
<Field label="Jour">
<span className="flex h-8 items-center px-1 text-sm text-ink-2">
{dayLabel}
</span>
</Field>
<Field label="Salarié" htmlFor="composer-membership">
<select
id="composer-membership"
name="membershipId"
defaultValue={membershipId ?? ''}
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
<option value="">Besoin non couvert</option>
{rows.map((row) => (
<option key={row.membershipId} value={row.membershipId ?? ''}>
{displayName(row)}
</option>
))}
</select>
</Field>
<Field label="Début" htmlFor="composer-start">
<input
id="composer-start"
name="start"
type="time"
required
defaultValue="09:00"
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
<Field label="Fin" htmlFor="composer-end">
<input
id="composer-end"
name="end"
type="time"
required
defaultValue="17:00"
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
<Field label="Pause (min)" htmlFor="composer-break">
<input
id="composer-break"
name="breakMinutes"
type="number"
min={0}
max={600}
step={5}
defaultValue={0}
className="h-8 w-20 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
/>
</Field>
<Field label="Poste" htmlFor="composer-label">
<select
id="composer-label"
name="labelId"
className="h-8 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1"
>
{labels.map((label) => (
<option key={label.id} value={label.id}>
{label.name}
</option>
))}
</select>
</Field>
<div className="flex items-center gap-2">
<Button type="submit" variant="primary" size="sm" disabled={pending}>
Ajouter
</Button>
<Button type="button" size="sm" onClick={onClose}>
Annuler
</Button>
</div>
{state.error ? (
<p role="alert" className="w-full text-xs text-danger">
{state.error}
</p>
) : null}
</form>
);
}
function Field({
label,
htmlFor,
children,
}: {
label: string;
htmlFor?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label
htmlFor={htmlFor}
className="text-micro font-medium tracking-[0.04em] text-ink-3 uppercase"
>
{label}
</label>
{children}
</div>
);
}
+75 -47
View File
@@ -1,19 +1,22 @@
import { AbsenceBar } from '@/components/planning/AbsenceBar';
import { CounterStrip } from '@/components/planning/CounterStrip';
import { ShiftChip } from '@/components/planning/ShiftChip';
import { computeWeekCounters } from '@/domain/counters/week';
import { cx } from '@/lib/cx';
import {
fullName,
initials,
timeRange,
type DemoWeekRow,
} from '@/lib/demo/types';
displayName,
initialsOf,
type BoardRow,
} from '@/domain/planning/board';
import { cx } from '@/lib/cx';
export interface WeekGridProps {
days: readonly string[];
rows: DemoWeekRow[];
unassignedRow?: DemoWeekRow;
dates: readonly string[];
rows: BoardRow[];
unassignedRow?: BoardRow | null;
/** Rendu de la case d'un jour : sert à greffer l'ajout d'un créneau. */
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
/** Rendu attaché à un créneau : suppression, édition. */
shiftAction?: (shiftId: string) => React.ReactNode;
}
/**
@@ -24,14 +27,41 @@ export interface WeekGridProps {
* majoré et repos compensateur — et les distinguer d'un coup d'œil évite de les
* planifier par inadvertance.
*/
export function WeekGrid({ days, rows, unassignedRow }: WeekGridProps) {
export function WeekGrid({
days,
dates,
rows,
unassignedRow,
cellAction,
shiftAction,
}: WeekGridProps) {
return (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<div className="min-w-[1040px]">
<GridHeader days={days} />
{unassignedRow ? <Row row={unassignedRow} days={days} /> : null}
{unassignedRow ? (
<Row
row={unassignedRow}
days={days}
dates={dates}
{...(cellAction ? { cellAction } : {})}
{...(shiftAction ? { shiftAction } : {})}
/>
) : null}
{rows.length === 0 && !unassignedRow ? (
<p className="px-4 py-6 text-sm text-ink-3">
Aucun salarié rattaché à cette équipe.
</p>
) : null}
{rows.map((row) => (
<Row key={row.employee.id} row={row} days={days} />
<Row
key={row.membershipId ?? 'unassigned'}
row={row}
days={days}
dates={dates}
{...(cellAction ? { cellAction } : {})}
{...(shiftAction ? { shiftAction } : {})}
/>
))}
</div>
</div>
@@ -61,9 +91,21 @@ function GridHeader({ days }: { days: readonly string[] }) {
);
}
function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
function Row({
row,
days,
dates,
cellAction,
shiftAction,
}: {
row: BoardRow;
days: readonly string[];
dates: readonly string[];
cellAction?: (row: BoardRow, dayIndex: number) => React.ReactNode;
shiftAction?: (shiftId: string) => React.ReactNode;
}) {
const counters = computeWeekCounters(row.counters);
const name = row.unassigned ? 'Non assigné' : fullName(row.employee);
const name = row.unassigned ? 'Non assigné' : displayName(row);
return (
<div
@@ -86,7 +128,7 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
aria-hidden
className="flex size-6 items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
>
{initials(row.employee)}
{initialsOf(row)}
</span>
)}
<span className="min-w-0">
@@ -99,57 +141,43 @@ function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
{name}
</span>
<span className="block truncate text-micro text-ink-3">
{row.employee.job}
{row.job}
</span>
</span>
</div>
<CounterStrip
counters={counters}
forfaitJours={row.employee.forfaitJours ?? false}
unassigned={row.unassigned ?? false}
forfaitJours={row.forfaitJours}
unassigned={row.unassigned}
className="mt-1"
/>
</div>
<div className="relative grid flex-1 grid-cols-7">
{row.absence ? (
<AbsenceBar
kind={row.absence.kind}
label={row.absence.label}
duration={`${row.absence.span} j`}
startDay={row.absence.startDay}
span={row.absence.span}
className="top-1.5"
/>
) : null}
{days.map((day, index) => {
const shifts = row.days[index] ?? [];
return (
<div
key={day}
key={dates[index] ?? day}
className={cx(
'flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
'group/cell flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
index > 4 && 'bg-surface-2',
row.absence &&
index >= row.absence.startDay &&
index < row.absence.startDay + row.absence.span &&
'pt-7',
)}
>
{shifts.map((shift, position) => (
<ShiftChip
key={`${day}-${position}`}
poste={shift.poste}
time={timeRange(shift)}
state={shift.state ?? 'published'}
{...(shift.delta ? { delta: shift.delta } : {})}
{...(shift.alert ? { alert: shift.alert } : {})}
{...(shift.breakMinutes
? { breakMinutes: shift.breakMinutes }
: {})}
/>
{shifts.map((shift) => (
<div key={shift.id} className="relative">
<ShiftChip
poste={shift.poste}
time={shift.time}
state={shift.state}
{...(shift.breakMinutes
? { breakMinutes: shift.breakMinutes }
: {})}
/>
{shiftAction?.(shift.id)}
</div>
))}
{cellAction?.(row, index)}
</div>
);
})}