Files
planflow/src/components/shell/AppShell.tsx
T
Claude d9f55f866f WP-04 (suite) : vues par poste et par mois, duplication, édition, impression
É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
2026-08-08 07:20:36 +00:00

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>
);
}