From 4012f32f7fd3747991ec23f7f0902c301672f992 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 22:16:52 +0000 Subject: [PATCH] Complete the six application screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv --- src/app/(app)/bientot/page.tsx | 25 ++++ src/app/(app)/conges/page.tsx | 148 ++++++++++++++++++++ src/app/(app)/equipe/[id]/page.tsx | 147 ++++++++++++++++++++ src/app/(app)/equipe/page.tsx | 118 ++++++++++++++++ src/app/(app)/planning/jour/page.tsx | 48 +++++++ src/app/globals.css | 111 +++++++++++++++ src/components/planning/DayTimeline.tsx | 176 ++++++++++++++++++++++++ src/components/shell/ThemeToggle.tsx | 35 +++-- src/lib/demo/conges.ts | 58 ++++++++ src/lib/demo/fiche.ts | 96 +++++++++++++ tests/unit/counters.test.ts | 114 +++++++++++++++ tests/unit/postes.test.ts | 93 +++++++++++++ 12 files changed, 1158 insertions(+), 11 deletions(-) create mode 100644 src/app/(app)/bientot/page.tsx create mode 100644 src/app/(app)/conges/page.tsx create mode 100644 src/app/(app)/equipe/[id]/page.tsx create mode 100644 src/app/(app)/equipe/page.tsx create mode 100644 src/app/(app)/planning/jour/page.tsx create mode 100644 src/components/planning/DayTimeline.tsx create mode 100644 src/lib/demo/conges.ts create mode 100644 src/lib/demo/fiche.ts create mode 100644 tests/unit/counters.test.ts create mode 100644 tests/unit/postes.test.ts diff --git a/src/app/(app)/bientot/page.tsx b/src/app/(app)/bientot/page.tsx new file mode 100644 index 0000000..3c40457 --- /dev/null +++ b/src/app/(app)/bientot/page.tsx @@ -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 ( + + +
+ +
+
+ ); +} diff --git a/src/app/(app)/conges/page.tsx b/src/app/(app)/conges/page.tsx new file mode 100644 index 0000000..0171e7c --- /dev/null +++ b/src/app/(app)/conges/page.tsx @@ -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 = { + 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 ( + + + + + + } + /> + + +
+ {WEEKDAYS.map((day) => ( +
+ {day} +
+ ))} +
+
+ {cells.map((cell) => ( +
+ + {cell.inMonth ? cell.dayNumber : ''} + +
    + {cell.items.slice(0, 3).map((absence) => ( +
  • + {absence.who.split(' ')[0]} · {absence.type} +
  • + ))} + {cell.items.length > 3 ? ( +
  • + +{cell.items.length - 3} +
  • + ) : null} +
+
+ ))} +
+
+ + + + {PENDING_REQUESTS.length}} + /> +
    + {PENDING_REQUESTS.map((request) => ( +
  • + + + {request.who} · {request.type} + + + {request.note} + + + + {request.range} + + {request.days} + + + + +
  • + ))} +
+
+
+ ); +} diff --git a/src/app/(app)/equipe/[id]/page.tsx b/src/app/(app)/equipe/[id]/page.tsx new file mode 100644 index 0000000..a20dbd1 --- /dev/null +++ b/src/app/(app)/equipe/[id]/page.tsx @@ -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 ( + + + + + + } + /> + +
+ + {initials(employee)} + +
+

{employee.contract}

+

+ Poste principal · {POSTE_LABELS[employee.poste]} +

+
+ + {employee.forfaitJours ? ( + Forfait jours · 218 j + ) : null} +
    + {FICHE_COUNTERS.map((counter) => ( +
  • + + {counter.label} + + + {counter.value} + +
  • + ))} +
+
+ +
+ + + Voir le planning + + } + /> +
    + {FICHE_SHIFTS.map((shift, index) => { + const tokens = posteTokens(shift.poste); + return ( +
  • + + {shift.day} + + + {posteShort(shift.poste)} + + {shift.time} + + {shift.worked} + + {shift.state} +
  • + ); + })} +
+
+ + + +
    + {FICHE_DOCUMENTS.map((document) => ( +
  • + + + {document.label} + + + {document.date} + + + {document.state} +
  • + ))} +
+
+
+
+ ); +} diff --git a/src/app/(app)/equipe/page.tsx b/src/app/(app)/equipe/page.tsx new file mode 100644 index 0000000..ad06049 --- /dev/null +++ b/src/app/(app)/equipe/page.tsx @@ -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 = { + 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 ( + + + + + + } + /> + +
+ + + + + + + + + + + + {EMPLOYEES.map((employee) => { + const tokens = posteTokens(employee.poste); + const status = STATUS[employee.status] ?? STATUS.actif!; + return ( + + + + + + + + ); + })} + +
+ Collaborateur + + Poste principal + + Contrat + + Entrée + + Statut +
+ + + {initials(employee)} + + + + {fullName(employee)} + + + {employee.job} + + + + + + + {posteShort(employee.poste)} + + {POSTE_LABELS[employee.poste]} + + + {employee.contract} + + {employee.since} + + {status.label} +
+
+
+ ); +} diff --git a/src/app/(app)/planning/jour/page.tsx b/src/app/(app)/planning/jour/page.tsx new file mode 100644 index 0000000..e86fe87 --- /dev/null +++ b/src/app/(app)/planning/jour/page.tsx @@ -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 ( + + + + + + } + /> + +
+ {lanes.length} salariés présents + Creux de couverture à 13 h +
+ + +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index bd6e860..21b3b22 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 diff --git a/src/components/planning/DayTimeline.tsx b/src/components/planning/DayTimeline.tsx new file mode 100644 index 0000000..a43425d --- /dev/null +++ b/src/components/planning/DayTimeline.tsx @@ -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 ( +
+
+
+
+ Salarié +
+
+ {hours.map((hour) => ( + + {String(hour).padStart(2, '0')}h + + ))} +
+
+
+ +
+
+ Effectif présent +
+
+ {coverage.map((count, index) => ( +
1 ? 's' : ''} à ${String(hours[index]).padStart(2, '0')} h`} + > + + {count} + +
+ ))} +
+
+ + {lanes.map((lane) => ( +
+
+ + {lane.unassigned ? '?' : initials(lane.employee)} + + + + {lane.unassigned + ? 'Non assigné' + : `${lane.employee.firstName} ${lane.employee.lastName}`} + + + {lane.employee.job} + + +
+ +
+ {hours.slice(1, -1).map((hour) => ( + + ))} + + {lane.shifts.map((shift, index) => { + const tokens = posteTokens(shift.poste); + const ghost = shift.state === 'unassigned'; + return ( +
+ + {posteShort(shift.poste)} + + + {clock(shift.start)}–{clock(shift.end)} + +
+ ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/shell/ThemeToggle.tsx b/src/components/shell/ThemeToggle.tsx index fdc7511..10d36fd 100644 --- a/src/components/shell/ThemeToggle.tsx +++ b/src/components/shell/ThemeToggle.tsx @@ -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('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 ( diff --git a/src/lib/demo/conges.ts b/src/lib/demo/conges.ts new file mode 100644 index 0000000..1585abf --- /dev/null +++ b/src/lib/demo/conges.ts @@ -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', + }, +]; diff --git a/src/lib/demo/fiche.ts b/src/lib/demo/fiche.ts new file mode 100644 index 0000000..dd6d43b --- /dev/null +++ b/src/lib/demo/fiche.ts @@ -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' }, +]; diff --git a/tests/unit/counters.test.ts b/tests/unit/counters.test.ts new file mode 100644 index 0000000..62f2f6f --- /dev/null +++ b/tests/unit/counters.test.ts @@ -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); + }); +}); diff --git a/tests/unit/postes.test.ts b/tests/unit/postes.test.ts new file mode 100644 index 0000000..26ac07d --- /dev/null +++ b/tests/unit/postes.test.ts @@ -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'); + }); +});