Complete the six application screens

Adds the day timeline, team list, employee record and leave calendar.
The design export shipped data for these but no markup, so their layout
is designed here from the data shapes and the established style; only
Aperçu and the week grid are ports.

The day view carries a per-hour headcount band. Where the week view
answers "who works how much", this one answers "who is on the floor at
14:00", and the band makes coverage gaps visible without reading every
lane.

Two fixes the tests and linter caught:

- The poste derivation variables were missing from globals.css. Hues
  and tiers were ported but not the --post-*-bg/fg/edge that consume
  them, so every shift chip would have rendered colourless. The palette
  test now asserts all 36 exist.
- ThemeToggle synced state in an effect. The document's data-theme
  attribute is the real store — it is set by the inline script before
  React exists — so it now subscribes with useSyncExternalStore instead
  of keeping a copy that is wrong on first render.

Counter tests cover the DST cases in both directions: a 22:00–06:00
shift lasts 7 or 9 hours on the changeover nights, never 8.

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 22:16:52 +00:00
1 parent a24c1b821c
commit 4012f32f7f
12 files changed
+1158 -11

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { EmptyState } from '@/components/ui/Card';
export const metadata = { title: 'Écran à venir · PlanFlow' };
export default async function BientotPage({
searchParams,
}: {
searchParams: Promise<{ ecran?: string }>;
}) {
const { ecran } = await searchParams;
const label = ecran ?? 'Cet écran';
return (
<PageBody>
<PageHeader title={label} subtitle="Nantes Atlantis" />
<div className="rounded-3 border border-dashed border-line-2 bg-surface">
<EmptyState
title="Écran à venir"
description={`« ${label} » fait partie du périmètre de PlanFlow mais n'est pas encore construit. L'entrée reste dans la navigation pour que celle-ci reflète le produit visé plutôt que son état d'avancement.`}
/>
</div>
</PageBody>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader } from '@/components/ui/Card';
import { cx } from '@/lib/cx';
import {
CALENDAR_ABSENCES,
MONTH_LABEL,
MONTH_LENGTH,
MONTH_OFFSET,
PENDING_REQUESTS,
} from '@/lib/demo/conges';
export const metadata = { title: 'Calendrier des absences · PlanFlow' };
const WEEKDAYS = [
'Lundi',
'Mardi',
'Mercredi',
'Jeudi',
'Vendredi',
'Samedi',
'Dimanche',
];
const KIND_STYLES: Record<string, string> = {
cp: 'bg-info-soft text-info-soft-ink border-info',
rtt: 'bg-accent-soft text-accent-soft-ink border-accent',
maladie: 'bg-surface-3 text-ink-2 border-line-3',
'sans-solde': 'bg-surface-2 text-ink-2 border-line-3',
attente: 'bg-warn-soft text-warn-soft-ink border-warn',
};
export default function CongesPage() {
const cells = Array.from({ length: 42 }, (_, index) => {
const dayNumber = index - MONTH_OFFSET + 1;
const inMonth = dayNumber >= 1 && dayNumber <= MONTH_LENGTH;
const items = inMonth
? CALENDAR_ABSENCES.filter(
(absence) => dayNumber >= absence.from && dayNumber <= absence.to,
)
: [];
return { index, dayNumber, inMonth, items, weekend: index % 7 > 4 };
});
return (
<PageBody>
<PageHeader
title="Calendrier des absences"
subtitle={`${MONTH_LABEL} · Nantes Atlantis · ${PENDING_REQUESTS.length} demandes en attente`}
actions={
<>
<Button>Exporter</Button>
<Button variant="primary">Saisir une absence</Button>
</>
}
/>
<Card>
<div className="grid grid-cols-7 border-b border-line-2 bg-surface-2">
{WEEKDAYS.map((day) => (
<div
key={day}
className="border-r border-line-1 px-2 py-2 text-center text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase last:border-r-0"
>
{day}
</div>
))}
</div>
<div className="grid grid-cols-7">
{cells.map((cell) => (
<div
key={cell.index}
className={cx(
'min-h-24 border-r border-b border-line-1 p-1.5 last:border-r-0',
!cell.inMonth && 'bg-surface-2',
cell.inMonth && cell.weekend && 'bg-surface-2',
)}
>
<span
className={cx(
'tnum block text-micro font-semibold',
cell.inMonth ? 'text-ink-1' : 'text-ink-3',
)}
>
{cell.inMonth ? cell.dayNumber : ''}
</span>
<ul className="mt-1 flex flex-col gap-0.5">
{cell.items.slice(0, 3).map((absence) => (
<li
key={`${absence.who}-${absence.type}`}
className={cx(
'truncate rounded-1 border px-1 py-px text-micro',
KIND_STYLES[absence.kind],
)}
title={`${absence.who} · ${absence.type}`}
>
{absence.who.split(' ')[0]} · {absence.type}
</li>
))}
{cell.items.length > 3 ? (
<li className="px-1 text-micro text-ink-3">
+{cell.items.length - 3}
</li>
) : null}
</ul>
</div>
))}
</div>
</Card>
<Card>
<span id="attente" />
<CardHeader
title="Demandes en attente"
badge={<Badge tone="warn">{PENDING_REQUESTS.length}</Badge>}
/>
<ul>
{PENDING_REQUESTS.map((request) => (
<li
key={`${request.who}-${request.range}`}
className="flex flex-wrap items-center gap-3 border-b border-line-1 px-4 py-3 last:border-b-0"
>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{request.who} · {request.type}
</span>
<span className="block text-micro text-ink-3">
{request.note}
</span>
</span>
<span className="tnum flex-none text-xs text-ink-2">
{request.range}
</span>
<Badge tone="neutral">{request.days}</Badge>
<span className="flex flex-none gap-2">
<Button size="sm">Refuser</Button>
<Button size="sm" variant="primary">
Accepter
</Button>
</span>
</li>
))}
</ul>
</Card>
</PageBody>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { notFound } from 'next/navigation';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { Card, CardHeader } from '@/components/ui/Card';
import { POSTE_LABELS, posteShort, posteTokens } from '@/lib/design/postes';
import { EMPLOYEES, findEmployee } from '@/lib/demo/equipe';
import {
FICHE_COUNTERS,
FICHE_DOCUMENTS,
FICHE_SHIFTS,
} from '@/lib/demo/fiche';
import { fullName, initials } from '@/lib/demo/types';
export function generateStaticParams() {
return EMPLOYEES.map((employee) => ({ id: employee.id }));
}
export default async function FichePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const employee = findEmployee(id);
if (!employee) notFound();
return (
<PageBody>
<PageHeader
title={fullName(employee)}
subtitle={`${employee.job} · Nantes Atlantis · entrée le ${employee.since}`}
actions={
<>
<Button>Documents</Button>
<Button variant="primary">Modifier le contrat</Button>
</>
}
/>
<div className="flex flex-wrap items-center gap-3 rounded-3 border border-line-1 bg-surface p-4">
<span
aria-hidden
className="flex size-11 flex-none items-center justify-center rounded-full bg-surface-3 text-sm font-semibold text-ink-2"
>
{initials(employee)}
</span>
<div className="min-w-0">
<p className="text-sm font-medium">{employee.contract}</p>
<p className="text-micro text-ink-3">
Poste principal · {POSTE_LABELS[employee.poste]}
</p>
</div>
<span className="flex-1" />
{employee.forfaitJours ? (
<Badge tone="accent">Forfait jours · 218 j</Badge>
) : null}
<ul className="flex flex-wrap items-center gap-2">
{FICHE_COUNTERS.map((counter) => (
<li
key={counter.label}
className="rounded-2 border border-line-1 bg-surface-2 px-3 py-1.5"
>
<span className="block text-micro text-ink-3">
{counter.label}
</span>
<span
className={`tnum block text-sm font-semibold ${
counter.tone === 'warn' ? 'text-warn-soft-ink' : 'text-ink-1'
}`}
>
{counter.value}
</span>
</li>
))}
</ul>
</div>
<div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(400px,1fr))]">
<Card>
<CardHeader
title="Créneaux de la semaine"
action={
<Button size="sm" variant="ghost">
Voir le planning
</Button>
}
/>
<ul>
{FICHE_SHIFTS.map((shift, index) => {
const tokens = posteTokens(shift.poste);
return (
<li
key={`${shift.day}-${index}`}
className="flex items-center gap-3 border-b border-line-1 px-4 py-2.5 last:border-b-0"
>
<span className="tnum w-28 flex-none text-xs text-ink-2">
{shift.day}
</span>
<span
className="flex-none rounded-2 px-1.5 py-0.5 text-micro font-semibold tracking-wide"
style={{
background: tokens.bg,
color: tokens.fg,
border: `1px solid ${tokens.edge}`,
}}
title={POSTE_LABELS[shift.poste]}
>
{posteShort(shift.poste)}
</span>
<span className="tnum flex-1 text-sm">{shift.time}</span>
<span className="tnum flex-none text-xs text-ink-2">
{shift.worked}
</span>
<Badge tone={shift.tone}>{shift.state}</Badge>
</li>
);
})}
</ul>
</Card>
<Card>
<CardHeader title="Documents" />
<ul>
{FICHE_DOCUMENTS.map((document) => (
<li
key={document.label}
className="flex items-center gap-3 border-b border-line-1 px-4 py-3 last:border-b-0"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{document.label}
</span>
<span className="tnum block text-micro text-ink-3">
{document.date}
</span>
</span>
<Badge tone={document.tone}>{document.state}</Badge>
</li>
))}
</ul>
</Card>
</div>
</PageBody>
);
}
+118
View File
@@ -0,0 +1,118 @@
import Link from 'next/link';
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
import { Badge, type Tone } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { POSTE_LABELS, posteShort, posteTokens } from '@/lib/design/postes';
import { EMPLOYEES } from '@/lib/demo/equipe';
import { fullName, initials } from '@/lib/demo/types';
export const metadata = { title: 'Équipe · PlanFlow' };
const STATUS: Record<string, { label: string; tone: Tone }> = {
actif: { label: 'Actif', tone: 'ok' },
essai: { label: 'Période d’essai', tone: 'info' },
sortie: { label: 'Sortie prévue', tone: 'warn' },
};
export default function EquipePage() {
const actifs = EMPLOYEES.filter((e) => e.status !== 'sortie').length;
const essai = EMPLOYEES.filter((e) => e.status === 'essai').length;
return (
<PageBody>
<PageHeader
title="Équipe"
subtitle={`Nantes Atlantis · ${actifs} salariés actifs, ${essai} en période d’essai`}
actions={
<>
<Button>Registre unique du personnel</Button>
<Button variant="primary">Nouvelle embauche</Button>
</>
}
/>
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<table className="w-full min-w-[880px] border-collapse text-sm">
<thead>
<tr className="border-b border-line-2 bg-surface-2 text-left text-micro font-semibold tracking-[0.06em] text-ink-3 uppercase">
<th scope="col" className="px-4 py-2.5">
Collaborateur
</th>
<th scope="col" className="px-4 py-2.5">
Poste principal
</th>
<th scope="col" className="px-4 py-2.5">
Contrat
</th>
<th scope="col" className="px-4 py-2.5">
Entrée
</th>
<th scope="col" className="px-4 py-2.5">
Statut
</th>
</tr>
</thead>
<tbody>
{EMPLOYEES.map((employee) => {
const tokens = posteTokens(employee.poste);
const status = STATUS[employee.status] ?? STATUS.actif!;
return (
<tr
key={employee.id}
className="border-b border-line-1 last:border-b-0 hover:bg-surface-2"
>
<td className="px-4 py-2.5">
<Link
href={`/equipe/${employee.id}`}
className="flex items-center gap-2.5 rounded-2"
>
<span
aria-hidden
className="flex size-7 flex-none items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
>
{initials(employee)}
</span>
<span className="min-w-0">
<span className="block truncate font-medium">
{fullName(employee)}
</span>
<span className="block truncate text-micro text-ink-3">
{employee.job}
</span>
</span>
</Link>
</td>
<td className="px-4 py-2.5">
<span
className="inline-flex items-center gap-1.5 rounded-2 px-2 py-0.5 text-micro"
style={{
background: tokens.bg,
color: tokens.fg,
border: `1px solid ${tokens.edge}`,
}}
>
<span className="font-semibold tracking-wide">
{posteShort(employee.poste)}
</span>
{POSTE_LABELS[employee.poste]}
</span>
</td>
<td className="tnum px-4 py-2.5 text-ink-2">
{employee.contract}
</td>
<td className="tnum px-4 py-2.5 text-ink-2">
{employee.since}
</td>
<td className="px-4 py-2.5">
<Badge tone={status.tone}>{status.label}</Badge>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</PageBody>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { DayTimeline, type DayLane } 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';
export const metadata = { title: 'Planning · mercredi 12 août · PlanFlow' };
/** Mercredi = index 2 dans la semaine de démonstration. */
const DAY_INDEX = 2;
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 unassigned = UNASSIGNED_ROW.days[DAY_INDEX] ?? [];
if (unassigned.length > 0) {
lanes.push({
employee: UNASSIGNED_ROW.employee,
shifts: unassigned,
unassigned: true,
});
}
return (
<PageBody>
<PageHeader
title="Planning · mercredi 12 août"
subtitle="Nantes Atlantis · amplitude 06:00 – 21:00"
actions={
<>
<Button>Imprimer la journée</Button>
<Button variant="primary">Ajouter un créneau</Button>
</>
}
/>
<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>
</div>
<DayTimeline lanes={lanes} fromHour={6} toHour={21} />
</PageBody>
);
}
+111
View File
@@ -142,6 +142,117 @@
--post-net-t: 0;
--post-for-h: 68;
--post-for-t: 0.045;
/* Dérivation : une seule formule pour les douze postes. Ajouter un poste
revient à déclarer sa teinte et son palier, puis ces trois lignes. */
--post-cai-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-cai-t)) var(--chip-bg-c)
var(--post-cai-h)
);
--post-cai-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-cai-h));
--post-cai-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-cai-t)) var(--chip-edge-c)
var(--post-cai-h)
);
--post-vte-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-vte-t)) var(--chip-bg-c)
var(--post-vte-h)
);
--post-vte-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-vte-h));
--post-vte-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-vte-t)) var(--chip-edge-c)
var(--post-vte-h)
);
--post-res-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-res-t)) var(--chip-bg-c)
var(--post-res-h)
);
--post-res-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-res-h));
--post-res-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-res-t)) var(--chip-edge-c)
var(--post-res-h)
);
--post-acc-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-acc-t)) var(--chip-bg-c)
var(--post-acc-h)
);
--post-acc-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-acc-h));
--post-acc-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-acc-t)) var(--chip-edge-c)
var(--post-acc-h)
);
--post-cab-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-cab-t)) var(--chip-bg-c)
var(--post-cab-h)
);
--post-cab-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-cab-h));
--post-cab-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-cab-t)) var(--chip-edge-c)
var(--post-cab-h)
);
--post-rss-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-rss-t)) var(--chip-bg-c)
var(--post-rss-h)
);
--post-rss-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-rss-h));
--post-rss-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-rss-t)) var(--chip-edge-c)
var(--post-rss-h)
);
--post-inv-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-inv-t)) var(--chip-bg-c)
var(--post-inv-h)
);
--post-inv-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-inv-h));
--post-inv-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-inv-t)) var(--chip-edge-c)
var(--post-inv-h)
);
--post-liv-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-liv-t)) var(--chip-bg-c)
var(--post-liv-h)
);
--post-liv-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-liv-h));
--post-liv-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-liv-t)) var(--chip-edge-c)
var(--post-liv-h)
);
--post-enc-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-enc-t)) var(--chip-bg-c)
var(--post-enc-h)
);
--post-enc-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-enc-h));
--post-enc-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-enc-t)) var(--chip-edge-c)
var(--post-enc-h)
);
--post-clc-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-clc-t)) var(--chip-bg-c)
var(--post-clc-h)
);
--post-clc-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-clc-h));
--post-clc-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-clc-t)) var(--chip-edge-c)
var(--post-clc-h)
);
--post-net-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-net-t)) var(--chip-bg-c)
var(--post-net-h)
);
--post-net-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-net-h));
--post-net-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-net-t)) var(--chip-edge-c)
var(--post-net-h)
);
--post-for-bg: oklch(
calc(var(--chip-bg-l) + var(--tier-dir) * var(--post-for-t)) var(--chip-bg-c)
var(--post-for-h)
);
--post-for-fg: oklch(var(--chip-fg-l) var(--chip-fg-c) var(--post-for-h));
--post-for-edge: oklch(
calc(var(--chip-edge-l) + var(--tier-dir) * var(--post-for-t)) var(--chip-edge-c)
var(--post-for-h)
);
}
/* Thème sombre : redéfinit les mêmes variables. --tier-dir s'inverse pour que