Port the design tokens and the planning primitives
Brings in the token set from the Claude Design export: warm neutrals, semantic status colours, and the twelve-hue categorical poste palette, in light and dark. Declared through Tailwind's @theme so utilities compile to var(--color-*) and follow the theme without per-component branching. The poste palette derives every hue from one formula with an alternating lightness step, so neighbouring hues never land at the same lightness. ShiftChip always prints the poste code alongside the fill: colour carries meaning in the grid, and a grid has to stay readable in print, in greyscale, and to someone who confuses red and green. Week counters live in src/domain/counters rather than in the component, because the same numbers will feed the hours report and the payroll export later. Durations are whole minutes: decimal hours drift by a minute in ways that are invisible on screen and wrong on a payslip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
10 files changed
+856
No files matched your search
@@ -1 +1,256 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* ==========================================================================
|
||||
PlanFlow — jetons de design
|
||||
Portés depuis l'export Claude Design « PlanFlow Design System ».
|
||||
|
||||
Les valeurs de couleur sont en oklch : les paliers de clarté restent
|
||||
perceptuellement réguliers d'une teinte à l'autre, ce dont dépend la palette
|
||||
catégorielle de postes (§ plus bas).
|
||||
|
||||
Déclarés dans @theme pour que Tailwind génère les utilitaires correspondants
|
||||
(bg-surface, text-ink-1, border-line-1…). Le thème sombre redéfinit les mêmes
|
||||
variables : les utilitaires compilent vers var(--color-…) et suivent donc.
|
||||
========================================================================== */
|
||||
|
||||
@theme {
|
||||
--font-ui: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: ui-monospace, SFMono-Regular, 'Cascadia Mono', 'Segoe UI Mono',
|
||||
'Liberation Mono', Menlo, monospace;
|
||||
|
||||
--text-micro: 11px;
|
||||
--text-xs: 12px;
|
||||
--text-sm: 13px;
|
||||
--text-md: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 20px;
|
||||
--text-2xl: 26px;
|
||||
--text-3xl: 32px;
|
||||
|
||||
--radius-1: 3px;
|
||||
--radius-2: 6px;
|
||||
--radius-3: 10px;
|
||||
--radius-4: 14px;
|
||||
--radius-full: 999px;
|
||||
|
||||
--ease-organic: cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
--ease-organic-out: cubic-bezier(0, 0.6, 0.35, 1);
|
||||
|
||||
/* Neutres chauds — la base « organique » du thème. */
|
||||
--color-canvas: oklch(0.976 0.008 85);
|
||||
--color-surface: oklch(0.996 0.005 85);
|
||||
--color-surface-2: oklch(0.958 0.01 85);
|
||||
--color-surface-3: oklch(0.932 0.012 85);
|
||||
--color-line-1: oklch(0.905 0.012 85);
|
||||
--color-line-2: oklch(0.852 0.014 85);
|
||||
--color-line-3: oklch(0.735 0.016 85);
|
||||
--color-ink-1: oklch(0.255 0.02 140);
|
||||
--color-ink-2: oklch(0.445 0.016 135);
|
||||
--color-ink-3: oklch(0.545 0.014 120);
|
||||
|
||||
--color-accent: oklch(0.44 0.078 158);
|
||||
--color-accent-hover: oklch(0.385 0.08 158);
|
||||
--color-accent-press: oklch(0.335 0.072 158);
|
||||
--color-accent-ink: oklch(0.985 0.008 158);
|
||||
--color-accent-soft: oklch(0.935 0.03 158);
|
||||
--color-accent-soft-ink: oklch(0.315 0.06 158);
|
||||
|
||||
--color-focus: oklch(0.52 0.16 252);
|
||||
|
||||
--color-danger: oklch(0.475 0.145 30);
|
||||
--color-danger-hover: oklch(0.415 0.145 30);
|
||||
--color-danger-ink: oklch(0.985 0.006 30);
|
||||
--color-danger-soft: oklch(0.94 0.035 30);
|
||||
--color-danger-soft-ink: oklch(0.355 0.105 30);
|
||||
|
||||
--color-warn: oklch(0.745 0.125 78);
|
||||
--color-warn-ink: oklch(0.255 0.055 60);
|
||||
--color-warn-soft: oklch(0.945 0.045 82);
|
||||
--color-warn-soft-ink: oklch(0.375 0.085 60);
|
||||
|
||||
--color-ok: oklch(0.475 0.095 148);
|
||||
--color-ok-soft: oklch(0.935 0.035 148);
|
||||
--color-ok-soft-ink: oklch(0.315 0.07 148);
|
||||
|
||||
--color-info: oklch(0.475 0.105 245);
|
||||
--color-info-soft: oklch(0.935 0.035 245);
|
||||
--color-info-soft-ink: oklch(0.325 0.08 245);
|
||||
|
||||
--shadow-e1: 0 1px 2px oklch(0.3 0.02 85 / 0.08);
|
||||
--shadow-e2:
|
||||
0 1px 2px oklch(0.3 0.02 85 / 0.06), 0 6px 14px oklch(0.3 0.02 85 / 0.07);
|
||||
--shadow-e3:
|
||||
0 2px 6px oklch(0.3 0.02 85 / 0.08), 0 16px 40px oklch(0.3 0.02 85 / 0.14);
|
||||
}
|
||||
|
||||
:root {
|
||||
--focus-halo: oklch(0.52 0.16 252 / 0.22);
|
||||
|
||||
--lh-tight: 1.2;
|
||||
--lh-ui: 1.35;
|
||||
--lh-table: 1.4;
|
||||
--lh-prose: 1.55;
|
||||
|
||||
--d-1: 80ms;
|
||||
--d-2: 130ms;
|
||||
--d-3: 200ms;
|
||||
--d-4: 280ms;
|
||||
|
||||
/* Hauteur d'une ligne de grille. Change avec la densité. */
|
||||
--row-h: 38px;
|
||||
|
||||
/* ---- Palette catégorielle de postes -----------------------------------
|
||||
Douze teintes dérivées d'une même formule : la clarté et le chroma sont
|
||||
communs, seule la teinte varie, plus un palier alterné (--post-*-t) qui
|
||||
écarte les teintes voisines. Deux postes ne peuvent donc pas se retrouver
|
||||
à la même clarté avec des teintes proches — c'est ce qui les garde
|
||||
distinguables en vision daltonienne, où la teinte seule ne suffit pas.
|
||||
|
||||
La couleur ne porte jamais l'information seule : le code du poste est
|
||||
toujours écrit dans le bloc (voir ShiftChip).
|
||||
--------------------------------------------------------------------- */
|
||||
--tier-dir: -1;
|
||||
--chip-bg-l: 0.905;
|
||||
--chip-bg-c: 0.042;
|
||||
--chip-fg-l: 0.315;
|
||||
--chip-fg-c: 0.055;
|
||||
--chip-edge-l: 0.52;
|
||||
--chip-edge-c: 0.12;
|
||||
|
||||
--post-cai-h: 250;
|
||||
--post-cai-t: 0;
|
||||
--post-vte-h: 162;
|
||||
--post-vte-t: 0.045;
|
||||
--post-res-h: 30;
|
||||
--post-res-t: 0;
|
||||
--post-acc-h: 205;
|
||||
--post-acc-t: 0.045;
|
||||
--post-cab-h: 100;
|
||||
--post-cab-t: 0;
|
||||
--post-rss-h: 308;
|
||||
--post-rss-t: 0.045;
|
||||
--post-inv-h: 138;
|
||||
--post-inv-t: 0;
|
||||
--post-liv-h: 52;
|
||||
--post-liv-t: 0.045;
|
||||
--post-enc-h: 276;
|
||||
--post-enc-t: 0;
|
||||
--post-clc-h: 340;
|
||||
--post-clc-t: 0.045;
|
||||
--post-net-h: 185;
|
||||
--post-net-t: 0;
|
||||
--post-for-h: 68;
|
||||
--post-for-t: 0.045;
|
||||
}
|
||||
|
||||
/* Thème sombre : redéfinit les mêmes variables. --tier-dir s'inverse pour que
|
||||
le palier de clarté pousse vers le clair au lieu du sombre. */
|
||||
:root[data-theme='dark'] {
|
||||
--tier-dir: 1;
|
||||
--chip-bg-l: 0.315;
|
||||
--chip-bg-c: 0.048;
|
||||
--chip-fg-l: 0.905;
|
||||
--chip-fg-c: 0.032;
|
||||
--chip-edge-l: 0.66;
|
||||
--chip-edge-c: 0.105;
|
||||
|
||||
--color-canvas: oklch(0.185 0.01 150);
|
||||
--color-surface: oklch(0.225 0.012 150);
|
||||
--color-surface-2: oklch(0.265 0.012 150);
|
||||
--color-surface-3: oklch(0.305 0.013 150);
|
||||
--color-line-1: oklch(0.325 0.012 150);
|
||||
--color-line-2: oklch(0.395 0.014 150);
|
||||
--color-line-3: oklch(0.52 0.016 150);
|
||||
--color-ink-1: oklch(0.955 0.008 95);
|
||||
--color-ink-2: oklch(0.815 0.012 100);
|
||||
--color-ink-3: oklch(0.67 0.013 105);
|
||||
|
||||
--color-accent: oklch(0.735 0.105 158);
|
||||
--color-accent-hover: oklch(0.795 0.095 158);
|
||||
--color-accent-press: oklch(0.68 0.1 158);
|
||||
--color-accent-ink: oklch(0.195 0.04 158);
|
||||
--color-accent-soft: oklch(0.3 0.045 158);
|
||||
--color-accent-soft-ink: oklch(0.87 0.07 158);
|
||||
|
||||
--color-focus: oklch(0.76 0.14 252);
|
||||
--focus-halo: oklch(0.76 0.14 252 / 0.28);
|
||||
|
||||
--color-danger: oklch(0.69 0.15 32);
|
||||
--color-danger-hover: oklch(0.745 0.14 32);
|
||||
--color-danger-ink: oklch(0.185 0.045 32);
|
||||
--color-danger-soft: oklch(0.305 0.06 32);
|
||||
--color-danger-soft-ink: oklch(0.885 0.075 32);
|
||||
|
||||
--color-warn: oklch(0.79 0.13 80);
|
||||
--color-warn-ink: oklch(0.21 0.05 60);
|
||||
--color-warn-soft: oklch(0.32 0.055 75);
|
||||
--color-warn-soft-ink: oklch(0.895 0.08 82);
|
||||
|
||||
--color-ok: oklch(0.71 0.1 148);
|
||||
--color-ok-soft: oklch(0.295 0.05 148);
|
||||
--color-ok-soft-ink: oklch(0.885 0.075 148);
|
||||
|
||||
--color-info: oklch(0.705 0.11 245);
|
||||
--color-info-soft: oklch(0.3 0.05 245);
|
||||
--color-info-soft-ink: oklch(0.88 0.07 245);
|
||||
|
||||
--shadow-e1: 0 1px 2px oklch(0 0 0 / 0.4);
|
||||
--shadow-e2: 0 1px 2px oklch(0 0 0 / 0.35), 0 6px 14px oklch(0 0 0 / 0.4);
|
||||
--shadow-e3: 0 2px 6px oklch(0 0 0 / 0.35), 0 16px 40px oklch(0 0 0 / 0.55);
|
||||
}
|
||||
|
||||
/* Densité confortable : une seule variable pilote la hauteur de ligne. */
|
||||
:root[data-density='confortable'] {
|
||||
--row-h: 46px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Base
|
||||
========================================================================== */
|
||||
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-ink-1);
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-md);
|
||||
line-height: var(--lh-ui);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Anneau de focus unique, contraste ≥ 3:1 sur tous les fonds y compris les
|
||||
aplats de poste. La grille se pilote au clavier : il doit rester visible. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Compteurs, horaires, écarts : sans chiffres tabulaires les colonnes dansent
|
||||
d'une ligne à l'autre et la comparaison devient pénible. */
|
||||
.tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 1ms !important;
|
||||
transition-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
export type AbsenceKind = 'cp' | 'rtt' | 'maladie' | 'sans-solde' | 'attente';
|
||||
|
||||
const KINDS: Record<AbsenceKind, { bg: string; ink: string; edge: string }> = {
|
||||
cp: {
|
||||
bg: 'var(--color-info-soft)',
|
||||
ink: 'var(--color-info-soft-ink)',
|
||||
edge: 'var(--color-info)',
|
||||
},
|
||||
rtt: {
|
||||
bg: 'var(--color-accent-soft)',
|
||||
ink: 'var(--color-accent-soft-ink)',
|
||||
edge: 'var(--color-accent)',
|
||||
},
|
||||
maladie: {
|
||||
bg: 'var(--color-surface-3)',
|
||||
ink: 'var(--color-ink-2)',
|
||||
edge: 'var(--color-line-3)',
|
||||
},
|
||||
'sans-solde': {
|
||||
bg: 'var(--color-surface-2)',
|
||||
ink: 'var(--color-ink-2)',
|
||||
edge: 'var(--color-line-3)',
|
||||
},
|
||||
attente: {
|
||||
bg: 'var(--color-warn-soft)',
|
||||
ink: 'var(--color-warn-soft-ink)',
|
||||
edge: 'var(--color-warn)',
|
||||
},
|
||||
};
|
||||
|
||||
export interface AbsenceBarProps {
|
||||
kind: AbsenceKind;
|
||||
label: string;
|
||||
/** Durée déjà formatée, ex. « 5 j ». */
|
||||
duration: string;
|
||||
/** Index du premier jour couvert, 0 = lundi. */
|
||||
startDay: number;
|
||||
/** Nombre de jours couverts. */
|
||||
span: number;
|
||||
/** Nombre de colonnes de la grille. */
|
||||
columns?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Barre d'absence continue sur plusieurs jours.
|
||||
*
|
||||
* Positionnée en pourcentage de la largeur de grille plutôt qu'en colonnes CSS :
|
||||
* la barre chevauche les cellules au lieu de s'y insérer, ce qui laisse voir
|
||||
* qu'une absence est une période continue et non une suite de jours isolés.
|
||||
*/
|
||||
export function AbsenceBar({
|
||||
kind,
|
||||
label,
|
||||
duration,
|
||||
startDay,
|
||||
span,
|
||||
columns = 7,
|
||||
className,
|
||||
}: AbsenceBarProps) {
|
||||
const tone = KINDS[kind];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'pointer-events-none absolute z-10 flex items-center gap-2 overflow-hidden rounded-2 px-2 py-0.5 text-micro font-medium',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
left: `calc(100% / ${columns} * ${startDay} + 3px)`,
|
||||
width: `calc(100% / ${columns} * ${span} - 6px)`,
|
||||
background: tone.bg,
|
||||
color: tone.ink,
|
||||
border: `1px solid ${tone.edge}`,
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="tnum ml-auto shrink-0 opacity-80">{duration}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { DeltaTone, WeekCountersView } from '@/domain/counters/week';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
const DELTA_TONES: Record<DeltaTone, string> = {
|
||||
flat: 'text-ink-1',
|
||||
over: 'bg-warn-soft text-warn-soft-ink',
|
||||
under: 'bg-danger-soft text-danger-soft-ink',
|
||||
none: 'text-ink-3',
|
||||
};
|
||||
|
||||
export interface CounterStripProps {
|
||||
counters: WeekCountersView;
|
||||
/** Vrai si le contrat est au forfait jours : pas de comparaison horaire. */
|
||||
forfaitJours?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Cell({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="flex items-baseline gap-1">
|
||||
<span className="text-ink-3">{label}</span>
|
||||
<span className="tnum text-ink-2">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Les cinq compteurs d'une ligne salarié : contrat, planifié, absences, écart,
|
||||
* dimanches et repos. Rendus depuis `computeWeekCounters`, jamais recalculés
|
||||
* localement.
|
||||
*/
|
||||
export function CounterStrip({
|
||||
counters,
|
||||
forfaitJours = false,
|
||||
className,
|
||||
}: CounterStripProps) {
|
||||
if (forfaitJours) {
|
||||
return (
|
||||
<div className={cx('flex items-center gap-2 text-micro', className)}>
|
||||
<span className="rounded-full bg-accent-soft px-1.5 py-px font-medium text-accent-soft-ink">
|
||||
Forfait jours
|
||||
</span>
|
||||
<Cell label="Rep." value={String(counters.restDays)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx('flex flex-wrap items-center gap-x-2 gap-y-px text-micro', className)}
|
||||
>
|
||||
<Cell label="Contr." value={counters.contractLabel} />
|
||||
<span aria-hidden className="text-line-3">
|
||||
·
|
||||
</span>
|
||||
<Cell label="Planif." value={counters.plannedLabel} />
|
||||
<span
|
||||
className={cx(
|
||||
'tnum rounded-full px-1.5 py-px font-semibold',
|
||||
DELTA_TONES[counters.tone],
|
||||
)}
|
||||
title="Écart au contrat"
|
||||
>
|
||||
{counters.deltaLabel}
|
||||
</span>
|
||||
<Cell label="Dim." value={String(counters.sundaysWorked)} />
|
||||
<Cell label="Rep." value={String(counters.restDays)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { posteShort, posteTokens, type PosteCode } from '@/lib/design/postes';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
/**
|
||||
* États d'un créneau, repris du design system.
|
||||
*
|
||||
* `alert` ajoute un liseré danger de 1,5 px : la couleur du poste occupe déjà
|
||||
* le fond, l'alerte doit donc passer par la forme, pas par une seconde teinte
|
||||
* qui entrerait en concurrence avec la palette catégorielle.
|
||||
*/
|
||||
export type ShiftState =
|
||||
| 'published'
|
||||
| 'validated'
|
||||
| 'draft'
|
||||
| 'unpublished'
|
||||
| 'alert'
|
||||
| 'unassigned';
|
||||
|
||||
export interface ShiftChipProps {
|
||||
poste: PosteCode;
|
||||
/** « 09:00–17:00 ». */
|
||||
time: string;
|
||||
state?: ShiftState;
|
||||
/** Écart affiché en pastille, ex. « +2 h 55 ». */
|
||||
delta?: string;
|
||||
/** Motif de l'alerte, ex. « Repos < 11 h ». */
|
||||
alert?: string;
|
||||
/** Durée de pause, en minutes. */
|
||||
breakMinutes?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ShiftChip({
|
||||
poste,
|
||||
time,
|
||||
state = 'published',
|
||||
delta,
|
||||
alert,
|
||||
breakMinutes,
|
||||
className,
|
||||
}: ShiftChipProps) {
|
||||
const tokens = posteTokens(poste);
|
||||
const ghost = state === 'unassigned';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'flex min-w-0 flex-col gap-px rounded-2 px-1.5 py-1 text-micro',
|
||||
state === 'draft' && 'border-dashed',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
background: ghost ? 'transparent' : tokens.bg,
|
||||
color: ghost ? 'var(--color-ink-2)' : tokens.fg,
|
||||
border:
|
||||
state === 'alert'
|
||||
? '1.5px solid var(--color-danger)'
|
||||
: ghost
|
||||
? `1px dashed ${tokens.edge}`
|
||||
: state === 'draft'
|
||||
? `1px dashed ${tokens.edge}`
|
||||
: state === 'unpublished'
|
||||
? `1px dotted ${tokens.edge}`
|
||||
: '1px solid transparent',
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
{/* Le code du poste est le second canal : la couleur ne suffit pas. */}
|
||||
<span className="font-semibold tracking-wide">{posteShort(poste)}</span>
|
||||
{state === 'validated' ? (
|
||||
<span className="text-ok" title="Validé" aria-label="Validé">
|
||||
✓
|
||||
</span>
|
||||
) : null}
|
||||
{delta ? (
|
||||
<span className="tnum ml-auto rounded-full bg-warn-soft px-1 font-semibold text-warn-soft-ink">
|
||||
{delta}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
<span className="tnum truncate">
|
||||
{time}
|
||||
{breakMinutes ? (
|
||||
<span className="opacity-70"> ({breakMinutes} mn)</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
{alert ? (
|
||||
<span className="mt-px flex items-center gap-1 font-medium text-danger-soft-ink">
|
||||
<span aria-hidden>▲</span>
|
||||
<span className="truncate">{alert}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
export type Tone = 'neutral' | 'accent' | 'ok' | 'warn' | 'danger' | 'info';
|
||||
|
||||
const TONES: Record<Tone, string> = {
|
||||
neutral: 'bg-surface-3 text-ink-2 border-line-2',
|
||||
accent: 'bg-accent-soft text-accent-soft-ink border-accent',
|
||||
ok: 'bg-ok-soft text-ok-soft-ink border-ok',
|
||||
warn: 'bg-warn-soft text-warn-soft-ink border-warn',
|
||||
danger: 'bg-danger-soft text-danger-soft-ink border-danger',
|
||||
info: 'bg-info-soft text-info-soft-ink border-info',
|
||||
};
|
||||
|
||||
export interface BadgeProps {
|
||||
tone?: Tone;
|
||||
/** Sans liseré : pour les compteurs discrets de la navigation. */
|
||||
subtle?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Pastille compacte. Porte un état ou un décompte, jamais une action. */
|
||||
export function Badge({
|
||||
tone = 'neutral',
|
||||
subtle = false,
|
||||
children,
|
||||
className,
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cx(
|
||||
'inline-flex shrink-0 items-center rounded-full px-2 py-px text-micro font-semibold',
|
||||
subtle ? 'border-transparent' : 'border',
|
||||
TONES[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
export type ButtonSize = 'sm' | 'md';
|
||||
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'border-accent bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-press',
|
||||
secondary:
|
||||
'border-line-3 bg-surface text-ink-1 hover:bg-surface-2 active:bg-surface-3',
|
||||
ghost:
|
||||
'border-transparent bg-transparent text-ink-2 hover:bg-surface-2 hover:text-ink-1',
|
||||
danger:
|
||||
'border-danger bg-danger text-danger-ink hover:bg-danger-hover active:bg-danger-hover',
|
||||
};
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: 'h-[26px] px-2.5 text-xs',
|
||||
md: 'h-8 px-3.5 text-sm',
|
||||
};
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
className,
|
||||
type = 'button',
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={cx(
|
||||
'inline-flex shrink-0 items-center justify-center gap-1.5 rounded-2 border font-medium',
|
||||
'cursor-pointer transition-colors duration-[var(--d-1)] ease-organic',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
VARIANTS[variant],
|
||||
SIZES[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
export interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Card({ children, className }: CardProps) {
|
||||
return (
|
||||
<section
|
||||
className={cx(
|
||||
'overflow-hidden rounded-3 border border-line-1 bg-surface',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CardHeaderProps {
|
||||
title: ReactNode;
|
||||
/** Décompte ou statut affiché contre le titre. */
|
||||
badge?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function CardHeader({ title, badge, action }: CardHeaderProps) {
|
||||
return (
|
||||
<header className="flex items-center gap-2.5 border-b border-line-1 px-4 py-3">
|
||||
<h2 className="text-md font-semibold">{title}</h2>
|
||||
{badge}
|
||||
<span className="flex-1" />
|
||||
{action}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'flex flex-col items-center gap-2.5 px-6 py-16 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-9 rounded-2 border-[1.5px] border-line-3 bg-surface-2"
|
||||
/>
|
||||
<p className="text-md font-semibold">{title}</p>
|
||||
{description ? (
|
||||
<p className="max-w-[46ch] text-sm leading-[var(--lh-prose)] text-ink-2">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Compteurs hebdomadaires d'un salarié.
|
||||
*
|
||||
* PLAN.md §7.4 : une seule implémentation alimente la grille, le rapport
|
||||
* d'heures et l'export de paie. Trois calculs qui divergent sont la première
|
||||
* cause d'écart entre un planning et un bulletin — et l'écart ne se voit qu'au
|
||||
* moment où le comptable le réclame.
|
||||
*
|
||||
* Les durées sont manipulées en **minutes entières**. Les heures décimales
|
||||
* accumulent des erreurs d'arrondi qui finissent par déplacer un compteur d'une
|
||||
* minute, ce qui est invisible à l'écran et faux en paie.
|
||||
*/
|
||||
|
||||
export interface WeekCounters {
|
||||
/** Durée contractuelle hebdomadaire, en minutes. */
|
||||
contractMinutes: number;
|
||||
/** Somme des créneaux planifiés, pauses déduites. */
|
||||
plannedMinutes: number;
|
||||
/** Durée couverte par des absences. */
|
||||
absenceMinutes: number;
|
||||
/** Nombre de dimanches travaillés dans la semaine. */
|
||||
sundaysWorked: number;
|
||||
/** Jours de repos dans la semaine. */
|
||||
restDays: number;
|
||||
}
|
||||
|
||||
export interface WeekCountersView extends WeekCounters {
|
||||
/** Planifié + absences − contrat. Signé. */
|
||||
deltaMinutes: number;
|
||||
contractLabel: string;
|
||||
plannedLabel: string;
|
||||
absenceLabel: string;
|
||||
deltaLabel: string;
|
||||
tone: DeltaTone;
|
||||
}
|
||||
|
||||
export type DeltaTone = 'flat' | 'over' | 'under' | 'none';
|
||||
|
||||
/**
|
||||
* Un salarié au forfait jours n'a pas de durée hebdomadaire : le comparer à un
|
||||
* contrat en heures produirait un écart qui n'a aucun sens (PLAN.md §6.4).
|
||||
*/
|
||||
export const FORFAIT_JOURS = Symbol('forfait-jours');
|
||||
|
||||
export function formatMinutes(total: number): string {
|
||||
const sign = total < 0 ? '−' : '';
|
||||
const abs = Math.abs(total);
|
||||
const hours = Math.floor(abs / 60);
|
||||
const minutes = abs % 60;
|
||||
return `${sign}${hours} h ${String(minutes).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Écart signé : le « + » explicite évite de confondre 0 h 00 et un manque. */
|
||||
export function formatDelta(total: number): string {
|
||||
if (total === 0) return '+0 h 00';
|
||||
return total > 0 ? `+${formatMinutes(total)}` : formatMinutes(total);
|
||||
}
|
||||
|
||||
export function deltaTone(deltaMinutes: number): DeltaTone {
|
||||
if (deltaMinutes === 0) return 'flat';
|
||||
return deltaMinutes > 0 ? 'over' : 'under';
|
||||
}
|
||||
|
||||
export function computeWeekCounters(input: WeekCounters): WeekCountersView {
|
||||
// Les absences comptent dans l'atteinte du contrat : un salarié en congés une
|
||||
// semaine entière ne doit pas apparaître en sous-réalisation de 35 h.
|
||||
const deltaMinutes =
|
||||
input.plannedMinutes + input.absenceMinutes - input.contractMinutes;
|
||||
|
||||
return {
|
||||
...input,
|
||||
deltaMinutes,
|
||||
contractLabel: formatMinutes(input.contractMinutes),
|
||||
plannedLabel: formatMinutes(input.plannedMinutes),
|
||||
absenceLabel: formatMinutes(input.absenceMinutes),
|
||||
deltaLabel: formatDelta(deltaMinutes),
|
||||
tone: deltaTone(deltaMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
/** Durée d'un créneau, pauses déduites, à partir de deux instants. */
|
||||
export function shiftMinutes(
|
||||
start: Date,
|
||||
end: Date,
|
||||
breakMinutes = 0,
|
||||
): number {
|
||||
// Calcul depuis les instants, jamais depuis l'heure murale : la nuit du
|
||||
// changement d'heure, 22 h–06 h dure 7 h ou 9 h, jamais 8 h (PLAN.md §3.3).
|
||||
const elapsed = Math.round((end.getTime() - start.getTime()) / 60_000);
|
||||
return Math.max(0, elapsed - breakMinutes);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Concatène des classes en ignorant les valeurs vides. */
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Palette catégorielle des postes.
|
||||
*
|
||||
* Douze teintes dérivées d'une formule commune (voir globals.css) : clarté et
|
||||
* chroma partagés, seule la teinte varie, plus un palier alterné qui écarte les
|
||||
* voisines. C'est ce qui les garde distinguables en vision daltonienne.
|
||||
*
|
||||
* Règle non négociable : **la couleur ne porte jamais l'information seule**.
|
||||
* Tout bloc coloré affiche aussi le code du poste. Un planning se lit aussi en
|
||||
* noir et blanc, à l'impression, et par quelqu'un qui confond le rouge et le
|
||||
* vert — trois cas où une pastille de couleur seule ne dit plus rien.
|
||||
*/
|
||||
|
||||
export const POSTE_CODES = [
|
||||
'cai',
|
||||
'vte',
|
||||
'res',
|
||||
'acc',
|
||||
'cab',
|
||||
'rss',
|
||||
'inv',
|
||||
'liv',
|
||||
'enc',
|
||||
'clc',
|
||||
'net',
|
||||
'for',
|
||||
] as const;
|
||||
|
||||
export type PosteCode = (typeof POSTE_CODES)[number];
|
||||
|
||||
export const POSTE_LABELS: Record<PosteCode, string> = {
|
||||
cai: 'Caisse',
|
||||
vte: 'Vente conseil',
|
||||
res: 'Réserve',
|
||||
acc: 'Accueil',
|
||||
cab: 'Cabines',
|
||||
rss: 'Réassort',
|
||||
inv: 'Inventaire',
|
||||
liv: 'Livraison',
|
||||
enc: 'Encadrement',
|
||||
clc: 'Retrait web',
|
||||
net: 'Entretien',
|
||||
for: 'Formation',
|
||||
};
|
||||
|
||||
/** Teinte oklch de chaque poste, pour la documentation et les tests. */
|
||||
export const POSTE_HUES: Record<PosteCode, number> = {
|
||||
cai: 250,
|
||||
vte: 162,
|
||||
res: 30,
|
||||
acc: 205,
|
||||
cab: 100,
|
||||
rss: 308,
|
||||
inv: 138,
|
||||
liv: 52,
|
||||
enc: 276,
|
||||
clc: 340,
|
||||
net: 185,
|
||||
for: 68,
|
||||
};
|
||||
|
||||
export interface PosteTokens {
|
||||
/** Aplat de fond. */
|
||||
bg: string;
|
||||
/** Encre lisible sur cet aplat. */
|
||||
fg: string;
|
||||
/** Liseré, conservé même quand le fond est vide (créneau non assigné). */
|
||||
edge: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jetons CSS d'un poste. Renvoie des `var(...)` plutôt que des couleurs
|
||||
* calculées : le thème sombre redéfinit les variables, donc la même expression
|
||||
* suit le thème sans que le composant ait à le savoir.
|
||||
*/
|
||||
export function posteTokens(code: PosteCode): PosteTokens {
|
||||
return {
|
||||
bg: `var(--post-${code}-bg)`,
|
||||
fg: `var(--post-${code}-fg)`,
|
||||
edge: `var(--post-${code}-edge)`,
|
||||
};
|
||||
}
|
||||
|
||||
export function posteLabel(code: PosteCode): string {
|
||||
return POSTE_LABELS[code];
|
||||
}
|
||||
|
||||
/** Code affiché dans le bloc — le second canal, en plus de la couleur. */
|
||||
export function posteShort(code: PosteCode): string {
|
||||
return code.toUpperCase();
|
||||
}
|
||||
|
||||
export function isPosteCode(value: string): value is PosteCode {
|
||||
return (POSTE_CODES as readonly string[]).includes(value);
|
||||
}
|
||||
Reference in new issue
Block a user