Files
planflow/src/components/shell/navigation.ts
T
Claude 93852c2602 Fix four defects the screenshots and tests exposed
Verifying the rendered pages rather than the build turned up real bugs:

- The WP-00 health page still sat at src/app/page.tsx and silently won
  the route over the new Aperçu screen, so the home page was a database
  status readout. Moved to /api/sante, where a probe belongs, and wired
  into the compose healthcheck.
- The unassigned row showed a +14 h delta against a contract of zero,
  reading as an overshoot when it is simply the volume left to staff.
  It now shows what there is to fill.
- Two sidebar entries lit at once: an anchor link matched its own page,
  and /equipe matched an employee record. Highlighting now resolves to
  the most specific match, and a test asserts exactly one entry lights
  per screen.
- Section tabs with no built screen pointed at the home page, which
  reads as a broken tab. They now lead to their first entry's
  placeholder.

Also gives truncated compliance alerts a title attribute, so a narrow
cell no longer says there is a problem without saying which.

Playwright can reuse a preinstalled browser through
PLAYWRIGHT_CHROMIUM_PATH when its revision differs from the bundled one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cr9dkEHwbDgkWPnyGj1Rjv
2026-08-07 22:26:38 +00:00

142 lines
4.5 KiB
TypeScript

/**
* 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 active = activeItem(pathname);
const match = active
? NAVIGATION.find((section) =>
section.items.some((item) => item.id === active.id),
)
: undefined;
return match ?? (NAVIGATION[0] as NavSection);
}
/** Chemin vers l'écran d'attente d'une entrée non encore construite. */
export function placeholderHref(label: string): string {
return `/bientot?ecran=${encodeURIComponent(label)}`;
}
/**
* Destination d'un onglet de section.
*
* Une section dont aucun écran n'est construit ne doit pas renvoyer à
* l'accueil : le clic donnerait l'impression que l'onglet est cassé. Elle mène
* à l'écran d'attente de sa première entrée, qui dit ce qui viendra là.
*/
export function sectionHref(section: NavSection): string {
const built = section.items.find((item) => item.href);
if (built?.href) return built.href;
const first = section.items[0];
return first ? placeholderHref(first.label) : '/';
}
export function matches(href: string, pathname: string): boolean {
// Un lien d'ancre pointe à l'intérieur d'une page déjà représentée par une
// autre entrée : il ne concourt pas.
if (href.includes('#')) return false;
if (href === '/') return pathname === '/';
return pathname === href || pathname.startsWith(`${href}/`);
}
/**
* Entrée à mettre en évidence pour une route donnée.
*
* `/equipe` et `/equipe/camille-ferrand` correspondent tous deux quand on est
* sur une fiche. On retient la **plus spécifique** : deux entrées allumées à la
* fois, et le repère de position ne repère plus rien.
*/
export function activeItem(pathname: string): NavItem | undefined {
return NAVIGATION.flatMap((section) => section.items)
.filter((item) => item.href && matches(item.href, pathname))
.sort((a, b) => (b.href as string).length - (a.href as string).length)[0];
}
export function isActive(href: string, pathname: string): boolean {
const active = activeItem(pathname);
return active?.href === href;
}