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:
12 files changed
+1158
-11
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user