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:
pr-daaif
2026-07-15 22:09:40 +01:00
parent 8adb561b63
commit 4f3af5af4f
27 changed files with 4733 additions and 22 deletions

View File

@@ -0,0 +1,105 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/components/avatar';
import { Logo } from '@/components/logo';
import { Button } from '@/components/ui/button';
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/use-auth';
/** Écran 1 de la maquette validée : connexion sobre + mode démonstration
* (ADR-002) visible uniquement quand l'API expose /auth/demo-accounts. */
export default function PageConnexion() {
const navigate = useNavigate();
const login = useLogin();
const demoLogin = useDemoLogin();
const { data: demoAccounts } = useDemoAccounts();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const surConnexion = (e: FormEvent) => {
e.preventDefault();
login.mutate(
{ email, password },
{ onSuccess: () => navigate('/', { replace: true }) },
);
};
const surDemo = (userId: string) => {
demoLogin.mutate(userId, {
onSuccess: () => navigate('/', { replace: true }),
});
};
return (
<div className="conn">
<div className="carte-conn">
<Logo sousTitre="Maintenance d'ascenseurs" />
<form
onSubmit={surConnexion}
className="flex flex-col gap-[14px]"
aria-label="Connexion"
>
<div className="champ">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
autoComplete="email"
placeholder="prenom.nom@spelev.ma"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="champ">
<label htmlFor="mdp">Mot de passe</label>
<input
id="mdp"
type="password"
autoComplete="current-password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{login.isError ? (
<p className="erreur-form" role="alert">
Email ou mot de passe incorrect. Vérifiez votre saisie.
</p>
) : null}
<Button
type="submit"
variant="prim"
className="justify-center"
disabled={login.isPending}
>
Se connecter
</Button>
</form>
{demoAccounts && demoAccounts.length > 0 ? (
<>
<div className="sep">Mode démonstration</div>
<div className="demo-liste">
{demoAccounts.map((compte) => (
<button
key={compte.id}
type="button"
onClick={() => surDemo(compte.id)}
disabled={demoLogin.isPending}
>
<Avatar initials={compte.initials} roleName={compte.roleName} />
{compte.displayName}
<span className="role">{compte.roleName}</span>
</button>
))}
</div>
<div className="text-center">
<span className="chip-demo">DEMO_MODE=true absent en production</span>
</div>
</>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { Button } from '@/components/ui/button';
import { Avatar } from '@/components/avatar';
/**
* /design — référence VIVANTE des revues pixel (charte §8) : tous les tokens
* et primitives, rendus par le vrai code de l'app, dans le thème courant.
*/
const NUANCES: { titre: string; puces: [string, string][] }[] = [
{
titre: 'Marque',
puces: [
['--primaire', 'primaire'],
['--primaire-doux', 'primaire-doux'],
['--safran', 'safran'],
['--safran-doux', 'safran-doux'],
],
},
{
titre: 'Neutres',
puces: [
['--fond', 'fond'],
['--surface', 'surface'],
['--surface-2', 'surface-2'],
['--bordure', 'bordure'],
['--bordure-forte', 'bordure-forte'],
['--encre', 'encre'],
['--encre-2', 'encre-2'],
['--encre-3', 'encre-3'],
],
},
{
titre: 'Statuts OT (réservés)',
puces: [
['--st-ouvert', 'ouvert'],
['--st-encours', 'en cours'],
['--st-attente', 'attente'],
['--st-termine', 'terminé'],
['--st-annule', 'annulé'],
],
},
{
titre: 'Sémantique',
puces: [
['--succes', 'succès'],
['--alerte', 'alerte'],
['--danger', 'danger'],
['--info', 'info'],
],
},
{
titre: 'Palette graphique (ordre fixe, validée CVD)',
puces: [
['--graf-1', 'graf-1'],
['--graf-2', 'graf-2'],
['--graf-3', 'graf-3'],
['--graf-4', 'graf-4'],
['--graf-grille', 'grille'],
],
},
];
const TAILLES = [12, 13, 14, 16, 20, 24, 32] as const;
export default function PageDesign() {
return (
<>
<div className="entete-page">
<h1>Design référence vivante</h1>
<span className="text-[12.5px] text-encre-3">
tokens.css est la loi ; cette page est comparée aux maquettes à chaque
revue pixel.
</span>
</div>
{NUANCES.map(({ titre, puces }) => (
<div className="carte" key={titre}>
<h2>{titre}</h2>
<div className="nuancier">
{puces.map(([token, libelle]) => (
<div className="puce-c" key={token}>
<i style={{ background: `var(${token})` }} />
{libelle}
<br />
<code className="text-[10px]">{token}</code>
</div>
))}
</div>
</div>
))}
<div className="carte">
<h2>Typographie Manrope, échelle 1232</h2>
<div className="flex flex-col gap-2">
{TAILLES.map((t) => (
<div key={t} className="flex items-baseline gap-4">
<code className="w-10 text-[11px] text-encre-3">{t}px</code>
<span style={{ fontSize: t, fontWeight: t >= 20 ? 700 : 400 }}>
Grille du mois juillet 2026
</span>
</div>
))}
<div className="flex items-baseline gap-4">
<code className="w-10 text-[11px] text-encre-3">num</code>
<span className="num text-[16px]">
18 450 MAD · 12 OT · 78 % chiffres tabulaires alignés
</span>
</div>
</div>
</div>
<div className="carte">
<h2>Boutons</h2>
<div className="flex flex-wrap items-center gap-3">
<Button variant="prim">Créer l'OT d'urgence</Button>
<Button>Exporter</Button>
<Button variant="succes">Clôturer l'intervention</Button>
<Button variant="dangerLeger">Annuler l'OT</Button>
<Button variant="ghost">Voir tout</Button>
<Button variant="prim" disabled>
Désactivé
</Button>
</div>
</div>
<div className="grille-2">
<div className="carte">
<h2>Statuts (pastille + libellé, jamais la couleur seule)</h2>
<div className="flex flex-wrap gap-2">
<span className="st ouvert">Ouvert</span>
<span className="st encours">En cours</span>
<span className="st attente">En attente</span>
<span className="st termine">Terminé</span>
<span className="st annule">Annulé</span>
</div>
</div>
<div className="carte">
<h2>Priorités</h2>
<div className="flex flex-wrap gap-4">
<span className="prio bloque">
<i />
Personne bloquée
</span>
<span className="prio haute">
<i />
Haute
</span>
<span className="prio moyenne">
<i />
Moyenne
</span>
<span className="prio basse">
<i />
Basse
</span>
</div>
<p className="mt-3 text-[12px] text-encre-3">
La pastille « personne bloquée » est la seule animation continue de
l'application (charte §6).
</p>
</div>
</div>
<div className="carte">
<h2>Champs & identité</h2>
<div className="grille-2">
<div className="champ">
<label htmlFor="demo-champ">Email</label>
<input id="demo-champ" placeholder="prenom.nom@spelev.ma" />
</div>
<div className="flex flex-wrap items-center gap-3">
<Avatar initials="SI" roleName="Dispatcher" />
<Avatar initials="AB" roleName="Technicien" />
<Avatar initials="NB" roleName="Gestionnaire" />
<span className="chip-demo">DÉMO</span>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,49 @@
import { Link } from 'react-router-dom';
import { useMe } from '@/auth/use-auth';
/** R0 : coquille seulement — le vrai tableau de bord (KPI, urgences,
* graphiques) arrive avec l'exploitation (R2), conformément au plan. */
export default function PageTableauDeBord() {
const { data: me } = useMe();
return (
<>
<div className="entete-page">
<h1>Tableau de bord</h1>
</div>
<div className="carte">
<h2>Fondations (R0)</h2>
<p>
Bienvenue{me ? ` ${me.displayName}` : ''} vous êtes connecté avec le
rôle <b>{me?.role.name}</b>. Cette coquille valide l'authentification,
la bascule de rôle en un clic et le bi-thème ; les indicateurs
d'exploitation arriveront avec la release R2.
</p>
<p className="mt-2 text-encre-2">
La référence vivante du design est consultable sur la page{' '}
<Link to="/design" className="font-semibold text-primaire">
/design
</Link>
.
</p>
</div>
{me ? (
<div className="carte">
<h2>Vos droits (matrice en base)</h2>
<div className="flex flex-wrap gap-2">
{me.permissions
.filter((p) => p.canView)
.map((p) => (
<span key={p.objectCategory} className="st ouvert">
{p.objectCategory}
</span>
))}
{me.permissions.every((p) => !p.canView) ? (
<p className="text-encre-2">Ce rôle ne voit aucune catégorie d'objets.</p>
) : null}
</div>
</div>
) : null}
</>
);
}