mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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>
This commit is contained in:
207
apps/web/src/layout/coquille.tsx
Normal file
207
apps/web/src/layout/coquille.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user