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,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 (
|
||||
|
||||
Reference in new issue
Block a user