Add the application shell, Aperçu and the week grid
Ports the shell and Aperçu screen from the Claude Design export, and builds the week grid from the design system's grid specification. The theme is applied by a nonced inline script before first paint. Without it the page renders light and then swaps, which is a white flash for anyone working in dark. The nonce our CSP already emits is what makes an inline script possible at all. Demo data lives in src/lib/demo behind a README stating it is fictional and replaceable. Screens import from there and nowhere else, so the directory can be deleted wholesale once real queries land. Weekend columns are shaded because in retail those are the days that carry counterparts — mayor-authorised Sundays and their compensatory rest — and scheduling one by accident is the failure worth preventing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
This commit is contained in:
14 files changed
+1301
-5
No files matched your search
@@ -0,0 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { AppShell } from '@/components/shell/AppShell';
|
||||
|
||||
export default function AppLayout({ children }: { children: ReactNode }) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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 { ENTRIES, TILES, TODOS } from '@/lib/demo/apercu';
|
||||
|
||||
export const metadata = { title: 'Aperçu RH · PlanFlow' };
|
||||
|
||||
const TILE_SWATCH: Record<string, string> = {
|
||||
warn: 'bg-warn-soft border-warn',
|
||||
info: 'bg-info-soft border-info',
|
||||
accent: 'bg-accent-soft border-accent',
|
||||
danger: 'bg-danger-soft border-danger',
|
||||
};
|
||||
|
||||
export default function ApercuPage() {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Aperçu RH"
|
||||
subtitle="Vos tâches en cours sur Nantes Atlantis · lundi 10 août 2026"
|
||||
actions={
|
||||
<>
|
||||
<Button>Exporter</Button>
|
||||
<Button variant="primary">Nouvelle embauche</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(232px,1fr))]">
|
||||
{TILES.map((tile) => (
|
||||
<button
|
||||
key={tile.label}
|
||||
type="button"
|
||||
className="flex cursor-pointer items-center gap-3 rounded-3 border border-line-1 bg-surface px-4 py-3.5 text-left transition-[border-color,box-shadow] duration-[var(--d-1)] ease-organic hover:border-line-3 hover:shadow-e1"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={`size-8 flex-none rounded-2 border ${TILE_SWATCH[tile.tone] ?? ''}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs text-ink-2">
|
||||
{tile.label}
|
||||
</span>
|
||||
<span className="tnum block text-xl leading-tight font-semibold">
|
||||
{tile.value}
|
||||
</span>
|
||||
</span>
|
||||
<span aria-hidden className="flex-none text-ink-3">
|
||||
›
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 [grid-template-columns:repeat(auto-fit,minmax(380px,1fr))]">
|
||||
<Card>
|
||||
<CardHeader
|
||||
title="Entrées à venir"
|
||||
badge={<Badge tone="accent">4</Badge>}
|
||||
action={
|
||||
<Button size="sm" variant="ghost">
|
||||
Voir tout
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ul>
|
||||
{ENTRIES.map((entry) => (
|
||||
<li
|
||||
key={entry.name}
|
||||
className="flex items-center gap-3 border-b border-line-1 px-4 py-2.5 last:border-b-0 hover:bg-surface-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"
|
||||
>
|
||||
{entry.initials}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{entry.name}
|
||||
</span>
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
{entry.job}
|
||||
</span>
|
||||
</span>
|
||||
<span className="tnum flex-none text-xs text-ink-2">
|
||||
{entry.date}
|
||||
</span>
|
||||
<Badge tone={entry.tone}>{entry.state}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="À traiter cette semaine" />
|
||||
<ul>
|
||||
{TODOS.map((todo) => (
|
||||
<li
|
||||
key={todo.title}
|
||||
className="flex items-start gap-3 border-b border-line-1 px-4 py-3 last:border-b-0 hover:bg-surface-2"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="mt-1.5 size-2 flex-none rounded-full"
|
||||
style={{ background: todo.dot }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">
|
||||
{todo.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-xs leading-[var(--lh-prose)] text-ink-2">
|
||||
{todo.detail}
|
||||
</span>
|
||||
</span>
|
||||
<span className="tnum flex-none whitespace-nowrap text-micro text-ink-3">
|
||||
{todo.when}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { WeekGrid } from '@/components/planning/WeekGrid';
|
||||
import { PageBody, PageHeader } from '@/components/shell/PageHeader';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { POSTE_CODES, POSTE_LABELS, posteTokens } from '@/lib/design/postes';
|
||||
import { UNASSIGNED_ROW, WEEK_DAYS, WEEK_LABEL, WEEK_ROWS } from '@/lib/demo/semaine';
|
||||
|
||||
export const metadata = { title: 'Planning · semaine 33 · PlanFlow' };
|
||||
|
||||
export default function SemainePage() {
|
||||
return (
|
||||
<PageBody>
|
||||
<PageHeader
|
||||
title="Planning · semaine 33"
|
||||
subtitle={`Nantes Atlantis · ${WEEK_LABEL.split('·')[1]?.trim()} · ${WEEK_ROWS.length} salariés planifiés`}
|
||||
actions={
|
||||
<>
|
||||
<Button>Dupliquer S‑32</Button>
|
||||
<Button>Imprimer</Button>
|
||||
<Button variant="primary">Publier la semaine</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="warn">2 alertes de convention</Badge>
|
||||
<Badge tone="info">Samedi non publié</Badge>
|
||||
<Badge tone="neutral">5 besoins non couverts</Badge>
|
||||
</div>
|
||||
|
||||
<WeekGrid
|
||||
days={WEEK_DAYS}
|
||||
rows={WEEK_ROWS}
|
||||
unassignedRow={UNASSIGNED_ROW}
|
||||
/>
|
||||
|
||||
<PosteLegend />
|
||||
</PageBody>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Légende des postes.
|
||||
*
|
||||
* Elle n'est pas décorative : la couleur seule ne dit pas quel poste est
|
||||
* lequel, et le code à trois lettres imprimé dans chaque bloc n'a de sens que
|
||||
* si sa correspondance est visible sur la même page.
|
||||
*/
|
||||
function PosteLegend() {
|
||||
return (
|
||||
<section className="rounded-3 border border-line-1 bg-surface p-4">
|
||||
<h2 className="mb-3 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
Postes
|
||||
</h2>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{POSTE_CODES.map((code) => {
|
||||
const tokens = posteTokens(code);
|
||||
return (
|
||||
<li
|
||||
key={code}
|
||||
className="flex items-center gap-1.5 rounded-2 px-2 py-1 text-micro"
|
||||
style={{
|
||||
background: tokens.bg,
|
||||
color: tokens.fg,
|
||||
border: `1px solid ${tokens.edge}`,
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold tracking-wide">
|
||||
{code.toUpperCase()}
|
||||
</span>
|
||||
<span>{POSTE_LABELS[code]}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+17
-5
@@ -1,4 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
import { THEME_INIT_SCRIPT } from '@/components/shell/ThemeToggle';
|
||||
|
||||
import './globals.css';
|
||||
|
||||
@@ -7,14 +10,23 @@ export const metadata: Metadata = {
|
||||
description: 'Gestion du personnel, des plannings et des temps.',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
// Le nonce est posé par requête dans src/proxy.ts. C'est la seule façon
|
||||
// d'exécuter un script inline sous notre CSP, qui n'autorise pas
|
||||
// 'unsafe-inline'.
|
||||
const nonce = (await headers()).get('x-nonce') ?? undefined;
|
||||
|
||||
return (
|
||||
<html lang="fr">
|
||||
<body className="min-h-dvh bg-neutral-50 text-neutral-900 antialiased">
|
||||
{children}
|
||||
</body>
|
||||
<html lang="fr" data-theme="light" suppressHydrationWarning>
|
||||
<head>
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }}
|
||||
/>
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { AbsenceBar } from '@/components/planning/AbsenceBar';
|
||||
import { CounterStrip } from '@/components/planning/CounterStrip';
|
||||
import { ShiftChip } from '@/components/planning/ShiftChip';
|
||||
import { computeWeekCounters } from '@/domain/counters/week';
|
||||
import { cx } from '@/lib/cx';
|
||||
import {
|
||||
fullName,
|
||||
initials,
|
||||
timeRange,
|
||||
type DemoWeekRow,
|
||||
} from '@/lib/demo/types';
|
||||
|
||||
export interface WeekGridProps {
|
||||
days: readonly string[];
|
||||
rows: DemoWeekRow[];
|
||||
unassignedRow?: DemoWeekRow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grille hebdomadaire : lignes salariés × sept jours.
|
||||
*
|
||||
* Le samedi et le dimanche portent un fond distinct. Ce n'est pas décoratif :
|
||||
* en commerce de détail, ce sont les jours à contreparties — dimanche du maire
|
||||
* majoré et repos compensateur — et les distinguer d'un coup d'œil évite de les
|
||||
* planifier par inadvertance.
|
||||
*/
|
||||
export function WeekGrid({ days, rows, unassignedRow }: WeekGridProps) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-3 border border-line-1 bg-surface">
|
||||
<div className="min-w-[1040px]">
|
||||
<GridHeader days={days} />
|
||||
{unassignedRow ? <Row row={unassignedRow} days={days} /> : null}
|
||||
{rows.map((row) => (
|
||||
<Row key={row.employee.id} row={row} days={days} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GridHeader({ days }: { days: readonly string[] }) {
|
||||
return (
|
||||
<div className="sticky top-0 z-20 flex border-b border-line-2 bg-surface-2">
|
||||
<div className="w-64 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="grid flex-1 grid-cols-7">
|
||||
{days.map((day, index) => (
|
||||
<div
|
||||
key={day}
|
||||
className={cx(
|
||||
'border-r border-line-1 px-2 py-2 text-center text-xs font-semibold last:border-r-0',
|
||||
index > 4 ? 'bg-surface-3 text-ink-2' : 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ row, days }: { row: DemoWeekRow; days: readonly string[] }) {
|
||||
const counters = computeWeekCounters(row.counters);
|
||||
const name = row.unassigned ? 'Non assigné' : fullName(row.employee);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'flex border-b border-line-1 last:border-b-0',
|
||||
row.unassigned && 'bg-surface-2',
|
||||
)}
|
||||
>
|
||||
<div className="w-64 flex-none border-r border-line-1 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{row.unassigned ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-6 items-center justify-center rounded-full border border-dashed border-line-3 text-micro text-ink-3"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-6 items-center justify-center rounded-full bg-surface-3 text-micro font-semibold text-ink-2"
|
||||
>
|
||||
{initials(row.employee)}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0">
|
||||
<span
|
||||
className={cx(
|
||||
'block truncate text-sm font-medium',
|
||||
row.unassigned ? 'text-ink-3' : 'text-ink-1',
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<span className="block truncate text-micro text-ink-3">
|
||||
{row.employee.job}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<CounterStrip
|
||||
counters={counters}
|
||||
forfaitJours={row.employee.forfaitJours ?? false}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative grid flex-1 grid-cols-7">
|
||||
{row.absence ? (
|
||||
<AbsenceBar
|
||||
kind={row.absence.kind}
|
||||
label={row.absence.label}
|
||||
duration={`${row.absence.span} j`}
|
||||
startDay={row.absence.startDay}
|
||||
span={row.absence.span}
|
||||
className="top-1.5"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{days.map((day, index) => {
|
||||
const shifts = row.days[index] ?? [];
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
className={cx(
|
||||
'flex min-h-[var(--row-h)] flex-col gap-1 border-r border-line-1 p-1 last:border-r-0',
|
||||
index > 4 && 'bg-surface-2',
|
||||
row.absence &&
|
||||
index >= row.absence.startDay &&
|
||||
index < row.absence.startDay + row.absence.span &&
|
||||
'pt-7',
|
||||
)}
|
||||
>
|
||||
{shifts.map((shift, position) => (
|
||||
<ShiftChip
|
||||
key={`${day}-${position}`}
|
||||
poste={shift.poste}
|
||||
time={timeRange(shift)}
|
||||
state={shift.state ?? 'published'}
|
||||
{...(shift.delta ? { delta: shift.delta } : {})}
|
||||
{...(shift.alert ? { alert: shift.alert } : {})}
|
||||
{...(shift.breakMinutes
|
||||
? { breakMinutes: shift.breakMinutes }
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ThemeToggle } from '@/components/shell/ThemeToggle';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cx } from '@/lib/cx';
|
||||
import {
|
||||
isActive,
|
||||
NAVIGATION,
|
||||
sectionForPath,
|
||||
type NavItem,
|
||||
} from '@/components/shell/navigation';
|
||||
|
||||
export interface AppShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const section = sectionForPath(pathname);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col bg-canvas text-ink-1">
|
||||
<header className="flex h-13 flex-none items-center gap-5 border-b border-line-2 bg-surface px-5">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2.5 rounded-2 text-lg font-semibold tracking-[-0.015em]"
|
||||
>
|
||||
<span aria-hidden className="size-5 rounded-2 bg-accent" />
|
||||
PlanFlow
|
||||
</Link>
|
||||
|
||||
<nav aria-label="Sections" className="flex h-full gap-0.5">
|
||||
{NAVIGATION.map((entry) => {
|
||||
const target = entry.items.find((item) => item.href);
|
||||
const current = entry.id === section.id;
|
||||
return (
|
||||
<Link
|
||||
key={entry.id}
|
||||
href={target?.href ?? '/'}
|
||||
aria-current={current ? 'page' : undefined}
|
||||
className={cx(
|
||||
'flex h-full items-center border-b-2 px-3.5 text-sm font-medium whitespace-nowrap',
|
||||
current
|
||||
? 'border-accent text-ink-1'
|
||||
: 'border-transparent text-ink-3 hover:text-ink-1',
|
||||
)}
|
||||
>
|
||||
{entry.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<span className="flex-1" />
|
||||
|
||||
<Button size="sm">
|
||||
Nantes Atlantis
|
||||
<span aria-hidden className="text-[10px] text-ink-3">
|
||||
▾
|
||||
</span>
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-ink"
|
||||
>
|
||||
MR
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<aside className="flex w-57 flex-none flex-col gap-0.5 overflow-auto border-r border-line-1 bg-surface p-3 pt-4">
|
||||
<p className="px-2.5 pb-2.5 text-micro font-semibold tracking-[0.08em] text-ink-3 uppercase">
|
||||
{section.label}
|
||||
</p>
|
||||
{section.items.map((item) => (
|
||||
<SidebarLink key={item.id} item={item} pathname={pathname} />
|
||||
))}
|
||||
<span className="flex-1" />
|
||||
<p className="border-t border-line-1 px-2.5 pt-3 text-micro leading-relaxed text-ink-3">
|
||||
Instance auto-hébergée
|
||||
<br />
|
||||
Maison Rivage · 34 établissements
|
||||
</p>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarLink({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||
const current = item.href ? isActive(item.href, pathname) : false;
|
||||
const className = cx(
|
||||
'flex w-full items-center justify-between gap-2 rounded-2 px-2.5 py-1.5 text-left text-sm',
|
||||
'transition-colors duration-[var(--d-1)] ease-organic',
|
||||
current
|
||||
? 'bg-accent-soft font-semibold text-accent-soft-ink'
|
||||
: 'text-ink-2 hover:bg-surface-2',
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className="truncate">{item.label}</span>
|
||||
{item.badge ? (
|
||||
<Badge subtle tone={current ? 'neutral' : 'neutral'}>
|
||||
{item.badge}
|
||||
</Badge>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (!item.href) {
|
||||
return (
|
||||
<Link
|
||||
href={`/bientot?ecran=${encodeURIComponent(item.label)}`}
|
||||
className={cx(className, 'text-ink-3')}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={current ? 'page' : undefined}
|
||||
className={className}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-[-0.018em]">{title}</h1>
|
||||
{subtitle ? (
|
||||
<p className="mt-1 text-sm text-ink-2">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="flex-1" />
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageBody({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-5 px-6 pt-5 pb-16">{children}</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
|
||||
export const THEME_STORAGE_KEY = 'planflow.theme';
|
||||
|
||||
/**
|
||||
* Script appliqué avant le premier rendu.
|
||||
*
|
||||
* Sans lui, la page s'affiche en clair puis bascule — un flash blanc en pleine
|
||||
* figure pour qui travaille en sombre. Il est injecté avec le nonce de la
|
||||
* requête, seule façon d'exécuter de l'inline sous notre CSP (src/proxy.ts).
|
||||
*/
|
||||
export const THEME_INIT_SCRIPT = `
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
|
||||
var theme = stored === 'dark' || stored === 'light'
|
||||
? stored
|
||||
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
})();
|
||||
`.trim();
|
||||
|
||||
function readTheme(): Theme {
|
||||
if (typeof document === 'undefined') return 'light';
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
? 'dark'
|
||||
: 'light';
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>('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()), []);
|
||||
|
||||
function toggle() {
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', next);
|
||||
try {
|
||||
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.
|
||||
}
|
||||
setTheme(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
onClick={toggle}
|
||||
aria-pressed={theme === 'dark'}
|
||||
>
|
||||
Thème {theme === 'dark' ? 'sombre' : 'clair'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Modèle de navigation, repris de l'export Claude Design.
|
||||
*
|
||||
* Deux niveaux : une section dans l'en-tête, ses entrées dans la barre
|
||||
* latérale. Les badges portent un décompte à traiter.
|
||||
*
|
||||
* Les entrées sans `href` correspondent à des écrans du périmètre qui n'ont pas
|
||||
* encore d'implémentation : elles restent visibles pour que la navigation
|
||||
* reflète le produit réel, et mènent à un écran d'attente explicite plutôt qu'à
|
||||
* une page blanche.
|
||||
*/
|
||||
|
||||
export interface NavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export interface NavSection {
|
||||
id: string;
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
export const NAVIGATION: NavSection[] = [
|
||||
{
|
||||
id: 'apercu',
|
||||
label: 'Aperçu',
|
||||
items: [
|
||||
{ id: 'taches', label: 'Tâches RH', href: '/' },
|
||||
{ id: 'entrees', label: 'Entrées et sorties' },
|
||||
{ id: 'profils', label: 'Profils incomplets', badge: '6' },
|
||||
{ id: 'soldes-cp', label: 'Compteurs de congés payés' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'plannings',
|
||||
label: 'Plannings',
|
||||
items: [
|
||||
{ id: 'semaine', label: 'Vue semaine', href: '/planning/semaine' },
|
||||
{ id: 'jour', label: 'Vue jour', href: '/planning/jour' },
|
||||
{ id: 'besoins', label: 'Besoins non couverts', badge: '5' },
|
||||
{ id: 'modeles', label: "Modèles d'ouverture" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'equipe',
|
||||
label: 'Équipe',
|
||||
items: [
|
||||
{ id: 'membres', label: 'Membres', href: '/equipe' },
|
||||
{ id: 'fiche', label: 'Fiche salarié', href: '/equipe/camille-ferrand' },
|
||||
{ id: 'contrats', label: 'Modifications de contrat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'conges',
|
||||
label: 'Congés',
|
||||
items: [
|
||||
{ id: 'calendrier', label: 'Calendrier des absences', href: '/conges' },
|
||||
{ id: 'attente', label: 'Demandes en attente', href: '/conges#attente', badge: '3' },
|
||||
{ id: 'politiques', label: 'Politiques de congés' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rapports',
|
||||
label: 'Rapports',
|
||||
items: [
|
||||
{ id: 'heures', label: 'Heures travaillées' },
|
||||
{ id: 'paie', label: 'Préparation de paie' },
|
||||
{ id: 'activite', label: "Journal d'activité" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reglages',
|
||||
label: 'Réglages',
|
||||
items: [
|
||||
{ id: 'convention', label: 'Convention collective' },
|
||||
{ id: 'postes', label: 'Postes et étiquettes' },
|
||||
{ id: 'sites', label: 'Établissements' },
|
||||
{ id: 'roles', label: 'Rôles et permissions' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Section contenant la route donnée, ou la première par défaut. */
|
||||
export function sectionForPath(pathname: string): NavSection {
|
||||
const match = NAVIGATION.find((section) =>
|
||||
section.items.some((item) => item.href && isActive(item.href, pathname)),
|
||||
);
|
||||
return match ?? (NAVIGATION[0] as NavSection);
|
||||
}
|
||||
|
||||
export function isActive(href: string, pathname: string): boolean {
|
||||
const base = href.split('#')[0] ?? href;
|
||||
if (base === '/') return pathname === '/';
|
||||
return pathname === base || pathname.startsWith(`${base}/`);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Données de démonstration
|
||||
|
||||
**Entièrement fictives.** Aucune donnée réelle de salarié ne doit entrer ici :
|
||||
ce répertoire est versionné, lisible par tous, et n'offre aucune des garanties
|
||||
que `PLAN.md` §3.6 exige pour un dossier RH — chiffrement au repos,
|
||||
journalisation des accès, durées de conservation.
|
||||
|
||||
Ce module existe parce que les écrans ont été construits avant WP-01 (tenancy,
|
||||
identité, base de données). Il tient la forme des données pour que la mise en
|
||||
page soit réelle plutôt que devinée.
|
||||
|
||||
**Remplacement.** Chaque écran importe depuis `@/lib/demo/...` et rien d'autre.
|
||||
Au fil des lots, ces imports sont remplacés un à un par des requêtes serveur
|
||||
scopées. Le jour où le répertoire disparaît, il ne reste rien à nettoyer
|
||||
ailleurs : c'est la seule raison pour laquelle il est isolé plutôt que dispersé
|
||||
dans les composants.
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Tone } from '@/components/ui/Badge';
|
||||
|
||||
export interface DemoTile {
|
||||
label: string;
|
||||
value: string;
|
||||
tone: Tone;
|
||||
}
|
||||
|
||||
export const TILES: DemoTile[] = [
|
||||
{ label: 'Profils incomplets', value: '6', tone: 'warn' },
|
||||
{ label: 'Fins de période d’essai', value: '2', tone: 'info' },
|
||||
{ label: 'Documents à signer', value: '11', tone: 'accent' },
|
||||
{ label: 'Titres de séjour à renouveler', value: '1', tone: 'danger' },
|
||||
];
|
||||
|
||||
export interface DemoEntry {
|
||||
initials: string;
|
||||
name: string;
|
||||
job: string;
|
||||
date: string;
|
||||
state: string;
|
||||
tone: Tone;
|
||||
}
|
||||
|
||||
export const ENTRIES: DemoEntry[] = [
|
||||
{
|
||||
initials: 'YB',
|
||||
name: 'Yanis Bouchard',
|
||||
job: 'Vendeur univers maison · CDI 35 h',
|
||||
date: '17 août',
|
||||
state: 'Dossier complet',
|
||||
tone: 'ok',
|
||||
},
|
||||
{
|
||||
initials: 'MR',
|
||||
name: 'Maëlys Renard',
|
||||
job: 'Hôtesse de caisse · CDD 24 h',
|
||||
date: '24 août',
|
||||
state: '2 pièces manquantes',
|
||||
tone: 'warn',
|
||||
},
|
||||
{
|
||||
initials: 'TN',
|
||||
name: 'Théo Nguyen',
|
||||
job: 'Employé réserve · CDI 35 h',
|
||||
date: '1 sept.',
|
||||
state: 'Contrat à signer',
|
||||
tone: 'info',
|
||||
},
|
||||
{
|
||||
initials: 'SL',
|
||||
name: 'Sarah Lemoine',
|
||||
job: 'Vendeuse décoration · CDI 30 h',
|
||||
date: '7 sept.',
|
||||
state: 'Dossier complet',
|
||||
tone: 'ok',
|
||||
},
|
||||
];
|
||||
|
||||
export interface DemoTodo {
|
||||
dot: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
when: string;
|
||||
}
|
||||
|
||||
export const TODOS: DemoTodo[] = [
|
||||
{
|
||||
dot: 'var(--color-danger)',
|
||||
title: '3 alertes de convention sur la semaine 33',
|
||||
detail:
|
||||
'Repos quotidien inférieur à 11 h pour Rémi Chartier (mardi) et amplitude dépassée pour Inès Bakhti (samedi).',
|
||||
when: 'Aujourd’hui',
|
||||
},
|
||||
{
|
||||
dot: 'var(--color-warn)',
|
||||
title: 'Semaine 34 non publiée',
|
||||
detail:
|
||||
'La publication doit intervenir au moins 7 jours avant le premier créneau, soit avant le 10 août 18:00.',
|
||||
when: 'J−0',
|
||||
},
|
||||
{
|
||||
dot: 'var(--color-info)',
|
||||
title: '3 demandes de congés en attente',
|
||||
detail:
|
||||
'Nour Zaidi (5 jours), Rémi Chartier (2 jours), Alix Perron (1 jour).',
|
||||
when: '2 j',
|
||||
},
|
||||
{
|
||||
dot: 'var(--color-ink-3)',
|
||||
title: '11 bulletins de paie à diffuser',
|
||||
detail:
|
||||
'Période de juillet 2026, contrôlée et validée par le service paie le 5 août.',
|
||||
when: '5 j',
|
||||
},
|
||||
{
|
||||
dot: 'var(--color-ink-3)',
|
||||
title: 'Compteurs de récupération à solder',
|
||||
detail:
|
||||
'4 salariés dépassent 12 h de récupération non prise depuis plus de deux mois.',
|
||||
when: '12 j',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { DemoEmployee } from '@/lib/demo/types';
|
||||
|
||||
/** Effectif fictif de l'établissement Nantes Atlantis. */
|
||||
export const EMPLOYEES: DemoEmployee[] = [
|
||||
{
|
||||
id: 'camille-ferrand',
|
||||
firstName: 'Camille',
|
||||
lastName: 'Ferrand',
|
||||
job: 'Responsable univers maison',
|
||||
poste: 'enc',
|
||||
contract: 'CDI 39 h',
|
||||
since: '4 mars 2021',
|
||||
status: 'actif',
|
||||
},
|
||||
{
|
||||
id: 'jonas-meyer',
|
||||
firstName: 'Jonas',
|
||||
lastName: 'Meyer',
|
||||
job: 'Adjoint magasin',
|
||||
poste: 'enc',
|
||||
contract: 'CDI · forfait jours',
|
||||
since: '12 sept. 2019',
|
||||
status: 'actif',
|
||||
forfaitJours: true,
|
||||
},
|
||||
{
|
||||
id: 'remi-chartier',
|
||||
firstName: 'Rémi',
|
||||
lastName: 'Chartier',
|
||||
job: 'Vendeur',
|
||||
poste: 'vte',
|
||||
contract: 'CDI 35 h',
|
||||
since: '2 juin 2022',
|
||||
status: 'actif',
|
||||
},
|
||||
{
|
||||
id: 'ines-bakhti',
|
||||
firstName: 'Inès',
|
||||
lastName: 'Bakhti',
|
||||
job: 'Hôtesse de caisse',
|
||||
poste: 'cai',
|
||||
contract: 'CDI 35 h',
|
||||
since: '18 janv. 2023',
|
||||
status: 'actif',
|
||||
},
|
||||
{
|
||||
id: 'nour-zaidi',
|
||||
firstName: 'Nour',
|
||||
lastName: 'Zaidi',
|
||||
job: 'Vendeuse décoration',
|
||||
poste: 'vte',
|
||||
contract: 'CDI 30 h',
|
||||
since: '5 oct. 2020',
|
||||
status: 'actif',
|
||||
},
|
||||
{
|
||||
id: 'alix-perron',
|
||||
firstName: 'Alix',
|
||||
lastName: 'Perron',
|
||||
job: 'Employé réserve',
|
||||
poste: 'res',
|
||||
contract: 'CDI 35 h',
|
||||
since: '9 mai 2024',
|
||||
status: 'actif',
|
||||
},
|
||||
{
|
||||
id: 'elsa-tavares',
|
||||
firstName: 'Elsa',
|
||||
lastName: 'Tavares',
|
||||
job: 'Vendeuse',
|
||||
poste: 'vte',
|
||||
contract: 'CDD 24 h',
|
||||
since: '3 févr. 2026',
|
||||
status: 'essai',
|
||||
},
|
||||
{
|
||||
id: 'malik-ouedraogo',
|
||||
firstName: 'Malik',
|
||||
lastName: 'Ouedraogo',
|
||||
job: 'Employé logistique',
|
||||
poste: 'liv',
|
||||
contract: 'CDI 35 h',
|
||||
since: '21 nov. 2018',
|
||||
status: 'actif',
|
||||
},
|
||||
{
|
||||
id: 'chloe-vasseur',
|
||||
firstName: 'Chloé',
|
||||
lastName: 'Vasseur',
|
||||
job: 'Vendeuse',
|
||||
poste: 'vte',
|
||||
contract: 'CDI 20 h',
|
||||
since: '14 juil. 2025',
|
||||
status: 'actif',
|
||||
},
|
||||
{
|
||||
id: 'yanis-bouchard',
|
||||
firstName: 'Yanis',
|
||||
lastName: 'Bouchard',
|
||||
job: 'Vendeur univers maison',
|
||||
poste: 'vte',
|
||||
contract: 'CDI 35 h',
|
||||
since: '17 août 2026',
|
||||
status: 'essai',
|
||||
},
|
||||
{
|
||||
id: 'sarah-lemoine',
|
||||
firstName: 'Sarah',
|
||||
lastName: 'Lemoine',
|
||||
job: 'Vendeuse décoration',
|
||||
poste: 'vte',
|
||||
contract: 'CDI 30 h',
|
||||
since: '7 sept. 2026',
|
||||
status: 'essai',
|
||||
},
|
||||
{
|
||||
id: 'hugo-delaunay',
|
||||
firstName: 'Hugo',
|
||||
lastName: 'Delaunay',
|
||||
job: 'Employé retrait web',
|
||||
poste: 'clc',
|
||||
contract: 'CDI 28 h',
|
||||
since: '2 avr. 2023',
|
||||
status: 'sortie',
|
||||
},
|
||||
];
|
||||
|
||||
export function findEmployee(id: string): DemoEmployee | undefined {
|
||||
return EMPLOYEES.find((employee) => employee.id === id);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { findEmployee } from '@/lib/demo/equipe';
|
||||
import type { DemoEmployee, DemoShift, DemoWeekRow } from '@/lib/demo/types';
|
||||
|
||||
const h = (hours: number, minutes = 0) => hours * 60 + minutes;
|
||||
|
||||
function pick(id: string): DemoEmployee {
|
||||
const employee = findEmployee(id);
|
||||
if (!employee) throw new Error(`Salarié de démonstration inconnu : ${id}`);
|
||||
return employee;
|
||||
}
|
||||
|
||||
const NONE: DemoShift[] = [];
|
||||
|
||||
export const WEEK_DAYS = [
|
||||
'Lun. 10',
|
||||
'Mar. 11',
|
||||
'Mer. 12',
|
||||
'Jeu. 13',
|
||||
'Ven. 14',
|
||||
'Sam. 15',
|
||||
'Dim. 16',
|
||||
] as const;
|
||||
|
||||
export const WEEK_LABEL = 'Semaine 33 · 10 – 16 août 2026';
|
||||
|
||||
export const WEEK_ROWS: DemoWeekRow[] = [
|
||||
{
|
||||
employee: pick('camille-ferrand'),
|
||||
counters: {
|
||||
contractMinutes: h(39),
|
||||
plannedMinutes: h(39),
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 1,
|
||||
restDays: 2,
|
||||
},
|
||||
days: [
|
||||
[{ poste: 'enc', start: h(8), end: h(16), state: 'validated' }],
|
||||
[{ poste: 'enc', start: h(8), end: h(16), state: 'validated' }],
|
||||
[
|
||||
{ poste: 'for', start: h(9), end: h(12, 30), state: 'validated' },
|
||||
{ poste: 'enc', start: h(13, 30), end: h(17), state: 'validated' },
|
||||
],
|
||||
[{ poste: 'enc', start: h(11), end: h(19), state: 'validated' }],
|
||||
[{ poste: 'enc', start: h(11), end: h(19), state: 'validated' }],
|
||||
NONE,
|
||||
NONE,
|
||||
],
|
||||
},
|
||||
{
|
||||
employee: pick('jonas-meyer'),
|
||||
counters: {
|
||||
contractMinutes: 0,
|
||||
plannedMinutes: h(38, 30),
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 1,
|
||||
restDays: 2,
|
||||
},
|
||||
days: [
|
||||
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
|
||||
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
|
||||
[
|
||||
{ poste: 'inv', start: h(6, 30), end: h(11), state: 'validated' },
|
||||
{ poste: 'enc', start: h(11, 30), end: h(15), state: 'validated' },
|
||||
],
|
||||
[{ poste: 'enc', start: h(12), end: h(20), state: 'validated' }],
|
||||
NONE,
|
||||
[{ poste: 'enc', start: h(9), end: h(19), state: 'draft' }],
|
||||
NONE,
|
||||
],
|
||||
},
|
||||
{
|
||||
employee: pick('remi-chartier'),
|
||||
counters: {
|
||||
contractMinutes: h(35),
|
||||
plannedMinutes: h(36, 30),
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 2,
|
||||
restDays: 2,
|
||||
},
|
||||
days: [
|
||||
[
|
||||
{ poste: 'vte', start: h(9, 30), end: h(13), state: 'validated' },
|
||||
{ poste: 'cai', start: h(14), end: h(18, 30), state: 'validated' },
|
||||
],
|
||||
[
|
||||
{
|
||||
poste: 'cai',
|
||||
start: h(12),
|
||||
end: h(20),
|
||||
state: 'alert',
|
||||
alert: 'Repos < 11 h',
|
||||
},
|
||||
],
|
||||
[{ poste: 'vte', start: h(10), end: h(14), state: 'validated' }],
|
||||
NONE,
|
||||
[
|
||||
{ poste: 'vte', start: h(9), end: h(13), state: 'validated' },
|
||||
{ poste: 'cab', start: h(14), end: h(18), state: 'validated' },
|
||||
],
|
||||
[{ poste: 'cai', start: h(9, 30), end: h(19, 30), state: 'draft' }],
|
||||
NONE,
|
||||
],
|
||||
},
|
||||
{
|
||||
employee: pick('ines-bakhti'),
|
||||
counters: {
|
||||
contractMinutes: h(35),
|
||||
plannedMinutes: h(35),
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 1,
|
||||
restDays: 2,
|
||||
},
|
||||
days: [
|
||||
[{ poste: 'cai', start: h(9), end: h(17), state: 'validated' }],
|
||||
[{ poste: 'cai', start: h(9), end: h(17), state: 'validated' }],
|
||||
NONE,
|
||||
[{ poste: 'cai', start: h(11, 30), end: h(19, 30), state: 'validated' }],
|
||||
[
|
||||
{ poste: 'cai', start: h(9), end: h(14), state: 'validated' },
|
||||
{ poste: 'clc', start: h(14, 30), end: h(17, 30), state: 'validated' },
|
||||
],
|
||||
[
|
||||
{
|
||||
poste: 'cai',
|
||||
start: h(9, 30),
|
||||
end: h(19, 30),
|
||||
state: 'alert',
|
||||
alert: 'Amplitude > 10 h',
|
||||
},
|
||||
],
|
||||
NONE,
|
||||
],
|
||||
},
|
||||
{
|
||||
employee: pick('nour-zaidi'),
|
||||
counters: {
|
||||
contractMinutes: h(30),
|
||||
plannedMinutes: h(12),
|
||||
absenceMinutes: h(18),
|
||||
sundaysWorked: 0,
|
||||
restDays: 5,
|
||||
},
|
||||
absence: { kind: 'cp', label: 'Congés payés', startDay: 0, span: 5 },
|
||||
days: [
|
||||
NONE,
|
||||
NONE,
|
||||
NONE,
|
||||
NONE,
|
||||
NONE,
|
||||
[{ poste: 'vte', start: h(13), end: h(19), state: 'draft' }],
|
||||
[{ poste: 'vte', start: h(11), end: h(17), state: 'unpublished' }],
|
||||
],
|
||||
},
|
||||
{
|
||||
employee: pick('alix-perron'),
|
||||
counters: {
|
||||
contractMinutes: h(35),
|
||||
plannedMinutes: h(33, 15),
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 0,
|
||||
restDays: 2,
|
||||
},
|
||||
days: [
|
||||
[
|
||||
{ poste: 'liv', start: h(6), end: h(11), state: 'validated' },
|
||||
{ poste: 'res', start: h(11, 30), end: h(14, 45), state: 'validated' },
|
||||
],
|
||||
[{ poste: 'res', start: h(6), end: h(13), state: 'validated' }],
|
||||
[{ poste: 'liv', start: h(6), end: h(11), state: 'validated' }],
|
||||
NONE,
|
||||
[{ poste: 'res', start: h(6), end: h(13), state: 'validated' }],
|
||||
[{ poste: 'rss', start: h(7), end: h(13), state: 'draft' }],
|
||||
NONE,
|
||||
],
|
||||
},
|
||||
{
|
||||
employee: pick('elsa-tavares'),
|
||||
counters: {
|
||||
contractMinutes: h(24),
|
||||
plannedMinutes: h(25, 30),
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 2,
|
||||
restDays: 3,
|
||||
},
|
||||
days: [
|
||||
NONE,
|
||||
NONE,
|
||||
[{ poste: 'vte', start: h(14), end: h(19), state: 'validated' }],
|
||||
[{ poste: 'cab', start: h(14), end: h(19), state: 'validated' }],
|
||||
[{ poste: 'vte', start: h(14), end: h(19, 30), state: 'validated' }],
|
||||
[{ poste: 'cai', start: h(13, 30), end: h(19, 30), state: 'draft' }],
|
||||
[{ poste: 'vte', start: h(10), end: h(14), state: 'unpublished' }],
|
||||
],
|
||||
},
|
||||
{
|
||||
employee: pick('malik-ouedraogo'),
|
||||
counters: {
|
||||
contractMinutes: h(35),
|
||||
plannedMinutes: h(31),
|
||||
absenceMinutes: h(14),
|
||||
sundaysWorked: 0,
|
||||
restDays: 2,
|
||||
},
|
||||
absence: { kind: 'maladie', label: 'Arrêt maladie', startDay: 3, span: 2 },
|
||||
days: [
|
||||
[
|
||||
{ poste: 'liv', start: h(5, 30), end: h(10, 30), state: 'validated' },
|
||||
{ poste: 'res', start: h(11), end: h(14), state: 'validated' },
|
||||
],
|
||||
[{ poste: 'rss', start: h(6), end: h(13, 30), state: 'validated' }],
|
||||
[{ poste: 'inv', start: h(6), end: h(13), state: 'validated' }],
|
||||
NONE,
|
||||
NONE,
|
||||
[{ poste: 'liv', start: h(6), end: h(12, 30), state: 'draft' }],
|
||||
NONE,
|
||||
],
|
||||
},
|
||||
{
|
||||
employee: pick('chloe-vasseur'),
|
||||
counters: {
|
||||
contractMinutes: h(20),
|
||||
plannedMinutes: h(21, 15),
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 2,
|
||||
restDays: 4,
|
||||
},
|
||||
days: [
|
||||
NONE,
|
||||
NONE,
|
||||
[{ poste: 'for', start: h(9), end: h(13), state: 'validated' }],
|
||||
NONE,
|
||||
[{ poste: 'vte', start: h(15), end: h(19, 30), state: 'validated' }],
|
||||
[{ poste: 'cai', start: h(11), end: h(18), state: 'draft' }],
|
||||
[{ poste: 'vte', start: h(11), end: h(15), state: 'unpublished' }],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Ligne des besoins sans titulaire, affichée en tête de grille. */
|
||||
export const UNASSIGNED_ROW: DemoWeekRow = {
|
||||
employee: {
|
||||
id: 'non-assigne',
|
||||
firstName: '',
|
||||
lastName: 'Non assigné',
|
||||
job: 'Besoin non couvert',
|
||||
poste: 'cai',
|
||||
contract: '—',
|
||||
since: '—',
|
||||
status: 'actif',
|
||||
},
|
||||
unassigned: true,
|
||||
counters: {
|
||||
contractMinutes: 0,
|
||||
plannedMinutes: h(14),
|
||||
absenceMinutes: 0,
|
||||
sundaysWorked: 0,
|
||||
restDays: 0,
|
||||
},
|
||||
days: [
|
||||
NONE,
|
||||
[{ poste: 'net', start: h(20), end: h(22), state: 'unassigned' }],
|
||||
NONE,
|
||||
[{ poste: 'clc', start: h(16), end: h(20), state: 'unassigned' }],
|
||||
[{ poste: 'cai', start: h(17), end: h(20), state: 'unassigned' }],
|
||||
[{ poste: 'cai', start: h(14), end: h(19), state: 'unassigned' }],
|
||||
[{ poste: 'clc', start: h(13), end: h(17), state: 'unassigned' }],
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { AbsenceKind } from '@/components/planning/AbsenceBar';
|
||||
import type { ShiftState } from '@/components/planning/ShiftChip';
|
||||
import type { WeekCounters } from '@/domain/counters/week';
|
||||
import type { PosteCode } from '@/lib/design/postes';
|
||||
|
||||
export interface DemoShift {
|
||||
poste: PosteCode;
|
||||
/** Début et fin en minutes depuis minuit, jour local. */
|
||||
start: number;
|
||||
end: number;
|
||||
breakMinutes?: number;
|
||||
state?: ShiftState;
|
||||
delta?: string;
|
||||
alert?: string;
|
||||
}
|
||||
|
||||
export interface DemoAbsence {
|
||||
kind: AbsenceKind;
|
||||
label: string;
|
||||
/** 0 = lundi. */
|
||||
startDay: number;
|
||||
span: number;
|
||||
}
|
||||
|
||||
export interface DemoEmployee {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
job: string;
|
||||
poste: PosteCode;
|
||||
contract: string;
|
||||
since: string;
|
||||
status: 'actif' | 'essai' | 'sortie';
|
||||
forfaitJours?: boolean;
|
||||
}
|
||||
|
||||
export interface DemoWeekRow {
|
||||
employee: DemoEmployee;
|
||||
counters: WeekCounters;
|
||||
/** Sept entrées, lundi → dimanche. */
|
||||
days: DemoShift[][];
|
||||
absence?: DemoAbsence;
|
||||
unassigned?: boolean;
|
||||
}
|
||||
|
||||
export function fullName(employee: DemoEmployee): string {
|
||||
return `${employee.firstName} ${employee.lastName}`;
|
||||
}
|
||||
|
||||
export function initials(employee: DemoEmployee): string {
|
||||
return `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`;
|
||||
}
|
||||
|
||||
/** « 09:00 » depuis des minutes après minuit. */
|
||||
export function clock(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function timeRange(shift: DemoShift): string {
|
||||
return `${clock(shift.start)}–${clock(shift.end)}`;
|
||||
}
|
||||
Reference in new issue
Block a user