mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
- 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>
208 lines
6.6 KiB
TypeScript
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… ⌘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>
|
|
);
|
|
}
|