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:
105
apps/web/src/pages/connexion.tsx
Normal file
105
apps/web/src/pages/connexion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
181
apps/web/src/pages/design.tsx
Normal file
181
apps/web/src/pages/design.tsx
Normal 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 12→32</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
49
apps/web/src/pages/tableau-de-bord.tsx
Normal file
49
apps/web/src/pages/tableau-de-bord.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user