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
+176
View File
@@ -0,0 +1,176 @@
import { posteShort, posteTokens } from '@/lib/design/postes';
import { cx } from '@/lib/cx';
import { clock, initials, type DemoEmployee, type DemoShift } from '@/lib/demo/types';
export interface DayLane {
employee: DemoEmployee;
shifts: DemoShift[];
unassigned?: boolean;
}
export interface DayTimelineProps {
lanes: DayLane[];
/** Amplitude affichée, en heures locales. */
fromHour: number;
toHour: number;
}
/**
* Vue jour : une chronologie horizontale par salarié.
*
* Là où la vue semaine répond à « qui travaille combien », celle-ci répond à
* « qui est présent à 14 h » — d'où la bande d'effectif sous l'échelle, qui
* rend les creux de couverture visibles sans avoir à lire chaque ligne.
*/
export function DayTimeline({ lanes, fromHour, toHour }: DayTimelineProps) {
const span = toHour - fromHour;
const hours = Array.from({ length: span + 1 }, (_, i) => fromHour + i);
const coverage = hours.slice(0, -1).map((hour) => {
const start = hour * 60;
const end = start + 60;
return lanes.filter(
(lane) =>
!lane.unassigned &&
lane.shifts.some((shift) => shift.start < end && shift.end > start),
).length;
});
const peak = Math.max(1, ...coverage);
const offset = (minutes: number) =>
`${((minutes - fromHour * 60) / (span * 60)) * 100}%`;
return (
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
<div className="min-w-[980px]">
<div className="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">
Salarié
</div>
<div className="relative flex-1">
{hours.map((hour) => (
<span
key={hour}
className="tnum absolute top-2 -translate-x-1/2 text-micro text-ink-3"
style={{ left: offset(hour * 60) }}
>
{String(hour).padStart(2, '0')}h
</span>
))}
<div className="h-9" />
</div>
</div>
<div className="flex border-b border-line-1 bg-surface-2/60">
<div className="w-56 flex-none border-r border-line-1 px-3 py-1.5 text-micro text-ink-2">
Effectif présent
</div>
<div className="flex flex-1">
{coverage.map((count, index) => (
<div
key={hours[index]}
className="flex flex-1 items-end justify-center border-r border-line-1 py-1 last:border-r-0"
title={`${count} présent${count > 1 ? 's' : ''} à ${String(hours[index]).padStart(2, '0')} h`}
>
<span
className="tnum w-full rounded-1 text-center text-micro font-semibold"
style={{
background:
count === 0
? 'var(--color-danger-soft)'
: 'var(--color-accent-soft)',
color:
count === 0
? 'var(--color-danger-soft-ink)'
: 'var(--color-accent-soft-ink)',
opacity: count === 0 ? 1 : 0.35 + (count / peak) * 0.65,
}}
>
{count}
</span>
</div>
))}
</div>
</div>
{lanes.map((lane) => (
<div
key={lane.employee.id}
className={cx(
'flex border-b border-line-1 last:border-b-0',
lane.unassigned && 'bg-surface-2',
)}
>
<div className="flex w-56 flex-none items-center gap-2 border-r border-line-1 px-3 py-2">
<span
aria-hidden
className={cx(
'flex size-6 flex-none items-center justify-center rounded-full text-micro font-semibold',
lane.unassigned
? 'border border-dashed border-line-3 text-ink-3'
: 'bg-surface-3 text-ink-2',
)}
>
{lane.unassigned ? '?' : initials(lane.employee)}
</span>
<span className="min-w-0">
<span
className={cx(
'block truncate text-sm font-medium',
lane.unassigned ? 'text-ink-3' : 'text-ink-1',
)}
>
{lane.unassigned
? 'Non assigné'
: `${lane.employee.firstName} ${lane.employee.lastName}`}
</span>
<span className="block truncate text-micro text-ink-3">
{lane.employee.job}
</span>
</span>
</div>
<div className="relative min-h-[var(--row-h)] flex-1">
{hours.slice(1, -1).map((hour) => (
<span
key={hour}
aria-hidden
className="absolute inset-y-0 w-px bg-line-1"
style={{ left: offset(hour * 60) }}
/>
))}
{lane.shifts.map((shift, index) => {
const tokens = posteTokens(shift.poste);
const ghost = shift.state === 'unassigned';
return (
<div
key={index}
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)})`,
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)}`}
>
<span className="font-semibold tracking-wide">
{posteShort(shift.poste)}
</span>
<span className="tnum truncate opacity-90">
{clock(shift.start)}–{clock(shift.end)}
</span>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
);
}
+24 -11
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useSyncExternalStore } from 'react';
import { Button } from '@/components/ui/Button';
@@ -29,20 +29,34 @@ export const THEME_INIT_SCRIPT = `
})();
`.trim();
function readTheme(): Theme {
if (typeof document === 'undefined') return 'light';
/**
* L'attribut `data-theme` du document est la source de vérité : il est posé par
* le script ci-dessus avant l'hydratation, donc avant que React n'existe.
* On s'y abonne plutôt que d'en tenir une copie, qui serait fausse au premier
* rendu et devrait être rattrapée après coup.
*/
function subscribe(onChange: () => void): () => void {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}
function getSnapshot(): Theme {
return document.documentElement.getAttribute('data-theme') === 'dark'
? 'dark'
: 'light';
}
export function ThemeToggle() {
const [theme, setTheme] = useState<Theme>('light');
/** Le serveur rend toujours le thème clair, que le script corrige aussitôt. */
function getServerSnapshot(): Theme {
return 'light';
}
// Le thème réel est posé par THEME_INIT_SCRIPT avant l'hydratation ; on le
// lit après montage plutôt que de le deviner au rendu serveur, où
// localStorage n'existe pas.
useEffect(() => setTheme(readTheme()), []);
export function ThemeToggle() {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
function toggle() {
const next: Theme = theme === 'dark' ? 'light' : 'dark';
@@ -51,9 +65,8 @@ export function ThemeToggle() {
localStorage.setItem(THEME_STORAGE_KEY, next);
} catch {
// Stockage indisponible (navigation privée) : le thème reste valable
// pour la session, ce qui est préférable à une erreur.
// pour la session, ce qui vaut mieux qu'une erreur.
}
setTheme(next);
}
return (
+58
View File
@@ -0,0 +1,58 @@
import type { AbsenceKind } from '@/components/planning/AbsenceBar';
export interface DemoCalendarAbsence {
who: string;
type: string;
kind: AbsenceKind;
/** Jours du mois, inclusifs. */
from: number;
to: number;
}
export const MONTH_LABEL = 'Août 2026';
/** Le 1er août 2026 tombe un samedi : la grille démarre le lundi 27 juillet. */
export const MONTH_OFFSET = 5;
export const MONTH_LENGTH = 31;
export const CALENDAR_ABSENCES: DemoCalendarAbsence[] = [
{ who: 'Nour Zaidi', type: 'CP', kind: 'cp', from: 10, to: 14 },
{ who: 'Malik Ouedraogo', type: 'Maladie', kind: 'maladie', from: 13, to: 14 },
{ who: 'Rémi Chartier', type: 'RTT', kind: 'rtt', from: 18, to: 19 },
{ who: 'Elsa Tavares', type: 'CP', kind: 'cp', from: 24, to: 28 },
{ who: 'Inès Bakhti', type: 'Sans solde', kind: 'sans-solde', from: 21, to: 21 },
{ who: 'Alix Perron', type: 'Maladie', kind: 'maladie', from: 5, to: 6 },
{ who: 'Chloé Vasseur', type: 'CP', kind: 'cp', from: 26, to: 28 },
];
export interface DemoPendingRequest {
who: string;
type: string;
range: string;
days: string;
note: string;
}
export const PENDING_REQUESTS: DemoPendingRequest[] = [
{
who: 'Nour Zaidi',
type: 'Congés payés',
range: '10 – 14 août',
days: '5 j',
note: 'Solde restant après validation : 9,5 j',
},
{
who: 'Rémi Chartier',
type: 'RTT',
range: '18 – 19 août',
days: '2 j',
note: 'Chevauche un jour de forte affluence',
},
{
who: 'Alix Perron',
type: 'Sans solde',
range: '31 août',
days: '1 j',
note: 'Aucune contrainte de planning',
},
];
+96
View File
@@ -0,0 +1,96 @@
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' },
];
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import {
computeWeekCounters,
deltaTone,
formatDelta,
formatMinutes,
shiftMinutes,
} from '@/domain/counters/week';
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
describe('formatMinutes', () => {
it('formate en heures et minutes sur deux chiffres', () => {
expect(formatMinutes(h(35))).toBe('35 h 00');
expect(formatMinutes(h(8, 5))).toBe('8 h 05');
expect(formatMinutes(0)).toBe('0 h 00');
});
it('utilise le signe moins typographique pour les négatifs', () => {
expect(formatMinutes(-h(1, 30))).toBe('−1 h 30');
});
});
describe('formatDelta', () => {
it('distingue un écart nul d’un manque', () => {
// « 0 h 00 » seul se lit comme une absence de donnée ; le « + » dit que le
// contrat est atteint exactement.
expect(formatDelta(0)).toBe('+0 h 00');
expect(formatDelta(h(2, 55))).toBe('+2 h 55');
expect(formatDelta(-h(0, 5))).toBe('−0 h 05');
});
});
describe('deltaTone', () => {
it('classe le dépassement, le manque et l’exact', () => {
expect(deltaTone(0)).toBe('flat');
expect(deltaTone(1)).toBe('over');
expect(deltaTone(-1)).toBe('under');
});
});
describe('computeWeekCounters', () => {
it('compte les absences dans l’atteinte du contrat', () => {
// Une semaine entière de congés ne doit pas apparaître en manque de 30 h :
// le salarié n'a pas sous-travaillé, il était absent.
const counters = computeWeekCounters({
contractMinutes: h(30),
plannedMinutes: h(12),
absenceMinutes: h(18),
sundaysWorked: 0,
restDays: 5,
});
expect(counters.deltaMinutes).toBe(0);
expect(counters.deltaLabel).toBe('+0 h 00');
expect(counters.tone).toBe('flat');
});
it('signale un dépassement du contrat', () => {
const counters = computeWeekCounters({
contractMinutes: h(35),
plannedMinutes: h(36, 30),
absenceMinutes: 0,
sundaysWorked: 2,
restDays: 2,
});
expect(counters.deltaLabel).toBe('+1 h 30');
expect(counters.tone).toBe('over');
});
it('signale un contrat non atteint', () => {
const counters = computeWeekCounters({
contractMinutes: h(35),
plannedMinutes: h(33, 15),
absenceMinutes: 0,
sundaysWorked: 0,
restDays: 2,
});
expect(counters.deltaLabel).toBe('−1 h 45');
expect(counters.tone).toBe('under');
});
});
describe('shiftMinutes', () => {
it('déduit la pause', () => {
const start = new Date('2026-08-10T09:00:00+02:00');
const end = new Date('2026-08-10T17:00:00+02:00');
expect(shiftMinutes(start, end, 30)).toBe(h(7, 30));
});
it('mesure la durée réelle la nuit du passage à l’heure d’hiver', () => {
// 25 octobre 2026, 03:00 → 02:00 : la nuit dure une heure de plus.
// Un calcul sur l'heure murale dirait 8 h ; l'instant dit 9 h.
const start = new Date('2026-10-24T22:00:00+02:00');
const end = new Date('2026-10-25T06:00:00+01:00');
expect(shiftMinutes(start, end)).toBe(h(9));
});
it('mesure la durée réelle la nuit du passage à l’heure d’été', () => {
// 29 mars 2026, 02:00 → 03:00 : la nuit dure une heure de moins.
const start = new Date('2026-03-28T22:00:00+01:00');
const end = new Date('2026-03-29T06:00:00+02:00');
expect(shiftMinutes(start, end)).toBe(h(7));
});
it('ne renvoie jamais de durée négative', () => {
const start = new Date('2026-08-10T09:00:00+02:00');
const end = new Date('2026-08-10T09:10:00+02:00');
expect(shiftMinutes(start, end, 60)).toBe(0);
});
});
+93
View File
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
isPosteCode,
POSTE_CODES,
POSTE_HUES,
POSTE_LABELS,
posteShort,
posteTokens,
} from '@/lib/design/postes';
const CSS = readFileSync(
fileURLToPath(new URL('../../src/app/globals.css', import.meta.url)),
'utf8',
);
describe('palette des postes', () => {
it('déclare douze postes distincts', () => {
expect(POSTE_CODES).toHaveLength(12);
expect(new Set(POSTE_CODES).size).toBe(12);
});
it('donne un libellé à chaque poste', () => {
for (const code of POSTE_CODES) {
expect(POSTE_LABELS[code], `libellé manquant pour ${code}`).toBeTruthy();
}
});
it('sépare suffisamment les teintes voisines', () => {
// Deux postes séparés de quelques degrés seraient indiscernables à petite
// taille, ce qui rendrait la grille fausse à la lecture.
const hues = [...Object.values(POSTE_HUES)].sort((a, b) => a - b);
for (let i = 1; i < hues.length; i += 1) {
const gap = (hues[i] as number) - (hues[i - 1] as number);
expect(gap, `teintes trop proches : ${hues[i - 1]} et ${hues[i]}`).toBeGreaterThanOrEqual(12);
}
});
it('définit les trois jetons CSS de chaque poste', () => {
for (const code of POSTE_CODES) {
for (const suffix of ['bg', 'fg', 'edge']) {
expect(
CSS.includes(`--post-${code}-${suffix}:`),
`jeton --post-${code}-${suffix} absent de globals.css`,
).toBe(true);
}
}
});
it('renvoie des variables CSS, pour que le thème sombre suive', () => {
const tokens = posteTokens('cai');
expect(tokens.bg).toBe('var(--post-cai-bg)');
expect(tokens.fg).toBe('var(--post-cai-fg)');
expect(tokens.edge).toBe('var(--post-cai-edge)');
});
it('fournit un code court affichable — le second canal de lecture', () => {
// La couleur ne porte jamais l'information seule : un planning se lit aussi
// en niveaux de gris et par quelqu'un qui confond le rouge et le vert.
expect(posteShort('vte')).toBe('VTE');
for (const code of POSTE_CODES) {
expect(posteShort(code)).toHaveLength(3);
}
});
it('reconnaît les codes valides', () => {
expect(isPosteCode('cai')).toBe(true);
expect(isPosteCode('zzz')).toBe(false);
});
});
describe('jetons de thème', () => {
it('redéfinit les couleurs pour le thème sombre', () => {
expect(CSS).toContain(":root[data-theme='dark']");
for (const token of ['--color-canvas', '--color-ink-1', '--color-accent']) {
const occurrences = CSS.split(token).length - 1;
expect(
occurrences,
`${token} doit être défini en clair et redéfini en sombre`,
).toBeGreaterThanOrEqual(2);
}
});
it('inverse le sens du palier de clarté en sombre', () => {
// Sans cette inversion, le palier qui écarte les teintes voisines pousse du
// mauvais côté et les rapproche au lieu de les séparer.
expect(CSS).toContain('--tier-dir: -1');
expect(CSS).toContain('--tier-dir: 1');
});
});