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:
@@ -36,5 +36,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
|
|
||||||
- ✅ **R0 (1/2)** : playbook 00-vision, 01-cadrage, 02-design — **charte + tokens + maquettes HD VALIDÉES par le référent le 15/07/2026** (artefact : maquette-web.html ; 7 écrans, bi-thème). 03-architecture rédigé (ADR-001 stack, ADR-002 démo-login, C4, modèle R0). Racine monorepo posée (package.json/pnpm-workspace/turbo/.nvmrc) + `infra/` (compose PostgreSQL pgvector+PostGIS, Redis, MinIO — services préfixés `siop2-`).
|
- ✅ **R0 (1/2)** : playbook 00-vision, 01-cadrage, 02-design — **charte + tokens + maquettes HD VALIDÉES par le référent le 15/07/2026** (artefact : maquette-web.html ; 7 écrans, bi-thème). 03-architecture rédigé (ADR-001 stack, ADR-002 démo-login, C4, modèle R0). Racine monorepo posée (package.json/pnpm-workspace/turbo/.nvmrc) + `infra/` (compose PostgreSQL pgvector+PostGIS, Redis, MinIO — services préfixés `siop2-`).
|
||||||
- ✅ **R0.10 `apps/api`** : NestJS + Prisma (migration `r0_identity`), guard JWT global fermé par défaut + `@Public()`, PermissionsGuard (matrice en base, cache 60 s), démo-login ADR-002 (module conditionnel, 404 sinon, double verrou prod), seed idempotent (7 rôles, 70 lignes de matrice, 7 comptes démo), `FileStorage`/health ; `packages/shared` (Zod) + `pnpm contract` → `docs/openapi.json` committée ; 19 tests Jest verts + smoke test build prod.
|
- ✅ **R0.10 `apps/api`** : NestJS + Prisma (migration `r0_identity`), guard JWT global fermé par défaut + `@Public()`, PermissionsGuard (matrice en base, cache 60 s), démo-login ADR-002 (module conditionnel, 404 sinon, double verrou prod), seed idempotent (7 rôles, 70 lignes de matrice, 7 comptes démo), `FileStorage`/health ; `packages/shared` (Zod) + `pnpm contract` → `docs/openapi.json` committée ; 19 tests Jest verts + smoke test build prod.
|
||||||
- 🔄 **R0 (2/2) — reprise ici** : R0.11 `apps/web` (login + sélecteur démo, coquille sidebar/topbar fidèle aux maquettes, page /design, client typé depuis `docs/openapi.json`) → R0.12 tests + CI (dont `ci-contract`) → R0.13 Dockerfiles + runbook Dokploy (le déploiement réel attend les accès au serveur du partenaire).
|
- ✅ **R0.11 `apps/web`** : React 19 + Vite + Tailwind v4, `tokens.css` copié tel quel + classes extraites de la maquette validée, Manrope auto-hébergée ; connexion + sélecteur démo (masqué si 404 — une seule source de vérité : l'API), coquille sidebar (rail safran, écrans futurs marqués R1-R3) / topbar (thème, chip DÉMO, sélecteur de rôle), page /design ; client typé `schema.d.ts` généré depuis `docs/openapi.json` et committé ; vérifié en navigateur réel (bascule de rôle 101 ms, bi-thème, 0 erreur console).
|
||||||
|
- 🔄 **R0 (2/2) — reprise ici** : R0.12 tests + CI (lint, typecheck, couverture api ≥ 70 %, `ci-contract`, Playwright parcours démo) → R0.13 Dockerfiles + runbook Dokploy (le déploiement réel attend les accès au serveur du partenaire).
|
||||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -82,15 +82,16 @@ const MATRIX: Record<RoleName, Partial<Record<ObjectCategory, Grant>>> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 7 comptes démo — un par rôle (personas des maquettes 02-design). */
|
/** 7 comptes démo — un par rôle. Les noms sont CEUX de la maquette validée
|
||||||
|
* (maquette-web.html) : la revue pixel compare écrans et maquettes. */
|
||||||
const DEMO_USERS: { email: string; displayName: string; role: RoleName }[] = [
|
const DEMO_USERS: { email: string; displayName: string; role: RoleName }[] = [
|
||||||
{ email: 'admin@demo.siop.ma', displayName: 'Amina Benali', role: 'Administrateur' },
|
{ email: 'admin@demo.siop.ma', displayName: 'Yasmine Alaoui', role: 'Administrateur' },
|
||||||
{ email: 'dispatcher@demo.siop.ma', displayName: 'Salma Radi', role: 'Dispatcher' },
|
{ email: 'dispatcher@demo.siop.ma', displayName: 'Salma Idrissi', role: 'Dispatcher' },
|
||||||
{ email: 'technicien@demo.siop.ma', displayName: 'Ahmed Meskini', role: 'Technicien' },
|
{ email: 'technicien@demo.siop.ma', displayName: 'Ahmed Benali', role: 'Technicien' },
|
||||||
{ email: 'technicien-limite@demo.siop.ma', displayName: 'Yassine Bouzid', role: 'Technicien limité' },
|
{ email: 'technicien-limite@demo.siop.ma', displayName: 'Youssef Tazi', role: 'Technicien limité' },
|
||||||
{ email: 'gestionnaire@demo.siop.ma', displayName: 'Nadia Cherkaoui', role: 'Gestionnaire' },
|
{ email: 'gestionnaire@demo.siop.ma', displayName: 'Nadia Berrada', role: 'Gestionnaire' },
|
||||||
{ email: 'demandeur@demo.siop.ma', displayName: 'Karim El Fassi', role: 'Demandeur' },
|
{ email: 'demandeur@demo.siop.ma', displayName: 'Karim Doukkali', role: 'Demandeur' },
|
||||||
{ email: 'vue-seule@demo.siop.ma', displayName: 'Omar Senhaji', role: 'Vue seule' },
|
{ email: 'vue-seule@demo.siop.ma', displayName: 'Rachid Bennis', role: 'Vue seule' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function seed(prisma: PrismaClient): Promise<void> {
|
export async function seed(prisma: PrismaClient): Promise<void> {
|
||||||
@@ -131,9 +132,11 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
|||||||
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
||||||
);
|
);
|
||||||
for (const u of DEMO_USERS) {
|
for (const u of DEMO_USERS) {
|
||||||
|
// Les comptes démo appartiennent au seed : nom et rôle sont réalignés
|
||||||
|
// à chaque exécution (jamais le mot de passe d'un compte existant).
|
||||||
await prisma.user.upsert({
|
await prisma.user.upsert({
|
||||||
where: { email: u.email },
|
where: { email: u.email },
|
||||||
update: {},
|
update: { displayName: u.displayName, roleId: roleIds.get(u.role)! },
|
||||||
create: {
|
create: {
|
||||||
email: u.email,
|
email: u.email,
|
||||||
displayName: u.displayName,
|
displayName: u.displayName,
|
||||||
|
|||||||
17
apps/web/index.html
Normal file
17
apps/web/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>SIOP — Maintenance d'ascenseurs</title>
|
||||||
|
<!-- Favicon = la marque seule (charte §5) : ▲▼ safran sur encre -->
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%231b2534'/%3E%3Cpath d='M16 5l7 9H9z' fill='%23dd8a0b'/%3E%3Cpath d='M16 27l-7-9h14z' fill='%23dd8a0b'/%3E%3C/svg%3E"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
apps/web/package.json
Normal file
40
apps/web/package.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "@siop/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "SIOP V2 — web (React 19 + Vite, thématisé par docs/02-design/tokens.css)",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -p tsconfig.json --noEmit && vite build",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"generate:client": "openapi-typescript ../../docs/openapi.json -o src/api/schema.d.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/manrope": "^5.1.0",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
|
"@siop/shared": "workspace:*",
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"openapi-fetch": "^0.13.4",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.1.0",
|
||||||
|
"tailwind-merge": "^2.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"jsdom": "^26.0.0",
|
||||||
|
"openapi-typescript": "^7.5.0",
|
||||||
|
"tailwindcss": "^4.1.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vitest": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/web/src/App.tsx
Normal file
38
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import { Coquille } from '@/layout/coquille';
|
||||||
|
import PageConnexion from '@/pages/connexion';
|
||||||
|
import PageDesign from '@/pages/design';
|
||||||
|
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/connexion" element={<PageConnexion />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<Coquille>
|
||||||
|
<PageTableauDeBord />
|
||||||
|
</Coquille>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/design"
|
||||||
|
element={
|
||||||
|
<Coquille>
|
||||||
|
<PageDesign />
|
||||||
|
</Coquille>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/web/src/api/client.ts
Normal file
24
apps/web/src/api/client.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import createClient from 'openapi-fetch';
|
||||||
|
import type { paths } from './schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client typé — règle d'or (ADR-001) : `paths` est GÉNÉRÉ depuis
|
||||||
|
* docs/openapi.json (pnpm generate:client), régénéré dans le même commit
|
||||||
|
* que tout changement d'API. Le front ne peut pas dériver du back.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'siop.token';
|
||||||
|
|
||||||
|
export const getToken = () => localStorage.getItem(TOKEN_KEY);
|
||||||
|
export const setToken = (token: string) => localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
export const clearToken = () => localStorage.removeItem(TOKEN_KEY);
|
||||||
|
|
||||||
|
export const api = createClient<paths>({ baseUrl: '/api' });
|
||||||
|
|
||||||
|
api.use({
|
||||||
|
onRequest({ request }) {
|
||||||
|
const token = getToken();
|
||||||
|
if (token) request.headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
return request;
|
||||||
|
},
|
||||||
|
});
|
||||||
318
apps/web/src/api/schema.d.ts
vendored
Normal file
318
apps/web/src/api/schema.d.ts
vendored
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* This file was auto-generated by openapi-typescript.
|
||||||
|
* Do not make direct changes to the file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface paths {
|
||||||
|
"/auth/login": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Connexion par e-mail et mot de passe */
|
||||||
|
post: operations["login"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/auth/demo-accounts": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Comptes de démonstration (ADR-002 — jamais de secret)
|
||||||
|
* @description ADR-002 : cette route est absente (404) quand DEMO_MODE n’est pas actif.
|
||||||
|
*/
|
||||||
|
get: operations["listDemoAccounts"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/auth/demo-login": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Connexion 1 clic sur un compte de démonstration (ADR-002)
|
||||||
|
* @description ADR-002 : cette route est absente (404) quand DEMO_MODE n’est pas actif.
|
||||||
|
*/
|
||||||
|
post: operations["demoLogin"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/users/me": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Profil courant + matrice de permissions du rôle */
|
||||||
|
get: operations["getMe"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/health": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** État des dépendances (base, Redis, stockage) */
|
||||||
|
get: operations["getHealth"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type webhooks = Record<string, never>;
|
||||||
|
export interface components {
|
||||||
|
schemas: {
|
||||||
|
AuthResponse: {
|
||||||
|
accessToken: string;
|
||||||
|
user: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: email */
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
role: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
name: "Administrateur" | "Dispatcher" | "Technicien" | "Technicien limité" | "Gestionnaire" | "Demandeur" | "Vue seule";
|
||||||
|
};
|
||||||
|
isDemo: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
LoginRequest: {
|
||||||
|
/** Format: email */
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
DemoAccountsResponse: {
|
||||||
|
accounts: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
roleName: "Administrateur" | "Dispatcher" | "Technicien" | "Technicien limité" | "Gestionnaire" | "Demandeur" | "Vue seule";
|
||||||
|
initials: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
DemoLoginRequest: {
|
||||||
|
/** Format: uuid */
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
MeResponse: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: email */
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
role: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
name: "Administrateur" | "Dispatcher" | "Technicien" | "Technicien limité" | "Gestionnaire" | "Demandeur" | "Vue seule";
|
||||||
|
};
|
||||||
|
isDemo: boolean;
|
||||||
|
permissions: {
|
||||||
|
/** @enum {string} */
|
||||||
|
objectCategory: "WORK_ORDERS" | "REQUESTS" | "ASSETS" | "LOCATIONS" | "METERS" | "PARTS" | "PURCHASE_ORDERS" | "PEOPLE_TEAMS" | "ANALYTICS" | "SETTINGS";
|
||||||
|
canView: boolean;
|
||||||
|
canViewOther: boolean;
|
||||||
|
canCreate: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
HealthResponse: {
|
||||||
|
/** @enum {string} */
|
||||||
|
status: "ok" | "degraded";
|
||||||
|
services: {
|
||||||
|
/** @enum {string} */
|
||||||
|
database: "up" | "down";
|
||||||
|
/** @enum {string} */
|
||||||
|
redis: "up" | "down";
|
||||||
|
/** @enum {string} */
|
||||||
|
storage: "up" | "down";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: never;
|
||||||
|
parameters: never;
|
||||||
|
requestBodies: never;
|
||||||
|
headers: never;
|
||||||
|
pathItems: never;
|
||||||
|
}
|
||||||
|
export type $defs = Record<string, never>;
|
||||||
|
export interface operations {
|
||||||
|
login: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["LoginRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Jeton émis */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AuthResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Identifiants invalides ou compte inactif */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listDemoAccounts: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Comptes isDemo actifs */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DemoAccountsResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
demoLogin: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DemoLoginRequest"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Jeton émis */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AuthResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Le compte n’est pas un compte de démonstration */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Compte inconnu */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getMe: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Profil */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Non authentifié */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getHealth: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description État agrégé */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HealthResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
79
apps/web/src/auth/use-auth.ts
Normal file
79
apps/web/src/auth/use-auth.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api, clearToken, getToken, setToken } from '@/api/client';
|
||||||
|
|
||||||
|
/** Profil courant. Un 401 (jeton expiré/invalide) purge la session. */
|
||||||
|
export function useMe() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
enabled: !!getToken(),
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await api.GET('/users/me');
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken();
|
||||||
|
throw new Error('Session expirée');
|
||||||
|
}
|
||||||
|
if (!data) throw new Error('Profil indisponible');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ADR-002 : la liste n'existe qu'en DEMO_MODE — un 404 signifie « pas de
|
||||||
|
* mode démo », le web n'affiche alors rien (null). */
|
||||||
|
export function useDemoAccounts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['demo-accounts'],
|
||||||
|
retry: false,
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await api.GET('/auth/demo-accounts');
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
return data?.accounts ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogin() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: { email: string; password: string }) => {
|
||||||
|
const { data, error } = await api.POST('/auth/login', { body });
|
||||||
|
if (error || !data) throw new Error('Email ou mot de passe incorrect');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setToken(data.accessToken);
|
||||||
|
queryClient.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connexion 1 clic (et bascule de rôle en cours de session — critère < 3 s). */
|
||||||
|
export function useDemoLogin() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userId: string) => {
|
||||||
|
const { data, error } = await api.POST('/auth/demo-login', {
|
||||||
|
body: { userId },
|
||||||
|
});
|
||||||
|
if (error || !data) throw new Error('Connexion démo impossible');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setToken(data.accessToken);
|
||||||
|
queryClient.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLogout() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return () => {
|
||||||
|
clearToken();
|
||||||
|
queryClient.clear();
|
||||||
|
navigate('/connexion', { replace: true });
|
||||||
|
};
|
||||||
|
}
|
||||||
42
apps/web/src/components/avatar.tsx
Normal file
42
apps/web/src/components/avatar.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { RoleName } from '@siop/shared';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
/** Couleur d'avatar par rôle (tokens uniquement — répartition de la maquette). */
|
||||||
|
const ROLE_TOKEN: Record<RoleName, string> = {
|
||||||
|
Administrateur: 'var(--graf-1)',
|
||||||
|
Dispatcher: 'var(--primaire)',
|
||||||
|
Technicien: 'var(--graf-3)',
|
||||||
|
'Technicien limité': 'var(--st-encours)',
|
||||||
|
Gestionnaire: 'var(--graf-4)',
|
||||||
|
Demandeur: 'var(--st-annule)',
|
||||||
|
'Vue seule': 'var(--safran)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Avatar({
|
||||||
|
initials,
|
||||||
|
roleName,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
initials: string;
|
||||||
|
roleName?: RoleName;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('avatar', className)}
|
||||||
|
style={roleName ? { background: ROLE_TOKEN[roleName] } : undefined}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initialsOf(displayName: string): string {
|
||||||
|
return displayName
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0]!.toUpperCase())
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
14
apps/web/src/components/logo.tsx
Normal file
14
apps/web/src/components/logo.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/** Logotype (charte §5) : « SIOP » Manrope 700 + marque ▲▼ safran. */
|
||||||
|
export function Logo({ sousTitre }: { sousTitre?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="logo">
|
||||||
|
<span className="marque" aria-hidden="true">
|
||||||
|
▲<br />▼
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<b>SIOP</b>
|
||||||
|
{sousTitre ? <small>{sousTitre}</small> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/web/src/components/nav-icons.tsx
Normal file
97
apps/web/src/components/nav-icons.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { SVGProps } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Icônes de navigation : tracés REPRIS de la maquette validée (trait 1,75 px,
|
||||||
|
* 17 px — charte §5). Lucide sert pour le reste de l'UI ; ici la fidélité
|
||||||
|
* pixel prime, la revue compare la sidebar à la maquette.
|
||||||
|
*/
|
||||||
|
const base: SVGProps<SVGSVGElement> = {
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IcoTableauDeBord = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<rect x="3" y="3" width="7" height="9" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="5" rx="1" />
|
||||||
|
<rect x="14" y="12" width="7" height="9" rx="1" />
|
||||||
|
<rect x="3" y="16" width="7" height="5" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoOrdresDeTravail = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M14.7 6.3a4.5 4.5 0 0 0-6 6L3 18v3h3l5.7-5.7a4.5 4.5 0 0 0 6-6l-2.6 2.6-2.4-2.4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoDemandes = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M8 10h8M8 14h5M21 12a9 9 0 1 1-4-7.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoPreventif = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoAscenseurs = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<rect x="6" y="2" width="12" height="20" rx="2" />
|
||||||
|
<path d="M10 7l2-2 2 2M10 17l2 2 2-2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoSites = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0z" />
|
||||||
|
<circle cx="12" cy="10" r="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoStock = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3.3 8.3L12 13l8.7-4.7M12 13v9" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoTiers = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M9 21a7 7 0 1 1 6-11l6 6v3h-3l-1-1-2 1-1-2-2 1z" transform="rotate(180 12 12)" />
|
||||||
|
<circle cx="9" cy="9" r="1.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoFichiers = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<path d="M14 2v6h6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoStatistiques = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M3 3v18h18" />
|
||||||
|
<rect x="7" y="12" width="3" height="6" />
|
||||||
|
<rect x="12" y="8" width="3" height="10" />
|
||||||
|
<rect x="17" y="5" width="3" height="13" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoPersonnes = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<circle cx="9" cy="8" r="3.5" />
|
||||||
|
<path d="M2.5 20a6.5 6.5 0 0 1 13 0M17 8a3 3 0 1 1 0 .01M16.5 13.5a6 6 0 0 1 5 6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const IcoRecherche = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
<path d="m21 21-4-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
36
apps/web/src/components/ui/button.tsx
Normal file
36
apps/web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primitive shadcn-style (composant possédé) : les variants correspondent
|
||||||
|
* exactement aux boutons de la maquette validée (.btn, .prim, .succes, …).
|
||||||
|
*/
|
||||||
|
const buttonVariants = cva('btn', {
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: '',
|
||||||
|
prim: 'prim',
|
||||||
|
succes: 'succes',
|
||||||
|
dangerLeger: 'danger-l',
|
||||||
|
ghost: 'ghost',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {}
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, type, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type={type ?? 'button'}
|
||||||
|
className={cn(buttonVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
apps/web/src/lib/cn.ts
Normal file
6
apps/web/src/lib/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
18
apps/web/src/main.tsx
Normal file
18
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
// Manrope auto-hébergée (charte §3 — jamais de CDN)
|
||||||
|
import '@fontsource/manrope/400.css';
|
||||||
|
import '@fontsource/manrope/500.css';
|
||||||
|
import '@fontsource/manrope/600.css';
|
||||||
|
import '@fontsource/manrope/700.css';
|
||||||
|
import '@/styles/app.css';
|
||||||
|
import App from '@/App';
|
||||||
|
import { applyThemeMode, getThemeMode } from '@/theme/theme';
|
||||||
|
|
||||||
|
applyThemeMode(getThemeMode());
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
556
apps/web/src/styles/app.css
Normal file
556
apps/web/src/styles/app.css
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
/*
|
||||||
|
* SIOP V2 — styles applicatifs.
|
||||||
|
* tokens.css est copié TEL QUEL depuis docs/02-design/ (source de vérité).
|
||||||
|
* Les classes composants ci-dessous sont EXTRAITES de la maquette validée
|
||||||
|
* (docs/02-design/maquettes/maquette-web.html) — seule adaptation : la
|
||||||
|
* coquille remplit le viewport au lieu du cadre de revue 1280 px.
|
||||||
|
* Règle : aucune valeur visuelle en dur — tout vient des tokens.
|
||||||
|
*/
|
||||||
|
@import 'tailwindcss';
|
||||||
|
@import './tokens.css';
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-fond: var(--fond);
|
||||||
|
--color-surface: var(--surface);
|
||||||
|
--color-surface-2: var(--surface-2);
|
||||||
|
--color-bordure: var(--bordure);
|
||||||
|
--color-bordure-forte: var(--bordure-forte);
|
||||||
|
--color-encre: var(--encre);
|
||||||
|
--color-encre-2: var(--encre-2);
|
||||||
|
--color-encre-3: var(--encre-3);
|
||||||
|
--color-primaire: var(--primaire);
|
||||||
|
--color-primaire-doux: var(--primaire-doux);
|
||||||
|
--color-safran: var(--safran);
|
||||||
|
--color-safran-doux: var(--safran-doux);
|
||||||
|
--color-succes: var(--succes);
|
||||||
|
--color-alerte: var(--alerte);
|
||||||
|
--color-danger: var(--danger);
|
||||||
|
--color-info: var(--info);
|
||||||
|
--font-ui: var(--police-ui);
|
||||||
|
--radius-carte: var(--rayon);
|
||||||
|
--radius-controle: var(--rayon-controle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Base (maquette §Base) ═══ */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background: var(--fond);
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: var(--police-ui);
|
||||||
|
color: var(--encre);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible {
|
||||||
|
outline: 2px solid var(--primaire);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Coquille applicative (plein viewport) ═══ */
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 232px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--fond);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar (toujours sombre — identité) */
|
||||||
|
.nav {
|
||||||
|
background: var(--nav-fond);
|
||||||
|
color: var(--nav-encre);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 2px 18px 16px;
|
||||||
|
color: var(--nav-encre-vif);
|
||||||
|
}
|
||||||
|
.logo .marque {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 0.55;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--safran);
|
||||||
|
}
|
||||||
|
.logo b {
|
||||||
|
font-size: 19px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.logo small {
|
||||||
|
color: var(--nav-encre);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 11px;
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.nav .groupe {
|
||||||
|
padding: 12px 18px 4px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #6b7f9e;
|
||||||
|
}
|
||||||
|
.nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
color: var(--nav-encre);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13.5px;
|
||||||
|
border-left: var(--rail) solid transparent;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.nav a:hover {
|
||||||
|
background: rgb(255 255 255 / 0.06);
|
||||||
|
color: var(--nav-encre-vif);
|
||||||
|
}
|
||||||
|
.nav a.actif {
|
||||||
|
background: rgb(255 255 255 / 0.09);
|
||||||
|
color: var(--nav-encre-vif);
|
||||||
|
border-left-color: var(--safran);
|
||||||
|
}
|
||||||
|
.nav a[aria-disabled='true'] {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.nav a .badge-nav {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--prio-bloque);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: var(--rayon-pastille);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.nav a .badge-release {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 9.5px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #6b7f9e;
|
||||||
|
border: 1px solid var(--nav-bordure);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--rayon-pastille);
|
||||||
|
}
|
||||||
|
.nav svg {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
stroke-width: 1.75;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
.contenu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--bordure);
|
||||||
|
}
|
||||||
|
.recherche {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 420px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--bordure);
|
||||||
|
border-radius: var(--rayon-controle);
|
||||||
|
padding: 7px 12px;
|
||||||
|
color: var(--encre-3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.recherche svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
.compte {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.chip-demo {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--safran);
|
||||||
|
background: var(--safran-doux);
|
||||||
|
padding: 3px 9px;
|
||||||
|
border-radius: var(--rayon-pastille);
|
||||||
|
border: 1px dashed var(--safran);
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primaire);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.compte .qui {
|
||||||
|
line-height: 1.15;
|
||||||
|
font-size: 12.5px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.compte .qui small {
|
||||||
|
color: var(--encre-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corps de page */
|
||||||
|
.page {
|
||||||
|
padding: 20px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.entete-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.entete-page h1 {
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
.entete-page .actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Boutons (variants shadcn-style pilotés par ces classes) */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--rayon-controle);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--bordure-forte);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--encre);
|
||||||
|
transition: filter var(--transition);
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
filter: brightness(0.97);
|
||||||
|
}
|
||||||
|
.btn.prim {
|
||||||
|
background: var(--primaire);
|
||||||
|
border-color: var(--primaire);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn.succes {
|
||||||
|
background: var(--succes);
|
||||||
|
border-color: var(--succes);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn.danger-l {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: color-mix(in srgb, var(--danger) 40%, transparent);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
border-color: transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--encre-2);
|
||||||
|
}
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.btn svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cartes */
|
||||||
|
.carte {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--bordure);
|
||||||
|
border-radius: var(--rayon);
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
.carte h2 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--encre-2);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.grille-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grille-2 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pastilles statut / priorité (réservées — jamais réutilisées ailleurs) */
|
||||||
|
.st {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--rayon-pastille);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.st::before {
|
||||||
|
content: '';
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
.st.ouvert { color: var(--st-ouvert); background: var(--st-ouvert-fond); }
|
||||||
|
.st.encours { color: var(--st-encours); background: var(--st-encours-fond); }
|
||||||
|
.st.attente { color: var(--st-attente); background: var(--st-attente-fond); }
|
||||||
|
.st.termine { color: var(--st-termine); background: var(--st-termine-fond); }
|
||||||
|
.st.annule { color: var(--st-annule); background: var(--st-annule-fond); }
|
||||||
|
.prio {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.prio i {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
.point-pulse {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--prio-bloque);
|
||||||
|
animation: pulse 1.4s ease-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--prio-bloque) 45%, transparent); }
|
||||||
|
70% { box-shadow: 0 0 0 8px transparent; }
|
||||||
|
100% { box-shadow: 0 0 0 0 transparent; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.point-pulse { animation: none; }
|
||||||
|
}
|
||||||
|
.prio.bloque { color: var(--prio-bloque); }
|
||||||
|
.prio.bloque i { background: var(--prio-bloque); animation: pulse 1.4s ease-out infinite; }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.prio.bloque i { animation: none; }
|
||||||
|
}
|
||||||
|
.prio.haute { color: var(--prio-haute); }
|
||||||
|
.prio.haute i { background: var(--prio-haute); }
|
||||||
|
.prio.moyenne { color: var(--prio-moyenne); }
|
||||||
|
.prio.moyenne i { background: var(--prio-moyenne); }
|
||||||
|
.prio.basse { color: var(--prio-basse); }
|
||||||
|
.prio.basse i { background: var(--prio-basse); }
|
||||||
|
|
||||||
|
/* ═══ Connexion ═══ */
|
||||||
|
.conn {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(
|
||||||
|
160deg,
|
||||||
|
var(--fond) 55%,
|
||||||
|
color-mix(in srgb, var(--primaire) 7%, var(--fond))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.carte-conn {
|
||||||
|
width: 400px;
|
||||||
|
max-width: 94%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--bordure);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.carte-conn .logo {
|
||||||
|
padding: 0 0 4px;
|
||||||
|
color: var(--encre);
|
||||||
|
}
|
||||||
|
.carte-conn .logo small {
|
||||||
|
color: var(--encre-2);
|
||||||
|
}
|
||||||
|
.champ label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--encre-2);
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.champ input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--bordure-forte);
|
||||||
|
border-radius: var(--rayon-controle);
|
||||||
|
font: inherit;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--encre);
|
||||||
|
}
|
||||||
|
.champ input::placeholder {
|
||||||
|
color: var(--encre-3);
|
||||||
|
}
|
||||||
|
.sep {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--encre-3);
|
||||||
|
font-size: 11.5px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.sep::before,
|
||||||
|
.sep::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--bordure);
|
||||||
|
}
|
||||||
|
.demo-liste {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.demo-liste button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--bordure);
|
||||||
|
border-radius: var(--rayon-controle);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--encre);
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
.demo-liste button:hover {
|
||||||
|
border-color: var(--primaire);
|
||||||
|
background: var(--primaire-doux);
|
||||||
|
}
|
||||||
|
.demo-liste .role {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--encre-2);
|
||||||
|
}
|
||||||
|
.erreur-form {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Menu (compte / bascule démo) ═══ */
|
||||||
|
.menu {
|
||||||
|
min-width: 230px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--bordure);
|
||||||
|
border-radius: var(--rayon);
|
||||||
|
box-shadow: var(--ombre-menu);
|
||||||
|
padding: 6px;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
.menu .titre-menu {
|
||||||
|
padding: 7px 10px 3px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--encre-3);
|
||||||
|
}
|
||||||
|
.menu .item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--encre);
|
||||||
|
text-align: left;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.menu .item:hover,
|
||||||
|
.menu .item[data-highlighted] {
|
||||||
|
background: var(--primaire-doux);
|
||||||
|
}
|
||||||
|
.menu .item .role {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--encre-2);
|
||||||
|
}
|
||||||
|
.menu .item svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
stroke-width: 1.75;
|
||||||
|
color: var(--encre-2);
|
||||||
|
}
|
||||||
|
.menu .separateur {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--bordure);
|
||||||
|
margin: 6px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Page /design : nuanciers ═══ */
|
||||||
|
.nuancier {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.puce-c {
|
||||||
|
width: 84px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--encre-2);
|
||||||
|
}
|
||||||
|
.puce-c i {
|
||||||
|
display: block;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--rayon-controle);
|
||||||
|
border: 1px solid var(--bordure);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
169
apps/web/src/styles/tokens.css
Normal file
169
apps/web/src/styles/tokens.css
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
* SIOP V2 — Design tokens (source de vérité visuelle)
|
||||||
|
* Importé tel quel par apps/web ; les maquettes HD l'utilisent aussi.
|
||||||
|
* Règle : AUCUNE valeur visuelle codée en dur hors de ce fichier.
|
||||||
|
* Thèmes : clair par défaut, sombre via @media + surcharge [data-theme].
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ——— Marque ——— */
|
||||||
|
--primaire: #1f4fb8; /* bleu-treuil : actions, liens, focus */
|
||||||
|
--primaire-actif: #1a439c;
|
||||||
|
--primaire-doux: #eaf0fb; /* fonds de sélection/hover primaires */
|
||||||
|
--safran: #dd8a0b; /* accent de marque — parcimonieux */
|
||||||
|
--safran-doux: #fdf3e3;
|
||||||
|
|
||||||
|
/* ——— Neutres (acier, biais froid) ——— */
|
||||||
|
--fond: #f5f7fa;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-2: #eef2f7; /* en-têtes de table, zones repliées */
|
||||||
|
--bordure: #dce3ec;
|
||||||
|
--bordure-forte: #b9c4d4;
|
||||||
|
--encre: #1b2534; /* texte principal */
|
||||||
|
--encre-2: #55647a; /* texte secondaire */
|
||||||
|
--encre-3: #8494ab; /* placeholder, désactivé */
|
||||||
|
|
||||||
|
/* ——— Statuts OT (réservés) ——— */
|
||||||
|
--st-ouvert: #3d6fe0; --st-ouvert-fond: #e9effc;
|
||||||
|
--st-encours: #6d5bd8; --st-encours-fond: #efecfa;
|
||||||
|
--st-attente: #b96f07; --st-attente-fond: #fbf1df;
|
||||||
|
--st-termine: #178a50; --st-termine-fond: #e6f5ec;
|
||||||
|
--st-annule: #68788f; --st-annule-fond: #edf0f4;
|
||||||
|
|
||||||
|
/* ——— Priorités (réservées) ——— */
|
||||||
|
--prio-bloque: #d92626; --prio-bloque-fond: #fdeaea; /* personne bloquée */
|
||||||
|
--prio-haute: #d92626;
|
||||||
|
--prio-moyenne: #b96f07;
|
||||||
|
--prio-basse: #178a50;
|
||||||
|
--prio-aucune: #8494ab;
|
||||||
|
|
||||||
|
/* ——— Sémantique générale ——— */
|
||||||
|
--succes: #178a50;
|
||||||
|
--alerte: #b96f07;
|
||||||
|
--danger: #d92626;
|
||||||
|
--info: #3d6fe0;
|
||||||
|
|
||||||
|
/* ——— Palette graphique (analytics, ordre FIXE, validée CVD) ——— */
|
||||||
|
--graf-1: #3b69d6;
|
||||||
|
--graf-2: #dd8a0b;
|
||||||
|
--graf-3: #1f9e8e;
|
||||||
|
--graf-4: #8a63d2;
|
||||||
|
--graf-grille: #e6ebf2;
|
||||||
|
|
||||||
|
/* ——— Navigation (sidebar toujours sombre — identité) ——— */
|
||||||
|
--nav-fond: #16233b;
|
||||||
|
--nav-encre: #c6d2e4;
|
||||||
|
--nav-encre-vif: #ffffff;
|
||||||
|
--nav-bordure: #24344f;
|
||||||
|
|
||||||
|
/* ——— Typographie ——— */
|
||||||
|
--police-ui: "Manrope", "Avenir Next", "Segoe UI Variable", system-ui, sans-serif;
|
||||||
|
--police-mono: "SF Mono", ui-monospace, "Cascadia Mono", monospace;
|
||||||
|
--t-11: 11px; --t-12: 12px; --t-13: 13px; --t-14: 14px;
|
||||||
|
--t-16: 16px; --t-20: 20px; --t-24: 24px; --t-32: 32px;
|
||||||
|
|
||||||
|
/* ——— Forme & espace (grille 4 px) ——— */
|
||||||
|
--e-1: 4px; --e-2: 8px; --e-3: 12px; --e-4: 16px; --e-5: 20px; --e-6: 24px; --e-8: 32px;
|
||||||
|
--rayon: 10px; /* cartes */
|
||||||
|
--rayon-controle: 8px; /* inputs, boutons */
|
||||||
|
--rayon-pastille: 999px;
|
||||||
|
--ombre-menu: 0 8px 24px rgb(16 30 54 / 0.14);
|
||||||
|
--rail: 3px; /* motif signature : rail actif safran */
|
||||||
|
|
||||||
|
/* ——— Mouvement ——— */
|
||||||
|
--transition: 140ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ——— Thème sombre (« machinerie de nuit », pas une inversion) ——— */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--nav-fond: #0c1524;
|
||||||
|
--nav-encre: #a7b4c8;
|
||||||
|
--nav-encre-vif: #f2f6fc;
|
||||||
|
--nav-bordure: #1e2c44;
|
||||||
|
|
||||||
|
--primaire: #6e92e8;
|
||||||
|
--primaire-actif: #8facf0;
|
||||||
|
--primaire-doux: #1d2c4c;
|
||||||
|
--safran: #e89a1f;
|
||||||
|
--safran-doux: #33270f;
|
||||||
|
|
||||||
|
--fond: #0f1622;
|
||||||
|
--surface: #182234;
|
||||||
|
--surface-2: #1e2a40;
|
||||||
|
--bordure: #2b3850;
|
||||||
|
--bordure-forte: #3d4d6b;
|
||||||
|
--encre: #e8edf5;
|
||||||
|
--encre-2: #a7b4c8;
|
||||||
|
--encre-3: #6d7d96;
|
||||||
|
|
||||||
|
--st-ouvert: #7da2ee; --st-ouvert-fond: #1c2a47;
|
||||||
|
--st-encours: #a394ec; --st-encours-fond: #262040;
|
||||||
|
--st-attente: #e0a33c; --st-attente-fond: #322510;
|
||||||
|
--st-termine: #4bc084; --st-termine-fond: #12301f;
|
||||||
|
--st-annule: #93a3ba; --st-annule-fond: #222d3f;
|
||||||
|
|
||||||
|
--prio-bloque: #f26d6d; --prio-bloque-fond: #3a1414;
|
||||||
|
--prio-haute: #f26d6d;
|
||||||
|
--prio-moyenne: #e0a33c;
|
||||||
|
--prio-basse: #4bc084;
|
||||||
|
--prio-aucune: #6d7d96;
|
||||||
|
|
||||||
|
--succes: #4bc084;
|
||||||
|
--alerte: #e0a33c;
|
||||||
|
--danger: #f26d6d;
|
||||||
|
--info: #7da2ee;
|
||||||
|
|
||||||
|
--graf-1: #5580e4;
|
||||||
|
--graf-2: #c7820a;
|
||||||
|
--graf-3: #26a896;
|
||||||
|
--graf-4: #9673de;
|
||||||
|
--graf-grille: #24304a;
|
||||||
|
|
||||||
|
--ombre-menu: 0 8px 24px rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Le sélecteur de thème de l'app doit GAGNER dans les deux sens. */
|
||||||
|
:root[data-theme="light"] {
|
||||||
|
--nav-fond: #16233b; --nav-encre: #c6d2e4; --nav-encre-vif: #ffffff; --nav-bordure: #24344f;
|
||||||
|
--primaire: #1f4fb8; --primaire-actif: #1a439c; --primaire-doux: #eaf0fb;
|
||||||
|
--safran: #dd8a0b; --safran-doux: #fdf3e3;
|
||||||
|
--fond: #f5f7fa; --surface: #ffffff; --surface-2: #eef2f7;
|
||||||
|
--bordure: #dce3ec; --bordure-forte: #b9c4d4;
|
||||||
|
--encre: #1b2534; --encre-2: #55647a; --encre-3: #8494ab;
|
||||||
|
--st-ouvert: #3d6fe0; --st-ouvert-fond: #e9effc;
|
||||||
|
--st-encours: #6d5bd8; --st-encours-fond: #efecfa;
|
||||||
|
--st-attente: #b96f07; --st-attente-fond: #fbf1df;
|
||||||
|
--st-termine: #178a50; --st-termine-fond: #e6f5ec;
|
||||||
|
--st-annule: #68788f; --st-annule-fond: #edf0f4;
|
||||||
|
--prio-bloque: #d92626; --prio-bloque-fond: #fdeaea;
|
||||||
|
--prio-haute: #d92626; --prio-moyenne: #b96f07; --prio-basse: #178a50; --prio-aucune: #8494ab;
|
||||||
|
--succes: #178a50; --alerte: #b96f07; --danger: #d92626; --info: #3d6fe0;
|
||||||
|
--graf-1: #3b69d6; --graf-2: #dd8a0b; --graf-3: #1f9e8e; --graf-4: #8a63d2;
|
||||||
|
--graf-grille: #e6ebf2;
|
||||||
|
--ombre-menu: 0 8px 24px rgb(16 30 54 / 0.14);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--nav-fond: #0c1524; --nav-encre: #a7b4c8; --nav-encre-vif: #f2f6fc; --nav-bordure: #1e2c44;
|
||||||
|
--primaire: #6e92e8; --primaire-actif: #8facf0; --primaire-doux: #1d2c4c;
|
||||||
|
--safran: #e89a1f; --safran-doux: #33270f;
|
||||||
|
--fond: #0f1622; --surface: #182234; --surface-2: #1e2a40;
|
||||||
|
--bordure: #2b3850; --bordure-forte: #3d4d6b;
|
||||||
|
--encre: #e8edf5; --encre-2: #a7b4c8; --encre-3: #6d7d96;
|
||||||
|
--st-ouvert: #7da2ee; --st-ouvert-fond: #1c2a47;
|
||||||
|
--st-encours: #a394ec; --st-encours-fond: #262040;
|
||||||
|
--st-attente: #e0a33c; --st-attente-fond: #322510;
|
||||||
|
--st-termine: #4bc084; --st-termine-fond: #12301f;
|
||||||
|
--st-annule: #93a3ba; --st-annule-fond: #222d3f;
|
||||||
|
--prio-bloque: #f26d6d; --prio-bloque-fond: #3a1414;
|
||||||
|
--prio-haute: #f26d6d; --prio-moyenne: #e0a33c; --prio-basse: #4bc084; --prio-aucune: #6d7d96;
|
||||||
|
--succes: #4bc084; --alerte: #e0a33c; --danger: #f26d6d; --info: #7da2ee;
|
||||||
|
--graf-1: #5580e4; --graf-2: #c7820a; --graf-3: #26a896; --graf-4: #9673de;
|
||||||
|
--graf-grille: #24304a;
|
||||||
|
--ombre-menu: 0 8px 24px rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root { --transition: 0ms; }
|
||||||
|
}
|
||||||
31
apps/web/src/theme/theme.test.ts
Normal file
31
apps/web/src/theme/theme.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { applyThemeMode, getThemeMode, nextThemeMode } from './theme';
|
||||||
|
|
||||||
|
describe('bi-thème (le choix explicite doit gagner dans les deux sens)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cycle auto → clair → sombre → auto', () => {
|
||||||
|
expect(nextThemeMode('auto')).toBe('light');
|
||||||
|
expect(nextThemeMode('light')).toBe('dark');
|
||||||
|
expect(nextThemeMode('dark')).toBe('auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pose et retire data-theme sur <html>', () => {
|
||||||
|
applyThemeMode('dark');
|
||||||
|
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||||
|
expect(getThemeMode()).toBe('dark');
|
||||||
|
|
||||||
|
applyThemeMode('auto');
|
||||||
|
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
||||||
|
expect(getThemeMode()).toBe('auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignore une valeur persistée invalide', () => {
|
||||||
|
localStorage.setItem('siop.theme', 'néon');
|
||||||
|
expect(getThemeMode()).toBe('auto');
|
||||||
|
});
|
||||||
|
});
|
||||||
34
apps/web/src/theme/theme.ts
Normal file
34
apps/web/src/theme/theme.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Bi-thème (charte §2) : « auto » suit le système ; le choix explicite est
|
||||||
|
* posé en data-theme sur <html> — tokens.css garantit qu'il GAGNE dans les
|
||||||
|
* deux sens. Persisté en localStorage.
|
||||||
|
*/
|
||||||
|
export type ThemeMode = 'auto' | 'light' | 'dark';
|
||||||
|
|
||||||
|
const THEME_KEY = 'siop.theme';
|
||||||
|
const ORDER: ThemeMode[] = ['auto', 'light', 'dark'];
|
||||||
|
|
||||||
|
export function getThemeMode(): ThemeMode {
|
||||||
|
const saved = localStorage.getItem(THEME_KEY);
|
||||||
|
return saved === 'light' || saved === 'dark' ? saved : 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyThemeMode(mode: ThemeMode): void {
|
||||||
|
if (mode === 'auto') {
|
||||||
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
localStorage.removeItem(THEME_KEY);
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', mode);
|
||||||
|
localStorage.setItem(THEME_KEY, mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextThemeMode(current: ThemeMode): ThemeMode {
|
||||||
|
return ORDER[(ORDER.indexOf(current) + 1) % ORDER.length]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const THEME_LABELS: Record<ThemeMode, string> = {
|
||||||
|
auto: 'Thème : auto',
|
||||||
|
light: 'Thème : clair',
|
||||||
|
dark: 'Thème : sombre',
|
||||||
|
};
|
||||||
18
apps/web/tsconfig.json
Normal file
18
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
21
apps/web/vite.config.ts
Normal file
21
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': path.resolve(__dirname, 'src') },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// Même topologie qu'en prod (nginx : /api → apps/api sans préfixe)
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -50,6 +50,12 @@
|
|||||||
--graf-4: #8a63d2;
|
--graf-4: #8a63d2;
|
||||||
--graf-grille: #e6ebf2;
|
--graf-grille: #e6ebf2;
|
||||||
|
|
||||||
|
/* ——— Navigation (sidebar toujours sombre — identité) ——— */
|
||||||
|
--nav-fond: #16233b;
|
||||||
|
--nav-encre: #c6d2e4;
|
||||||
|
--nav-encre-vif: #ffffff;
|
||||||
|
--nav-bordure: #24344f;
|
||||||
|
|
||||||
/* ——— Typographie ——— */
|
/* ——— Typographie ——— */
|
||||||
--police-ui: "Manrope", "Avenir Next", "Segoe UI Variable", system-ui, sans-serif;
|
--police-ui: "Manrope", "Avenir Next", "Segoe UI Variable", system-ui, sans-serif;
|
||||||
--police-mono: "SF Mono", ui-monospace, "Cascadia Mono", monospace;
|
--police-mono: "SF Mono", ui-monospace, "Cascadia Mono", monospace;
|
||||||
@@ -71,6 +77,11 @@
|
|||||||
/* ——— Thème sombre (« machinerie de nuit », pas une inversion) ——— */
|
/* ——— Thème sombre (« machinerie de nuit », pas une inversion) ——— */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root {
|
||||||
|
--nav-fond: #0c1524;
|
||||||
|
--nav-encre: #a7b4c8;
|
||||||
|
--nav-encre-vif: #f2f6fc;
|
||||||
|
--nav-bordure: #1e2c44;
|
||||||
|
|
||||||
--primaire: #6e92e8;
|
--primaire: #6e92e8;
|
||||||
--primaire-actif: #8facf0;
|
--primaire-actif: #8facf0;
|
||||||
--primaire-doux: #1d2c4c;
|
--primaire-doux: #1d2c4c;
|
||||||
@@ -115,6 +126,7 @@
|
|||||||
|
|
||||||
/* Le sélecteur de thème de l'app doit GAGNER dans les deux sens. */
|
/* Le sélecteur de thème de l'app doit GAGNER dans les deux sens. */
|
||||||
:root[data-theme="light"] {
|
:root[data-theme="light"] {
|
||||||
|
--nav-fond: #16233b; --nav-encre: #c6d2e4; --nav-encre-vif: #ffffff; --nav-bordure: #24344f;
|
||||||
--primaire: #1f4fb8; --primaire-actif: #1a439c; --primaire-doux: #eaf0fb;
|
--primaire: #1f4fb8; --primaire-actif: #1a439c; --primaire-doux: #eaf0fb;
|
||||||
--safran: #dd8a0b; --safran-doux: #fdf3e3;
|
--safran: #dd8a0b; --safran-doux: #fdf3e3;
|
||||||
--fond: #f5f7fa; --surface: #ffffff; --surface-2: #eef2f7;
|
--fond: #f5f7fa; --surface: #ffffff; --surface-2: #eef2f7;
|
||||||
@@ -133,6 +145,7 @@
|
|||||||
--ombre-menu: 0 8px 24px rgb(16 30 54 / 0.14);
|
--ombre-menu: 0 8px 24px rgb(16 30 54 / 0.14);
|
||||||
}
|
}
|
||||||
:root[data-theme="dark"] {
|
:root[data-theme="dark"] {
|
||||||
|
--nav-fond: #0c1524; --nav-encre: #a7b4c8; --nav-encre-vif: #f2f6fc; --nav-bordure: #1e2c44;
|
||||||
--primaire: #6e92e8; --primaire-actif: #8facf0; --primaire-doux: #1d2c4c;
|
--primaire: #6e92e8; --primaire-actif: #8facf0; --primaire-doux: #1d2c4c;
|
||||||
--safran: #e89a1f; --safran-doux: #33270f;
|
--safran: #e89a1f; --safran-doux: #33270f;
|
||||||
--fond: #0f1622; --surface: #182234; --surface-2: #1e2a40;
|
--fond: #0f1622; --surface: #182234; --surface-2: #1e2a40;
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-15 — Pr. Daaif (+ Claude) — R0.11 : apps/web (connexion + sélecteur démo, coquille, /design)
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- `apps/web` (React 19 + Vite + Tailwind v4) : `tokens.css` copié tel quel depuis 02-design ; classes composants **extraites de la maquette validée** ; Manrope auto-hébergée (@fontsource) ; primitives shadcn-style possédées (Button/variants cva, menu Radix).
|
||||||
|
- **Client typé** : `pnpm generate:client` → `src/api/schema.d.ts` généré depuis `docs/openapi.json` et committé (règle d'or) ; openapi-fetch + injection du jeton.
|
||||||
|
- Écrans : **connexion** (fidèle maquette, liste démo masquée si l'API répond 404 — ADR-002), **coquille** sidebar (4 groupes, rail safran actif, écrans à venir marqués R1-R3) + topbar (recherche ⌘K, bascule de thème, chip DÉMO, **sélecteur de rôle** dans le menu compte), **/design** (référence vivante : nuanciers, typo, boutons, pastilles), tableau de bord R0 minimal.
|
||||||
|
- Compléments : tokens `--nav-*` ajoutés à `tokens.css` (valeurs issues de la maquette validée) ; **seed réaligné sur les personas de la maquette** (Salma Idrissi, Ahmed Benali, …) pour des revues pixel cohérentes.
|
||||||
|
- **Vérifié dans un vrai navigateur** (Playwright + Chrome headless) : parcours démo-login → tableau de bord → bascule Dispatcher→Technicien en **101 ms** (critère < 3 s) → /design clair & sombre ; 7 captures, zéro erreur console. Typecheck, 3 tests vitest, build prod OK.
|
||||||
|
|
||||||
|
**Décisions**
|
||||||
|
|
||||||
|
- Le web ne lit pas `VITE_DEMO_MODE` : la présence du mode démo est déduite de la réponse de `/auth/demo-accounts` (404 ⇒ rien n'est affiché) — une seule source de vérité, l'API.
|
||||||
|
- Les entrées de navigation des releases futures restent visibles mais inertes, marquées R1/R2/R3 — le périmètre est annoncé, pas simulé.
|
||||||
|
|
||||||
|
**Prochaine étape** : R0.12 — CI GitHub Actions (lint, typecheck, tests, couverture api ≥ 70 %, `ci-contract` sur openapi.json/clients, Playwright du parcours démo) puis R0.13 Dockerfiles + runbook Dokploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-15 — Pr. Daaif (+ Claude) — R0.10 : apps/api complète (auth, matrice, démo-login, seed)
|
## 2026-07-15 — Pr. Daaif (+ Claude) — R0.10 : apps/api complète (auth, matrice, démo-login, seed)
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
2599
pnpm-lock.yaml
generated
2599
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user