Files
siop2/apps/web/src/layout/coquille.tsx
pr-daaif 4f3af5af4f feat(r0.11): apps/web — connexion + sélecteur démo, coquille, /design, client typé
- React 19 + Vite + Tailwind v4 : tokens.css copié tel quel (source de
  vérité), classes composants extraites de la maquette validée, Manrope
  auto-hébergée, primitives shadcn-style possédées (Button cva, menu Radix)
- connexion fidèle à la maquette ; liste démo affichée seulement si
  /auth/demo-accounts répond (404 ⇒ rien — ADR-002, l'API seule source)
- coquille : sidebar 4 groupes avec rail safran actif (écrans futurs marqués
  R1-R3), topbar recherche/thème/chip DÉMO + sélecteur de rôle (bascule 101 ms,
  critère < 3 s) ; page /design = référence vivante des revues pixel
- règle d'or : schema.d.ts généré depuis docs/openapi.json et committé
  (openapi-typescript + openapi-fetch)
- tokens.css : tokens --nav-* ajoutés (valeurs de la maquette validée) ;
  seed réaligné sur les personas de la maquette
- vérifié en navigateur réel (Playwright + Chrome headless) : 7 captures,
  parcours démo complet, bi-thème, zéro erreur console ; typecheck + vitest + build OK

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 22:09:40 +01:00

208 lines
6.6 KiB
TypeScript

import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
import { LogOut, Monitor, Moon, Sun } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { Navigate, NavLink, useNavigate } from 'react-router-dom';
import { getToken } from '@/api/client';
import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth';
import { Avatar, initialsOf } from '@/components/avatar';
import { Logo } from '@/components/logo';
import {
IcoAscenseurs,
IcoDemandes,
IcoFichiers,
IcoOrdresDeTravail,
IcoPersonnes,
IcoPreventif,
IcoRecherche,
IcoSites,
IcoStatistiques,
IcoStock,
IcoTableauDeBord,
IcoTiers,
} from '@/components/nav-icons';
import {
applyThemeMode,
getThemeMode,
nextThemeMode,
THEME_LABELS,
type ThemeMode,
} from '@/theme/theme';
/** Navigation de la maquette validée. Les écrans à venir restent visibles
* (périmètre annoncé) mais inertes, marqués de leur release. */
const NAVIGATION: {
groupe: string;
liens: { libelle: string; icone: () => ReactNode; route?: string; release?: string }[];
}[] = [
{
groupe: 'Exploitation',
liens: [
{ libelle: 'Tableau de bord', icone: IcoTableauDeBord, route: '/' },
{ libelle: 'Ordres de travail', icone: IcoOrdresDeTravail, release: 'R2' },
{ libelle: 'Demandes', icone: IcoDemandes, release: 'R2' },
{ libelle: 'Préventif', icone: IcoPreventif, release: 'R2' },
],
},
{
groupe: 'Parc',
liens: [
{ libelle: 'Ascenseurs', icone: IcoAscenseurs, release: 'R1' },
{ libelle: 'Sites', icone: IcoSites, release: 'R1' },
],
},
{
groupe: 'Ressources',
liens: [
{ libelle: 'Stock & achats', icone: IcoStock, release: 'R3' },
{ libelle: 'Tiers', icone: IcoTiers, release: 'R3' },
{ libelle: 'Fichiers', icone: IcoFichiers, release: 'R3' },
],
},
{
groupe: 'Pilotage',
liens: [
{ libelle: 'Statistiques', icone: IcoStatistiques, release: 'R3' },
{ libelle: 'Personnes', icone: IcoPersonnes, release: 'R1' },
],
},
];
const THEME_ICONS: Record<ThemeMode, typeof Sun> = {
auto: Monitor,
light: Sun,
dark: Moon,
};
export function Coquille({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const { data: me } = useMe();
const { data: demoAccounts } = useDemoAccounts();
const demoLogin = useDemoLogin();
const logout = useLogout();
const [themeMode, setThemeMode] = useState<ThemeMode>(getThemeMode);
if (!getToken()) return <Navigate to="/connexion" replace />;
const cyclerTheme = () => {
const mode = nextThemeMode(themeMode);
applyThemeMode(mode);
setThemeMode(mode);
};
const basculerDemo = (userId: string) => {
if (userId === me?.id) return;
demoLogin.mutate(userId, { onSuccess: () => navigate('/', { replace: true }) });
};
const IconeTheme = THEME_ICONS[themeMode];
return (
<div className="app">
<aside className="nav">
<Logo />
{NAVIGATION.map(({ groupe, liens }) => (
<nav key={groupe} aria-label={groupe}>
<div className="groupe">{groupe}</div>
{liens.map(({ libelle, icone: Icone, route, release }) =>
route ? (
<NavLink
key={libelle}
to={route}
end
className={({ isActive }) => (isActive ? 'actif' : undefined)}
>
<Icone />
{libelle}
</NavLink>
) : (
<a
key={libelle}
href="#"
aria-disabled="true"
title={`Disponible en ${release}`}
onClick={(e) => e.preventDefault()}
>
<Icone />
{libelle}
<span className="badge-release">{release}</span>
</a>
),
)}
</nav>
))}
</aside>
<div className="contenu">
<header className="topbar">
<div className="recherche" aria-hidden="true">
<IcoRecherche />
Rechercher un OT, un ascenseur, un site&ensp;K
</div>
<button
type="button"
className="btn ghost"
onClick={cyclerTheme}
title={THEME_LABELS[themeMode]}
aria-label={THEME_LABELS[themeMode]}
>
<IconeTheme />
</button>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button type="button" className="compte" aria-label="Compte et bascule de rôle">
{me?.isDemo ? <span className="chip-demo">DÉMO</span> : null}
{me ? (
<>
<Avatar
initials={initialsOf(me.displayName)}
roleName={me.role.name}
/>
<span className="qui">
<b>{me.displayName}</b>
<br />
<small>{me.role.name} </small>
</span>
</>
) : null}
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className="menu" align="end" sideOffset={8}>
{demoAccounts && demoAccounts.length > 0 ? (
<>
<div className="titre-menu">Changer de rôle (démo)</div>
{demoAccounts.map((compte) => (
<DropdownMenu.Item
key={compte.id}
className="item"
disabled={compte.id === me?.id}
onSelect={() => basculerDemo(compte.id)}
>
<Avatar
initials={compte.initials}
roleName={compte.roleName}
className="h-6 w-6 text-[10px]"
/>
{compte.displayName}
<span className="role">{compte.roleName}</span>
</DropdownMenu.Item>
))}
<div className="separateur" role="separator" />
</>
) : null}
<DropdownMenu.Item className="item" onSelect={logout}>
<LogOut />
Se déconnecter
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</header>
<main className="page">{children}</main>
</div>
</div>
);
}