Édition
- Le panneau de créneau sert aussi à la modification : déplacer un créneau,
c'est changer son jour, son salarié ou ses heures — exactement les champs de
la création. Deux écrans distincts finiraient par diverger.
- Duplication d'une semaine vers une autre, refusée si la destination contient
déjà des créneaux. Le report se fait **par heure locale**, pas par décalage de
sept jours : entre mars et avril, sept jours d'écart ne redonnent pas la même
heure.
Vues
- `/planning/etiquettes` : la semaine par poste. C'est la question du
responsable d'ouverture — « la caisse est-elle tenue samedi ? » — illisible
sur une grille dont les lignes sont des personnes. Les postes sans créneau
restent affichés : un poste vide toute la semaine est une information.
- `/planning/mois` : les heures par salarié et par jour, avec les journées de
plus de 10 h signalées. Vue de contrôle, pas d'édition.
- Les deux lisent `Shift` directement. Un test e2e pose un créneau et le
retrouve identique dans les quatre vues — c'est ce qui empêche l'une d'elles
de dériver vers son propre calcul.
Impression
- Feuille d'impression A4 paysage : les zones à défilement se déplient, les
lignes ne se coupent pas entre deux pages, et les aplats de poste sortent
(`print-color-adjust: exact`) sans être nécessaires à la lecture, puisque le
code du poste est imprimé dans chaque bloc.
- Pas de moteur PDF côté serveur : le navigateur sait paginer et enregistrer en
PDF. Un second moteur de rendu serait un second endroit où la grille peut
diverger de ce qui est à l'écran.
Accessibilité
- La grille porte enfin ses rôles ARIA (`grid`, `row`, `columnheader`,
`rowheader`, `gridcell`). Elle est faite de div pour la mise en page, mais se
lit comme un tableau : un lecteur d'écran doit pouvoir annoncer « ligne
Camille Ferrand, colonne mercredi ».
Cette structure a d'ailleurs révélé un test faux : `locator('div')` remontait au
conteneur de la grille, et le créneau du test atterrissait sur le premier
salarié de l'équipe au lieu de celui visé — une erreur invisible à l'écran.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
155 lines
4.5 KiB
TypeScript
155 lines
4.5 KiB
TypeScript
'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 { signOutAction } from '@/server/auth/actions';
|
|
import {
|
|
isActive,
|
|
NAVIGATION,
|
|
placeholderHref,
|
|
sectionForPath,
|
|
sectionHref,
|
|
type NavItem,
|
|
} from '@/components/shell/navigation';
|
|
|
|
export interface AppShellProps {
|
|
children: ReactNode;
|
|
initials: string;
|
|
fullName: string;
|
|
roleName: string;
|
|
accountName: string;
|
|
}
|
|
|
|
export function AppShell({
|
|
children,
|
|
initials,
|
|
fullName,
|
|
roleName,
|
|
accountName,
|
|
}: AppShellProps) {
|
|
const pathname = usePathname();
|
|
const section = sectionForPath(pathname);
|
|
|
|
return (
|
|
<div className="flex min-h-dvh flex-col bg-canvas text-ink-1">
|
|
<header data-print="hide" 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 current = entry.id === section.id;
|
|
return (
|
|
<Link
|
|
key={entry.id}
|
|
href={sectionHref(entry)}
|
|
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
|
|
className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-xs font-semibold text-accent-soft-ink"
|
|
title={`${fullName} · ${roleName}`}
|
|
>
|
|
<span aria-hidden>{initials}</span>
|
|
<span className="sr-only">{`${fullName}, ${roleName}`}</span>
|
|
</span>
|
|
<form action={signOutAction}>
|
|
<Button size="sm" type="submit">
|
|
Déconnexion
|
|
</Button>
|
|
</form>
|
|
</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 />
|
|
{accountName}
|
|
</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={placeholderHref(item.label)} className={cx(className, 'text-ink-3')}>
|
|
{content}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
href={item.href}
|
|
aria-current={current ? 'page' : undefined}
|
|
className={className}
|
|
>
|
|
{content}
|
|
</Link>
|
|
);
|
|
}
|