11 Commits

Author SHA1 Message Date
pr-daaif
3aa7189e96 docs: 🏁 R2 close — recettée, déployée en ligne, tag release/r2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:12:04 +01:00
pr-daaif
f7c0da1b0f fix(r2): un OT clôturé disparaissait des « Interventions récentes » des rôles voyant tout
Anomalie de recette (référent) : OT assigné à Ahmed, traité, clôturé —
visible dans son tableau de bord mais pas dans celui de Salma ni de
l'admin. Cause : liste triée par statut (Terminés en queue) alors que le
tableau de bord prend les 5 premières lignes ; les listes scopées
(technicien) sont courtes, celles des rôles « voir autre » non.

- tri par dernière activité (updatedAt desc) : un OT fraîchement clôturé
  remonte en tête pour tous
- urgences « personne bloquée » ACTIVES épinglées au sommet (une urgence
  annulée ne squatte plus la tête — corrigé au passage)
- test de régression dans la recette e2e (position ≤ urgences actives)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:01:17 +01:00
pr-daaif
50254daea4 feat(r2.4): portail public QR — signalement sans compte, suivi par jeton
- 3 routes publiques /portal (les seules @Public métier), throttlées
  (@nestjs/throttler : 10 signalements/min, 60 lectures/min) : résolution
  du QR, signalement (retourne un jeton de suivi opaque), suivi par
  référence + jeton — pas de jeton, pas de lecture, jamais de liste
- migration r2_portail : Request.publicToken (unique)
- page /q/{réf} fidèle à l'écran 6 validé R0 (mobile d'abord) : équipement
  prérempli « détecté par le QR », interrupteur personne bloquée,
  suivi sans jargon Reçu → Intervention → Résolu (dérivé de l'OT lié),
  rejet affiché « Sans suite : {motif} », photo annoncée (upload R3) ;
  signalements gardés sur le téléphone (localStorage, 10 max)
- e2e : LA boucle produit — gardien sans compte → traitement → ✓ Résolu ;
  11 tests Playwright verts, 50 tests API, 52 opérations au contrat
- générateur OpenAPI : paramètres non-« id » plus typés uuid

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:33:10 +01:00
pr-daaif
6a2dae1710 feat(r2.3): écrans web exploitation — OT, demandes, préventif, compteurs, urgence
- liste OT (filtres, strie rouge, « immédiat ») ; fiche OT : transitions
  pilotées par allowedTransitions, clôture grisée avec la garde expliquée,
  bilan codé (6 selects sur référentiels), checklist Fait→N-A→à faire,
  activité + commentaires, assignation, annulation motivée
- nouvel OT : interrupteur « personne bloquée » qui force la priorité
- demandes : table + panneau d'approbation (priorité, assignation,
  approuver → fiche OT), rejet en modale à motif obligatoire, signalement
  interne ; statut « Résolue » dérivé de l'OT lié
- préventif : tuiles réelles, générer + résumé (« regénérer ne double
  rien »), gabarits administrables ; compteurs : saisie + historique
- tableau de bord réel : bandeau urgence cliquable, KPIs, OT par statut,
  interventions récentes ; accueil dédié aux rôles sans exploitation
- urgence traversante : chip topbar pulsante (60 s), badges de nav
- GET /assets/options (auth seule) : le Demandeur peut désigner l'appareil
  qu'il signale — trou débusqué par l'e2e (49 opérations au contrat)
- génération préventive durcie : collision de référence RETENTÉE (plus de
  saut silencieux), P2003 toléré ; 3 runs Jest complets consécutifs verts
- 9 tests Playwright (recette R2 officielle rejouée intégralement),
  50 tests API (94,6 % / 78,9 %)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:13:20 +01:00
pr-daaif
902efa6192 feat(r2.2): préventif — génération mensuelle idempotente — et compteurs
- migration : Asset.underContract + WorkOrder.periodKey avec unicité
  [assetId, periodKey] — l'idempotence de la génération est EN BASE
  (deux appels concurrents ne doublent jamais une grille)
- génération : une grille par appareil sous contrat, périodicités ancrées
  sur la mise en service (mensuelles toujours dues), premier contrôle
  (toutes les tâches) pour un appareil sans historique, échéance fin de
  mois, événement GENERATED ; déclenchement manuel (cron au durcissement)
- gabarits administrables (désactivé = exclu des générations suivantes) ;
  GET /preventive/status (générées, terminées, en retard, premiers contrôles)
- compteurs : GET meters (2 compteurs, récents d'abord) ; relevé
  strictement croissant, refus motivé sinon
- contrat +7 opérations (48) ; 50 tests verts (94,7 % / 78 %) ; smoke test
  prod : juillet généré (9 grilles, 8 premiers contrôles), 2e appel à zéro

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:15:57 +01:00
pr-daaif
f7702e4252 feat(r2.1): socle backend exploitation — machine à états, bilan codé, demandes 1-1
- migration r2_exploitation (10 tables) : WorkOrder (référence séquentielle,
  horodatages), WorkOrderEvent, Request (1-1, motif de rejet), ReferenceValue,
  InterventionReport (6 FK), TaskTemplate/ChecklistItem, Meter/MeterReading
- contrat : 15 opérations (41 total) ; la table des transitions et les champs
  requis du bilan vivent dans @siop/shared ; la fiche OT expose
  allowedTransitions + closureBlockers (messages métier)
- API : machine à états stricte ; garde de clôture (bilan 3 champs requis +
  checklist sans tâche en attente) ; approbation → OT lié 1-1 (409 si déjà
  traitée) ; rejet à motif obligatoire ; scoping « voir autre » sur listes et
  accès directs (404 sans fuite) ; validation des valeurs de bilan par champ ;
  « personne bloquée » triée en tête côté API
- seed : 31 valeurs de référentiels, 8 gabarits (parachute réglementaire),
  OT/demandes/compteurs de la maquette — idempotent
- 45 tests verts (95 % stmts / 79 % branches) dont la recette officielle
  rejouée de bout en bout ; smoke test sur build de prod

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:56:07 +01:00
pr-daaif
54d926e9f4 docs(r2): kickoff design-first — maquettes des 5 écrans manquants à valider
- maquette-r2.html (moule validé) : demandes + panneau d'approbation
  (approuver → OT 1-1, rejet à motif requis), nouvel OT (interrupteur
  personne bloquée), préventif (gabarits à périodicité, essai parachute
  réglementaire, génération du mois idempotente, premiers contrôles),
  OT préventif (checklist Fait/N-A, clôture bloquée), compteurs (relevés
  croissants)
- les écrans cœur (dashboard, liste OT, fiche OT/bilan codé, portail)
  restent ceux validés en R0
- rendu vérifié (5 écrans, bi-thème, zéro erreur)
- BLOQUANT : validation du référent avant tout code applicatif R2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:33:43 +01:00
pr-daaif
c4d1ab7225 docs: 🏁 R1 close — recettée, déployée en ligne, tag release/r1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:23:52 +01:00
pr-daaif
9778629827 feat(r1.2): écrans web du référentiel — 7 écrans fidèles à maquette-r1
- Sites : liste + carte Leaflet/OSM réelle (pins maquette, positions
  PostGIS), création en modale avec position posée au clic sur la carte
- Fiche site : arbre site→zones, appareils rattachés, ajout de zone
- Ascenseurs : table dense, filtres site/statut, strie rouge à l'arrêt ;
  fiche appareil (organes ± ajout/retrait, statut, QR réel) ; création
  identité → rattachement → organes ; étiquette A6 imprimable (objet
  papier, window.print n'imprime qu'elle)
- Personnes & équipes : invitation → lien d'activation affiché à copier
  (pas d'email en R1, décision explicite), renvoi de lien, équipes ;
  page /activation (choix du mot de passe, connexion directe)
- Catégories : ajout, renommage inline, désactivation
- navigation et actions pilotées par la matrice (usePermissions) —
  l'API re-vérifie chaque requête
- e2e Playwright : recette R1 officielle rejouée intégralement (site →
  zone → appareil+organe → étiquette → invitation → activation) +
  parcours « la matrice pilote l'UI » ; purge idempotente en globalSetup
- alias Vite @siop/shared → source TS (exports nommés CJS ↯ workspace lié)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:06:45 +01:00
pr-daaif
266ffaaf1b feat(r1.1): socle backend du référentiel — modèle, contrat, API, seed, tests
- migration r1_referentiel : Category (EQUIPMENT/COMPONENT_TYPE), Location
  (site → zone, lat/lng + colonne PostGIS générée geography(Point,4326)
  + index GIST), Asset (statut d'équipement), AssetComponent (organe sans
  emplacement PAR CONSTRUCTION), Team, invitation sur User ; migration
  autosuffisante (CREATE EXTENSION IF NOT EXISTS postgis)
- contrat : 21 nouvelles opérations (26 total), générateur OpenAPI étendu
  aux paramètres de chemin ; spec + client web régénérés dans ce commit
- API : modules categories/locations/assets/teams + gestion des personnes
  (liste, rôles, invitation lien 7 j à usage unique, activation publique
  qui connecte directement, mise à jour rôle/équipes) — tout sous
  @RequirePermission ; invariants en service (profondeur 2, kinds,
  catégorie jamais supprimée)
- seed : parc de la maquette validée (5 sites + 8 zones, 8 appareils,
  organes A1/B2, 9 catégories, 2 équipes) — idempotent
- 36 tests verts (couverture 96 % stmts / 85 % branches) : recette
  site→zone→appareil→organes, matrice vivante, invitation→activation ;
  smoke test sur build de prod
- CI : postgres → postgis/postgis:18-3.6 (la migration R1 l'exige)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:27:48 +01:00
pr-daaif
6d9aafdab4 docs(r1): kickoff design-first — maquettes HD R1 (7 écrans) à valider
- R0 close : tag release/r0 (recette + production en ligne)
- maquette-r1.html dans le moule validé (CSS répliquée, tokens identiques) :
  sites + carte PostGIS, fiche site (hiérarchie d'emplacements), liste
  ascenseurs (statuts d'équipement distincts des statuts OT), création
  (identité → rattachement → organes), étiquette QR imprimable A6,
  personnes & équipes (invitation par lien d'activation), catégories
- rendu vérifié en Chrome headless (7 écrans, bi-thème, zéro erreur)
- BLOQUANT : validation du référent avant tout code applicatif R1

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:00:05 +01:00
105 changed files with 20817 additions and 92 deletions

View File

@@ -59,10 +59,10 @@ jobs:
name: api (tests + couverture ≥ 70 %) name: api (tests + couverture ≥ 70 %)
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
# R0 : PostgreSQL nu suffit (r0_identity). Dès que des tests exigeront # R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography).
# pgvector/PostGIS (R1+), passer sur l'image infra/postgres. # pgvector arrivera en R5 (bascule alors sur limage infra/postgres).
postgres: postgres:
image: postgres:18 image: postgis/postgis:18-3.6
env: env:
POSTGRES_USER: siop POSTGRES_USER: siop
POSTGRES_PASSWORD: siop POSTGRES_PASSWORD: siop
@@ -115,7 +115,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
postgres: postgres:
image: postgres:18 image: postgis/postgis:18-3.6
env: env:
POSTGRES_USER: siop POSTGRES_USER: siop
POSTGRES_PASSWORD: siop POSTGRES_PASSWORD: siop

View File

@@ -39,6 +39,15 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **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.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.12 — tests + CI** (5 jobs verts) : `lint` (ESLint 10 flat config racine, règle ADR-001 anti-import MinIO codée et vérifiée), `ci-contract` (régénération spec+client, diff bloquant), `api` (PostgreSQL 18 + Redis, migrate deploy, Jest couverture 70 % bloquante mesurée 97,5 %), `web` (typecheck + vitest + build), `e2e` (Playwright : parcours démo, bascule < 3 s chronométrée, déconnexion). Badge au README. - **R0.12 — tests + CI** (5 jobs verts) : `lint` (ESLint 10 flat config racine, règle ADR-001 anti-import MinIO codée et vérifiée), `ci-contract` (régénération spec+client, diff bloquant), `api` (PostgreSQL 18 + Redis, migrate deploy, Jest couverture 70 % bloquante mesurée 97,5 %), `web` (typecheck + vitest + build), `e2e` (Playwright : parcours démo, bascule < 3 s chronométrée, déconnexion). Badge au README.
- **R0.13 — Dockerfiles + runbook Dokploy** : image api (multi-stage pnpm deploy, `prisma migrate deploy` au boot, seed optionnel `SEED_ON_START`, non-root, healthcheck ; `binaryTargets` explicites) ; image web (nginx, proxy `/api` résolu à la requête, fallback SPA, cache assets) ; `infra/docker-compose.dokploy.yml` (5 services `siop2-`, seul le web sur `dokploy-network`) ; runbook `docs/06-production/runbook-dokploy.md`. **Répétition locale complète validée** (migrate+seed au boot, parcours via nginx conteneurisé, double verrou ADR-002 observé). Le déploiement réel attend les accès au serveur du partenaire. - **R0.13 — Dockerfiles + runbook Dokploy** : image api (multi-stage pnpm deploy, `prisma migrate deploy` au boot, seed optionnel `SEED_ON_START`, non-root, healthcheck ; `binaryTargets` explicites) ; image web (nginx, proxy `/api` résolu à la requête, fallback SPA, cache assets) ; `infra/docker-compose.dokploy.yml` (5 services `siop2-`, seul le web sur `dokploy-network`) ; runbook `docs/06-production/runbook-dokploy.md`. **Répétition locale complète validée** (migrate+seed au boot, parcours via nginx conteneurisé, double verrou ADR-002 observé). Le déploiement réel attend les accès au serveur du partenaire.
- 🚀 **R0 EN PRODUCTION (16/07/2026)** : `https://siop2.apps.enset.top` (Dokploy ENSET, profil démo, vérifiée : health ok, 7 comptes, demo-login, 401 sans jeton, HTTPS). La production client SPELEV attend les accès au serveur du partenaire. - **R0 CLOSE** (tag `release/r0`) en production : `https://siop2.apps.enset.top` (Dokploy ENSET, profil démo, vérifiée en ligne). Restes non bloquants : secret `DOKPLOY_WEBHOOK_URL` (CD), sauvegardes PostgreSQL Dokploy. Production client SPELEV : attend les accès au serveur du partenaire.
- 🔄 **R0reste** : secret `DOKPLOY_WEBHOOK_URL` pour activer le CD (runbook §4), sauvegardes PostgreSQL Dokploy, recette R0 avec le référent (revue pixel écrans maquettes) ouverture de R1 Référentiel. - **R1maquettes validées** (16/07) : `maquette-r1.html`, 7 écrans + 4 décisions (statuts d'équipement statuts OT ; organe sans emplacement par construction ; invitation par lien 7 j ; catégories jamais supprimées si utilisées).
- **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location sitezone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`.
- **R1.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` source TS (leçon CJS/workspace).
- 🏁 **R1 CLOSE (16/07/2026, tag `release/r1`)** : recettée par le référent, déployée et vérifiée en ligne (migration + seed au boot, 5 sites / 8 appareils sur l'instance).
- **R2 — maquettes validées** (16/07) : `maquette-r2.html` (demandes/approbation, nouvel OT, préventif, checklist, compteurs) + 4 décisions actées.
- **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demandeOT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
- **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production.
- **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API.
- **R2.4 — portail public QR** : `/q/{réf}` sans compte (écran validé R0, mobile d'abord), 3 routes publiques `/portal` throttlées, suivi par jeton opaque (`Request.publicToken`, jamais de liste exposée), étapes Reçu Intervention Résolu dérivées de l'OT ; boucle produit complète en e2e (11 Playwright verts, 52 opérations).
- 🔄 **R2 — reste pour clore** : recette R2 avec le référent (revue pixel maquettes R0+R2, sur `pnpm dev` ou l'instance en ligne après déploiement), tag `release/r2` ouverture R3 Gestion (stock, BC, tiers, main-d'œuvre, analytics, bibliothèque maquettes à produire d'abord).
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.

View File

@@ -23,6 +23,7 @@
"@nestjs/core": "^11.1.0", "@nestjs/core": "^11.1.0",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/platform-express": "^11.1.0", "@nestjs/platform-express": "^11.1.0",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.8.0", "@prisma/client": "^6.8.0",
"@siop/shared": "workspace:*", "@siop/shared": "workspace:*",
"argon2": "^0.43.0", "argon2": "^0.43.0",

View File

@@ -0,0 +1,27 @@
/** Purge des données créées par les parcours Playwright (motifs dédiés).
* Appelé par le globalSetup e2e — la base locale reste propre entre les runs. */
import 'dotenv/config';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.request.deleteMany({ where: { description: { contains: 'E2E' } } });
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });
// Un appareil de test peut avoir reçu grilles/demandes (génération du mois) :
// on purge ses dépendances avant lui (FK RESTRICT).
await prisma.request.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
await prisma.workOrder.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
await prisma.asset.deleteMany({ where: { reference: { startsWith: 'E2E-' } } });
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' }, parentId: { not: null } } });
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });
await prisma.user.deleteMany({ where: { email: { startsWith: 'recrue-' } } });
}
main()
.catch((e) => {
console.error(e);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());

View File

@@ -0,0 +1,145 @@
-- Migration autosuffisante : les extensions requises sont créées si absentes
-- (la CI et tout environnement neuf nont pas notre init.sql).
CREATE EXTENSION IF NOT EXISTS postgis;
-- CreateEnum
CREATE TYPE "CategoryKind" AS ENUM ('EQUIPMENT', 'COMPONENT_TYPE');
-- CreateEnum
CREATE TYPE "AssetStatus" AS ENUM ('IN_SERVICE', 'OUT_OF_SERVICE', 'UNDER_MAINTENANCE');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "activationExpiresAt" TIMESTAMP(3),
ADD COLUMN "activationToken" TEXT,
ADD COLUMN "phone" TEXT;
-- CreateTable
CREATE TABLE "Category" (
"id" UUID NOT NULL,
"kind" "CategoryKind" NOT NULL,
"name" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Location" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"parentId" UUID,
"address" TEXT,
"city" TEXT,
"guardianName" TEXT,
"guardianPhone" TEXT,
"latitude" DOUBLE PRECISION,
"longitude" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Asset" (
"id" UUID NOT NULL,
"reference" TEXT NOT NULL,
"brand" TEXT NOT NULL,
"model" TEXT,
"serialNumber" TEXT,
"commissionedAt" TIMESTAMP(3),
"loadKg" INTEGER,
"floors" INTEGER,
"status" "AssetStatus" NOT NULL DEFAULT 'IN_SERVICE',
"categoryId" UUID NOT NULL,
"locationId" UUID NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AssetComponent" (
"id" UUID NOT NULL,
"assetId" UUID NOT NULL,
"typeId" UUID NOT NULL,
"designation" TEXT,
CONSTRAINT "AssetComponent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Team" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_TeamToUser" (
"A" UUID NOT NULL,
"B" UUID NOT NULL,
CONSTRAINT "_TeamToUser_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "Category_kind_name_key" ON "Category"("kind", "name");
-- CreateIndex
CREATE INDEX "Location_parentId_idx" ON "Location"("parentId");
-- CreateIndex
CREATE UNIQUE INDEX "Asset_reference_key" ON "Asset"("reference");
-- CreateIndex
CREATE INDEX "Asset_locationId_idx" ON "Asset"("locationId");
-- CreateIndex
CREATE INDEX "Asset_categoryId_idx" ON "Asset"("categoryId");
-- CreateIndex
CREATE INDEX "AssetComponent_assetId_idx" ON "AssetComponent"("assetId");
-- CreateIndex
CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name");
-- CreateIndex
CREATE INDEX "_TeamToUser_B_index" ON "_TeamToUser"("B");
-- CreateIndex
CREATE UNIQUE INDEX "User_activationToken_key" ON "User"("activationToken");
-- AddForeignKey
ALTER TABLE "Location" ADD CONSTRAINT "Location_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ————— PostGIS (hors périmètre Prisma, décision modèle R1) —————
-- Position générée depuis latitude/longitude : rien à synchroniser côté app,
-- la colonne est prête pour les requêtes spatiales (affectation auto, backlog).
ALTER TABLE "Location"
ADD COLUMN "position" geography(Point, 4326)
GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint("longitude", "latitude"), 4326)::geography) STORED;
CREATE INDEX "Location_position_gix" ON "Location" USING GIST ("position");

View File

@@ -0,0 +1,268 @@
-- CreateEnum
CREATE TYPE "WorkOrderType" AS ENUM ('CORRECTIVE', 'PREVENTIVE', 'WORKS');
-- CreateEnum
CREATE TYPE "WorkOrderStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'ON_HOLD', 'DONE', 'CANCELLED');
-- CreateEnum
CREATE TYPE "WorkOrderPriority" AS ENUM ('NONE', 'LOW', 'MEDIUM', 'HIGH', 'PERSON_TRAPPED');
-- CreateEnum
CREATE TYPE "RequestStatus" AS ENUM ('RECEIVED', 'APPROVED', 'REJECTED');
-- CreateEnum
CREATE TYPE "ChecklistState" AS ENUM ('PENDING', 'DONE', 'NA');
-- CreateEnum
CREATE TYPE "BilanField" AS ENUM ('DOOR_STATE', 'CABIN_POSITION', 'ANOMALY', 'EXTERNAL_CAUSE', 'ACTION_TAKEN', 'COMPONENT_CONCERNED');
-- CreateEnum
CREATE TYPE "MeterKind" AS ENUM ('RUNNING_HOURS', 'STARTS');
-- DropIndex
DROP INDEX "Location_position_gix";
-- AlterTable
ALTER TABLE "Location" DROP COLUMN "position";
-- CreateTable
CREATE TABLE "WorkOrder" (
"id" UUID NOT NULL,
"reference" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"type" "WorkOrderType" NOT NULL,
"status" "WorkOrderStatus" NOT NULL DEFAULT 'OPEN',
"priority" "WorkOrderPriority" NOT NULL DEFAULT 'NONE',
"assetId" UUID NOT NULL,
"dueDate" TIMESTAMP(3),
"createdById" UUID,
"startedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WorkOrder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "WorkOrderEvent" (
"id" UUID NOT NULL,
"workOrderId" UUID NOT NULL,
"kind" TEXT NOT NULL,
"message" TEXT,
"byId" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "WorkOrderEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Request" (
"id" UUID NOT NULL,
"reference" TEXT NOT NULL,
"description" TEXT NOT NULL,
"isPersonTrapped" BOOLEAN NOT NULL DEFAULT false,
"status" "RequestStatus" NOT NULL DEFAULT 'RECEIVED',
"rejectionReason" TEXT,
"assetId" UUID NOT NULL,
"requestedById" UUID,
"requesterName" TEXT,
"workOrderId" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Request_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReferenceValue" (
"id" UUID NOT NULL,
"field" "BilanField" NOT NULL,
"label" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "ReferenceValue_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InterventionReport" (
"id" UUID NOT NULL,
"workOrderId" UUID NOT NULL,
"note" TEXT,
"doorStateId" UUID,
"cabinPositionId" UUID,
"anomalyId" UUID,
"externalCauseId" UUID,
"actionTakenId" UUID,
"componentConcernedId" UUID,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "InterventionReport_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TaskTemplate" (
"id" UUID NOT NULL,
"label" TEXT NOT NULL,
"componentTypeId" UUID,
"periodMonths" INTEGER NOT NULL,
"isRegulatory" BOOLEAN NOT NULL DEFAULT false,
"isActive" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "TaskTemplate_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChecklistItem" (
"id" UUID NOT NULL,
"workOrderId" UUID NOT NULL,
"label" TEXT NOT NULL,
"state" "ChecklistState" NOT NULL DEFAULT 'PENDING',
"templateId" UUID,
"doneById" UUID,
"doneAt" TIMESTAMP(3),
CONSTRAINT "ChecklistItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Meter" (
"id" UUID NOT NULL,
"assetId" UUID NOT NULL,
"kind" "MeterKind" NOT NULL,
CONSTRAINT "Meter_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MeterReading" (
"id" UUID NOT NULL,
"meterId" UUID NOT NULL,
"value" INTEGER NOT NULL,
"readById" UUID,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MeterReading_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_WorkOrderAssignees" (
"A" UUID NOT NULL,
"B" UUID NOT NULL,
CONSTRAINT "_WorkOrderAssignees_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "WorkOrder_reference_key" ON "WorkOrder"("reference");
-- CreateIndex
CREATE INDEX "WorkOrder_assetId_idx" ON "WorkOrder"("assetId");
-- CreateIndex
CREATE INDEX "WorkOrder_status_idx" ON "WorkOrder"("status");
-- CreateIndex
CREATE INDEX "WorkOrderEvent_workOrderId_idx" ON "WorkOrderEvent"("workOrderId");
-- CreateIndex
CREATE UNIQUE INDEX "Request_reference_key" ON "Request"("reference");
-- CreateIndex
CREATE UNIQUE INDEX "Request_workOrderId_key" ON "Request"("workOrderId");
-- CreateIndex
CREATE INDEX "Request_assetId_idx" ON "Request"("assetId");
-- CreateIndex
CREATE UNIQUE INDEX "ReferenceValue_field_label_key" ON "ReferenceValue"("field", "label");
-- CreateIndex
CREATE UNIQUE INDEX "InterventionReport_workOrderId_key" ON "InterventionReport"("workOrderId");
-- CreateIndex
CREATE UNIQUE INDEX "TaskTemplate_label_key" ON "TaskTemplate"("label");
-- CreateIndex
CREATE INDEX "ChecklistItem_workOrderId_idx" ON "ChecklistItem"("workOrderId");
-- CreateIndex
CREATE UNIQUE INDEX "Meter_assetId_kind_key" ON "Meter"("assetId", "kind");
-- CreateIndex
CREATE INDEX "MeterReading_meterId_idx" ON "MeterReading"("meterId");
-- CreateIndex
CREATE INDEX "_WorkOrderAssignees_B_index" ON "_WorkOrderAssignees"("B");
-- AddForeignKey
ALTER TABLE "WorkOrder" ADD CONSTRAINT "WorkOrder_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkOrder" ADD CONSTRAINT "WorkOrder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkOrderEvent" ADD CONSTRAINT "WorkOrderEvent_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "WorkOrderEvent" ADD CONSTRAINT "WorkOrderEvent_byId_fkey" FOREIGN KEY ("byId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Request" ADD CONSTRAINT "Request_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Request" ADD CONSTRAINT "Request_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Request" ADD CONSTRAINT "Request_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_doorStateId_fkey" FOREIGN KEY ("doorStateId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_cabinPositionId_fkey" FOREIGN KEY ("cabinPositionId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_anomalyId_fkey" FOREIGN KEY ("anomalyId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_externalCauseId_fkey" FOREIGN KEY ("externalCauseId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_actionTakenId_fkey" FOREIGN KEY ("actionTakenId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_componentConcernedId_fkey" FOREIGN KEY ("componentConcernedId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskTemplate" ADD CONSTRAINT "TaskTemplate_componentTypeId_fkey" FOREIGN KEY ("componentTypeId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "TaskTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_doneById_fkey" FOREIGN KEY ("doneById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Meter" ADD CONSTRAINT "Meter_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MeterReading" ADD CONSTRAINT "MeterReading_meterId_fkey" FOREIGN KEY ("meterId") REFERENCES "Meter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MeterReading" ADD CONSTRAINT "MeterReading_readById_fkey" FOREIGN KEY ("readById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_WorkOrderAssignees" ADD CONSTRAINT "_WorkOrderAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_WorkOrderAssignees" ADD CONSTRAINT "_WorkOrderAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "Asset" ADD COLUMN "underContract" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "WorkOrder" ADD COLUMN "periodKey" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "WorkOrder_assetId_periodKey_key" ON "WorkOrder"("assetId", "periodKey");

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Request" ADD COLUMN "publicToken" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Request_publicToken_key" ON "Request"("publicToken");

View File

@@ -39,12 +39,322 @@ model User {
email String @unique email String @unique
displayName String displayName String
passwordHash String? // null tant que le compte n'est pas activé (R1) passwordHash String? // null tant que le compte n'est pas activé (R1)
phone String?
roleId String @db.Uuid roleId String @db.Uuid
role Role @relation(fields: [roleId], references: [id]) role Role @relation(fields: [roleId], references: [id])
isActive Boolean @default(true) isActive Boolean @default(true)
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002) isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
// Invitation (R1) : lien d'activation 7 jours, usage unique.
// Statut dérivé : invité = passwordHash null && token présent.
activationToken String? @unique
activationExpiresAt DateTime?
teams Team[]
// R2 — exploitation
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
workOrderEvents WorkOrderEvent[]
requests Request[]
checklistDone ChecklistItem[]
meterReadings MeterReading[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([roleId]) @@index([roleId])
} }
// ————— R1 — Référentiel (docs/03-architecture/modele-donnees.md §R1) —————
enum CategoryKind {
EQUIPMENT
COMPONENT_TYPE
}
enum AssetStatus {
IN_SERVICE
OUT_OF_SERVICE
UNDER_MAINTENANCE
}
model Category {
id String @id @default(uuid()) @db.Uuid
kind CategoryKind
name String
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
assets Asset[]
components AssetComponent[]
taskTemplates TaskTemplate[]
@@unique([kind, name])
}
model Location {
id String @id @default(uuid()) @db.Uuid
name String
parentId String? @db.Uuid // site (null) → zone ; profondeur max 2 (service)
parent Location? @relation("LocationTree", fields: [parentId], references: [id])
children Location[] @relation("LocationTree")
address String?
city String?
guardianName String?
guardianPhone String?
latitude Float?
longitude Float?
// + colonne PostGIS générée (voir migration r1_referentiel) :
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
assets Asset[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parentId])
}
model Asset {
id String @id @default(uuid()) @db.Uuid
reference String @unique // « A1 » — imprimée sur l'étiquette QR
brand String
model String?
serialNumber String?
commissionedAt DateTime?
loadKg Int?
floors Int?
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
underContract Boolean @default(true) // contrat préventif (grille mensuelle)
categoryId String @db.Uuid
category Category @relation(fields: [categoryId], references: [id])
locationId String @db.Uuid
location Location @relation(fields: [locationId], references: [id])
components AssetComponent[]
workOrders WorkOrder[]
requests Request[]
meters Meter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([locationId])
@@index([categoryId])
}
// Organe : PAS de colonne emplacement — « un organe n'a pas d'emplacement
// propre » est garanti par construction (décision maquettes R1).
model AssetComponent {
id String @id @default(uuid()) @db.Uuid
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
typeId String @db.Uuid
type Category @relation(fields: [typeId], references: [id])
designation String?
@@index([assetId])
}
model Team {
id String @id @default(uuid()) @db.Uuid
name String @unique
description String?
members User[]
}
// ————— R2 — Exploitation (docs/03-architecture/modele-donnees.md §R2) —————
enum WorkOrderType {
CORRECTIVE // Dépannage
PREVENTIVE // Maintenance (grille du mois)
WORKS // Travaux
}
enum WorkOrderStatus {
OPEN
IN_PROGRESS
ON_HOLD
DONE
CANCELLED
}
enum WorkOrderPriority {
NONE
LOW
MEDIUM
HIGH
PERSON_TRAPPED // personne bloquée — urgence absolue
}
enum RequestStatus {
RECEIVED
APPROVED
REJECTED
}
enum ChecklistState {
PENDING
DONE
NA
}
enum BilanField {
DOOR_STATE
CABIN_POSITION
ANOMALY
EXTERNAL_CAUSE
ACTION_TAKEN
COMPONENT_CONCERNED
}
enum MeterKind {
RUNNING_HOURS
STARTS
}
model WorkOrder {
id String @id @default(uuid()) @db.Uuid
reference String @unique // OT-2026-0341
title String
description String?
type WorkOrderType
status WorkOrderStatus @default(OPEN) // machine à états stricte (service)
priority WorkOrderPriority @default(NONE)
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id])
dueDate DateTime?
assignees User[] @relation("WorkOrderAssignees")
createdById String? @db.Uuid
createdBy User? @relation("WorkOrderCreator", fields: [createdById], references: [id])
startedAt DateTime?
completedAt DateTime?
cancelledAt DateTime?
events WorkOrderEvent[]
checklist ChecklistItem[]
report InterventionReport?
request Request?
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
// l'idempotence de la génération (les OT correctifs restent à null).
periodKey String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([assetId, periodKey])
@@index([assetId])
@@index([status])
}
model WorkOrderEvent {
id String @id @default(uuid()) @db.Uuid
workOrderId String @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
kind String // COMMENT · STATUS_CHANGED · ASSIGNED · CREATED · FROM_REQUEST
message String?
byId String? @db.Uuid
by User? @relation(fields: [byId], references: [id])
createdAt DateTime @default(now())
@@index([workOrderId])
}
model Request {
id String @id @default(uuid()) @db.Uuid
reference String @unique // DEM-2026-0112
description String
isPersonTrapped Boolean @default(false)
status RequestStatus @default(RECEIVED)
rejectionReason String? // REQUIS au rejet (service)
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id])
requestedById String? @db.Uuid
requestedBy User? @relation(fields: [requestedById], references: [id])
requesterName String? // portail public via QR (R2.4)
// Suivi SANS COMPTE (portail) : le téléphone du gardien garde ce jeton,
// seul moyen de lire l'avancement — jamais listé, jamais devinable.
publicToken String? @unique
workOrderId String? @unique @db.Uuid // lien 1-1 — jamais de doublon
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([assetId])
}
// Référentiels administrables du bilan codé (un par champ)
model ReferenceValue {
id String @id @default(uuid()) @db.Uuid
field BilanField
label String
isActive Boolean @default(true)
doorStates InterventionReport[] @relation("BilanDoorState")
cabinPositions InterventionReport[] @relation("BilanCabinPosition")
anomalies InterventionReport[] @relation("BilanAnomaly")
externalCauses InterventionReport[] @relation("BilanExternalCause")
actionsTaken InterventionReport[] @relation("BilanActionTaken")
componentsConcerned InterventionReport[] @relation("BilanComponentConcerned")
@@unique([field, label])
}
model InterventionReport {
id String @id @default(uuid()) @db.Uuid
workOrderId String @unique @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
note String?
doorStateId String? @db.Uuid
doorState ReferenceValue? @relation("BilanDoorState", fields: [doorStateId], references: [id])
cabinPositionId String? @db.Uuid
cabinPosition ReferenceValue? @relation("BilanCabinPosition", fields: [cabinPositionId], references: [id])
anomalyId String? @db.Uuid
anomaly ReferenceValue? @relation("BilanAnomaly", fields: [anomalyId], references: [id])
externalCauseId String? @db.Uuid
externalCause ReferenceValue? @relation("BilanExternalCause", fields: [externalCauseId], references: [id])
actionTakenId String? @db.Uuid
actionTaken ReferenceValue? @relation("BilanActionTaken", fields: [actionTakenId], references: [id])
componentConcernedId String? @db.Uuid
componentConcerned ReferenceValue? @relation("BilanComponentConcerned", fields: [componentConcernedId], references: [id])
updatedAt DateTime @updatedAt
}
model TaskTemplate {
id String @id @default(uuid()) @db.Uuid
label String @unique
componentTypeId String? @db.Uuid
componentType Category? @relation(fields: [componentTypeId], references: [id])
periodMonths Int // 1, 3, 6, 12…
isRegulatory Boolean @default(false) // essai parachute
isActive Boolean @default(true)
checklistItems ChecklistItem[]
}
model ChecklistItem {
id String @id @default(uuid()) @db.Uuid
workOrderId String @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
label String
state ChecklistState @default(PENDING)
templateId String? @db.Uuid
template TaskTemplate? @relation(fields: [templateId], references: [id])
doneById String? @db.Uuid
doneBy User? @relation(fields: [doneById], references: [id])
doneAt DateTime?
@@index([workOrderId])
}
model Meter {
id String @id @default(uuid()) @db.Uuid
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
kind MeterKind
readings MeterReading[]
@@unique([assetId, kind])
}
model MeterReading {
id String @id @default(uuid()) @db.Uuid
meterId String @db.Uuid
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
value Int // strictement croissant (service)
readById String? @db.Uuid
readBy User? @relation(fields: [readById], references: [id])
createdAt DateTime @default(now())
@@index([meterId])
}

View File

@@ -131,6 +131,16 @@ export async function seed(prisma: PrismaClient): Promise<void> {
const passwordHash = await argon2.hash( const passwordHash = await argon2.hash(
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026', process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
); );
await seedUsers(prisma, roleIds, passwordHash);
await seedReferentiel(prisma);
await seedExploitation(prisma);
}
async function seedUsers(
prisma: PrismaClient,
roleIds: Map<RoleName, string>,
passwordHash: string,
): Promise<void> {
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 // 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). // à chaque exécution (jamais le mot de passe d'un compte existant).
@@ -148,6 +158,428 @@ export async function seed(prisma: PrismaClient): Promise<void> {
} }
} }
// ————— R1 — Référentiel (données des maquettes validées) —————
const EQUIPMENT_CATEGORIES = [
'Ascenseur électrique',
'Ascenseur hydraulique',
'Monte-charge',
'EPMR (plateforme PMR)',
];
const COMPONENT_TYPES = [
'Portes cabine / palières',
'Treuil / machinerie',
'Parachute',
'Armoire de commande',
'Boutons & signalisation',
];
/** site → zones ; positions réelles Casablanca/Mohammedia. */
const SITES: {
name: string;
address: string;
city: string;
guardianName?: string;
guardianPhone?: string;
latitude: number;
longitude: number;
zones: string[];
}[] = [
{
name: 'Tour Atlas', address: 'Bd de la Corniche, Aïn Diab', city: 'Casablanca',
guardianName: 'Karim Doukkali', guardianPhone: '06 61 23 45 67',
latitude: 33.6062, longitude: -7.6706,
zones: ['Hall principal', 'Tour bureaux (étages 1-24)', 'Parking sous-sol', 'Résidence (aile est)'],
},
{
name: 'Résidence Al Manar', address: 'Bd Hassan II', city: 'Mohammedia',
guardianName: 'Hassan Alami',
latitude: 33.6866, longitude: -7.383,
zones: ['Hall principal'],
},
{
name: 'Anfa Place', address: 'Bd de lOcéan Atlantique', city: 'Casablanca',
latitude: 33.5883, longitude: -7.6822,
zones: ['Galerie commerciale'],
},
{
name: 'Clinique Yasmine', address: 'Rue Ibn Rochd', city: 'Casablanca',
guardianName: 'Rachid Mansouri',
latitude: 33.5731, longitude: -7.6316,
zones: ['Bloc A'],
},
{
name: 'Marina Center', address: 'Av. des FAR', city: 'Mohammedia',
latitude: 33.7, longitude: -7.39,
zones: ['Hall B'],
},
];
const ASSETS: {
reference: string; brand: string; model: string; serialNumber?: string;
commissionedAt?: string; loadKg?: number; floors?: number;
category: string; site: string; zone: string;
status?: 'IN_SERVICE' | 'OUT_OF_SERVICE' | 'UNDER_MAINTENANCE';
components?: { type: string; designation?: string }[];
}[] = [
{
reference: 'A1', brand: 'Otis', model: 'Gen2 Premier', serialNumber: 'OT-2020-4521',
commissionedAt: '2020-03-15', loadKg: 630, floors: 8,
category: 'Ascenseur électrique', site: 'Résidence Al Manar', zone: 'Hall principal',
components: [
{ type: 'Portes cabine / palières', designation: 'Fermator 40/10' },
{ type: 'Treuil / machinerie', designation: 'Gen2 gearless' },
{ type: 'Parachute' },
{ type: 'Armoire de commande', designation: 'MCS 220' },
],
},
{
reference: 'A2', brand: 'Otis', model: 'Gen2', loadKg: 630, floors: 12,
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Hall principal',
},
{
reference: 'B1', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
},
{
reference: 'B2', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
status: 'OUT_OF_SERVICE',
components: [
{ type: 'Portes cabine / palières', designation: 'Sematic' },
{ type: 'Armoire de commande' },
],
},
{
reference: 'M1', brand: 'Kone', model: 'TranSys', loadKg: 2000, floors: 3,
category: 'Monte-charge', site: 'Tour Atlas', zone: 'Parking sous-sol',
status: 'UNDER_MAINTENANCE',
},
{
reference: 'C1', brand: 'Kone', model: 'MonoSpace 500', loadKg: 800, floors: 5,
category: 'Ascenseur électrique', site: 'Anfa Place', zone: 'Galerie commerciale',
},
{
reference: 'D1', brand: 'ThyssenKrupp', model: 'Evolution', loadKg: 1600, floors: 6,
category: 'Ascenseur électrique', site: 'Clinique Yasmine', zone: 'Bloc A',
},
{
reference: 'E2', brand: 'Otis', model: 'HydroFit', loadKg: 630, floors: 4,
category: 'Ascenseur hydraulique', site: 'Marina Center', zone: 'Hall B',
},
];
const TEAMS: { name: string; description: string; memberEmails: string[] }[] = [
{
name: 'Casablanca Centre',
description: 'Tour Atlas · Anfa Place · Clinique Yasmine',
memberEmails: ['technicien@demo.siop.ma', 'technicien-limite@demo.siop.ma'],
},
{
name: 'Mohammedia',
description: 'Résidence Al Manar · Marina Center',
memberEmails: [],
},
];
async function seedReferentiel(prisma: PrismaClient): Promise<void> {
const categoryIds = new Map<string, string>();
for (const [kind, names] of [
['EQUIPMENT', EQUIPMENT_CATEGORIES],
['COMPONENT_TYPE', COMPONENT_TYPES],
] as const) {
for (const name of names) {
const category = await prisma.category.upsert({
where: { kind_name: { kind, name } },
update: {},
create: { kind, name },
});
categoryIds.set(name, category.id);
}
}
// Pas d'unicité en base sur (parent, nom) : idempotence par findFirst.
const zoneIds = new Map<string, string>(); // « site / zone » → id
for (const site of SITES) {
const { zones, ...data } = site;
let root = await prisma.location.findFirst({
where: { name: site.name, parentId: null },
});
root ??= await prisma.location.create({ data });
for (const zoneName of zones) {
let zone = await prisma.location.findFirst({
where: { name: zoneName, parentId: root.id },
});
zone ??= await prisma.location.create({
data: { name: zoneName, parentId: root.id, city: site.city },
});
zoneIds.set(`${site.name} / ${zoneName}`, zone.id);
}
}
for (const asset of ASSETS) {
const created = await prisma.asset.upsert({
where: { reference: asset.reference },
update: {},
create: {
reference: asset.reference,
brand: asset.brand,
model: asset.model,
serialNumber: asset.serialNumber,
commissionedAt: asset.commissionedAt ? new Date(asset.commissionedAt) : undefined,
loadKg: asset.loadKg,
floors: asset.floors,
status: asset.status ?? 'IN_SERVICE',
categoryId: categoryIds.get(asset.category)!,
locationId: zoneIds.get(`${asset.site} / ${asset.zone}`)!,
},
include: { _count: { select: { components: true } } },
});
if (asset.components?.length && created._count.components === 0) {
await prisma.assetComponent.createMany({
data: asset.components.map((c) => ({
assetId: created.id,
typeId: categoryIds.get(c.type)!,
designation: c.designation,
})),
});
}
}
for (const team of TEAMS) {
await prisma.team.upsert({
where: { name: team.name },
update: {},
create: {
name: team.name,
description: team.description,
members: { connect: team.memberEmails.map((email) => ({ email })) },
},
});
}
}
// ————— R2 — Exploitation (référentiels du bilan, gabarits, données maquette) —————
const REFERENCE_VALUES: Record<string, string[]> = {
DOOR_STATE: [
'Fonctionnement normal',
'Porte bloquée ouverte',
'Porte bloquée fermée',
'Fermeture incomplète',
'Réouverture intempestive',
],
CABIN_POSITION: ['À niveau', 'Entre deux niveaux', 'Cuvette', 'Dernier niveau'],
ANOMALY: [
'Frottement mécanique',
'Défaut électrique',
'Usure normale',
'Choc / vandalisme',
'Aucune anomalie constatée',
],
EXTERNAL_CAUSE: ['Coupure électrique', 'Dégât des eaux', 'Mauvais usage', 'Aucune'],
ACTION_TAKEN: [
'Réglage',
'Remplacement de pièce',
'Nettoyage / graissage',
'Remise en service simple',
'Visite dentretien',
'Attente de pièce',
],
COMPONENT_CONCERNED: [
'Portes',
'Guides',
'Treuil / machinerie',
'Armoire de commande',
'Boutons / signalisation',
'Parachute',
'Cabine',
],
};
/** Gabarits du préventif (maquette R2) — période calendaire en mois. */
const TASK_TEMPLATES: {
label: string;
periodMonths: number;
componentType?: string;
isRegulatory?: boolean;
}[] = [
{ label: 'Contrôle fermeture / verrouillage des portes', periodMonths: 1, componentType: 'Portes cabine / palières' },
{ label: 'Nettoyage cuvette et toit de cabine', periodMonths: 1 },
{ label: 'Contrôle boutons cabine & paliers', periodMonths: 1, componentType: 'Boutons & signalisation' },
{ label: 'Vérification éclairage de secours', periodMonths: 1 },
{ label: 'Contrôle niveau dhuile réducteur', periodMonths: 3, componentType: 'Treuil / machinerie' },
{ label: 'Vérification jeu des coulisseaux', periodMonths: 6 },
{ label: 'Contrôle câbles de traction (usure, tension)', periodMonths: 6, componentType: 'Treuil / machinerie' },
{ label: 'Essai du parachute', periodMonths: 12, componentType: 'Parachute', isRegulatory: true },
];
async function seedExploitation(prisma: PrismaClient): Promise<void> {
const refIds = new Map<string, string>(); // « FIELD/label » → id
for (const [field, labels] of Object.entries(REFERENCE_VALUES)) {
for (const label of labels) {
const value = await prisma.referenceValue.upsert({
where: { field_label: { field: field as never, label } },
update: {},
create: { field: field as never, label },
});
refIds.set(`${field}/${label}`, value.id);
}
}
const componentTypes = new Map(
(await prisma.category.findMany({ where: { kind: 'COMPONENT_TYPE' } })).map((c) => [
c.name,
c.id,
]),
);
for (const t of TASK_TEMPLATES) {
await prisma.taskTemplate.upsert({
where: { label: t.label },
update: {},
create: {
label: t.label,
periodMonths: t.periodMonths,
isRegulatory: t.isRegulatory ?? false,
componentTypeId: t.componentType ? componentTypes.get(t.componentType) : undefined,
},
});
}
// Données de démonstration (rejouent la maquette : OT en cours, urgence,
// grille du mois, demandes à approuver). Idempotent par référence.
const parReference = async (ref: string) =>
(await prisma.asset.findUniqueOrThrow({ where: { reference: ref } })).id;
const parEmail = async (email: string) =>
(await prisma.user.findUniqueOrThrow({ where: { email } })).id;
const a1 = await parReference('A1');
const b2 = await parReference('B2');
const c1 = await parReference('C1');
const ahmed = await parEmail('technicien@demo.siop.ma');
const salma = await parEmail('dispatcher@demo.siop.ma');
const karim = await parEmail('demandeur@demo.siop.ma');
const annee = new Date().getFullYear();
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
(t) => t.label,
);
const OTS: {
ref: string; title: string; type: 'CORRECTIVE' | 'PREVENTIVE' | 'WORKS';
status: 'OPEN' | 'IN_PROGRESS' | 'ON_HOLD' | 'DONE' | 'CANCELLED';
priority: 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'PERSON_TRAPPED';
assetId: string; assignees?: string[]; dueJours?: number;
checklist?: string[]; bilan?: Record<string, string>;
}[] = [
{
ref: `OT-${annee}-0342`, title: 'Personne bloquée en cabine', type: 'CORRECTIVE',
status: 'OPEN', priority: 'PERSON_TRAPPED', assetId: b2,
},
{
ref: `OT-${annee}-0341`, title: 'Bruit anormal en gaine', type: 'CORRECTIVE',
status: 'IN_PROGRESS', priority: 'HIGH', assetId: a1, assignees: [ahmed], dueJours: 2,
},
{
ref: `OT-${annee}-0338`, title: 'Grille du mois — juillet', type: 'PREVENTIVE',
status: 'IN_PROGRESS', priority: 'LOW', assetId: a1, assignees: [ahmed],
dueJours: 15, checklist: grilleLabels,
},
{
ref: `OT-${annee}-0332`, title: 'Réglage nivellement cabine', type: 'CORRECTIVE',
status: 'DONE', priority: 'LOW', assetId: a1,
bilan: {
doorStateId: refIds.get('DOOR_STATE/Fonctionnement normal')!,
actionTakenId: refIds.get('ACTION_TAKEN/Réglage')!,
componentConcernedId: refIds.get('COMPONENT_CONCERNED/Guides')!,
},
},
];
for (const ot of OTS) {
const existant = await prisma.workOrder.findUnique({ where: { reference: ot.ref } });
if (existant) continue;
await prisma.workOrder.create({
data: {
reference: ot.ref,
title: ot.title,
type: ot.type,
status: ot.status,
priority: ot.priority,
assetId: ot.assetId,
createdById: salma,
dueDate: ot.dueJours
? new Date(Date.now() + ot.dueJours * 24 * 3600 * 1000)
: undefined,
startedAt: ot.status === 'IN_PROGRESS' || ot.status === 'DONE' ? new Date() : undefined,
completedAt: ot.status === 'DONE' ? new Date() : undefined,
assignees: ot.assignees ? { connect: ot.assignees.map((id) => ({ id })) } : undefined,
events: { create: { kind: 'CREATED', message: 'OT créé (seed)', byId: salma } },
checklist: ot.checklist
? { create: ot.checklist.map((label) => ({ label })) }
: undefined,
report: ot.bilan ? { create: ot.bilan } : undefined,
},
});
}
const DEMANDES: {
ref: string; description: string; assetId: string; isPersonTrapped?: boolean;
status: 'RECEIVED' | 'APPROVED' | 'REJECTED';
rejectionReason?: string; otRef?: string;
}[] = [
{
ref: `DEM-${annee}-0111`, assetId: a1, status: 'RECEIVED',
description: 'La porte ne se ferme plus au 3ᵉ étage, il faut la retenir à la main.',
},
{ ref: `DEM-${annee}-0110`, assetId: c1, status: 'RECEIVED', description: 'Voyant étage éteint.' },
{
ref: `DEM-${annee}-0107`, assetId: a1, status: 'APPROVED',
description: 'Bruit anormal en gaine.', otRef: `OT-${annee}-0341`,
},
{
ref: `DEM-${annee}-0104`, assetId: b2, status: 'REJECTED',
description: 'Odeur de brûlé.', rejectionReason: 'Fausse alerte confirmée sur place par le gardien.',
},
];
for (const dem of DEMANDES) {
const existant = await prisma.request.findUnique({ where: { reference: dem.ref } });
if (existant) continue;
const workOrderId = dem.otRef
? (await prisma.workOrder.findUnique({ where: { reference: dem.otRef } }))?.id
: undefined;
await prisma.request.create({
data: {
reference: dem.ref,
description: dem.description,
isPersonTrapped: dem.isPersonTrapped ?? false,
status: dem.status,
rejectionReason: dem.rejectionReason,
assetId: dem.assetId,
requestedById: karim,
workOrderId,
},
});
}
// Compteurs de la maquette (A1)
for (const [kind, valeurs] of [
['RUNNING_HOURS', [12246, 12322, 12411]],
['STARTS', [1815400, 1831970]],
] as const) {
const meter = await prisma.meter.upsert({
where: { assetId_kind: { assetId: a1, kind } },
update: {},
create: { assetId: a1, kind },
include: { _count: { select: { readings: true } } },
});
if (meter._count.readings === 0) {
await prisma.meterReading.createMany({
data: valeurs.map((value) => ({ meterId: meter.id, value, readById: ahmed })),
});
}
}
}
/* c8 ignore start — wrapper CLI */ /* c8 ignore start — wrapper CLI */
if (require.main === module) { if (require.main === module) {
const prisma = new PrismaClient(); const prisma = new PrismaClient();

View File

@@ -1,11 +1,21 @@
import { DynamicModule, Module } from '@nestjs/common'; import { DynamicModule, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AssetsModule } from './assets/assets.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { DemoAuthModule } from './auth/demo/demo-auth.module'; import { DemoAuthModule } from './auth/demo/demo-auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { CategoriesModule } from './categories/categories.module';
import { demoModeEnabled } from './config/env'; import { demoModeEnabled } from './config/env';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { LocationsModule } from './locations/locations.module';
import { MetersModule } from './meters/meters.module';
import { PortalModule } from './portal/portal.module';
import { PreventiveModule } from './preventive/preventive.module';
import { ReferenceValuesModule } from './reference-values/reference-values.module';
import { RequestsModule } from './requests/requests.module';
import { TeamsModule } from './teams/teams.module';
import { WorkOrdersModule } from './work-orders/work-orders.module';
import { PermissionsGuard } from './permissions/permissions.guard'; import { PermissionsGuard } from './permissions/permissions.guard';
import { PermissionsModule } from './permissions/permissions.module'; import { PermissionsModule } from './permissions/permissions.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
@@ -28,6 +38,18 @@ export class AppModule {
AuthModule, AuthModule,
UsersModule, UsersModule,
HealthModule, HealthModule,
// R1 — référentiel
CategoriesModule,
LocationsModule,
AssetsModule,
TeamsModule,
// R2 — exploitation
WorkOrdersModule,
RequestsModule,
ReferenceValuesModule,
PreventiveModule,
MetersModule,
PortalModule,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404 // ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []), ...(demoModeEnabled() ? [DemoAuthModule] : []),
], ],

View File

@@ -0,0 +1,80 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
AssetComponentCreateSchema,
AssetCreateSchema,
AssetUpdateSchema,
type AssetComponentCreate,
type AssetCreate,
type AssetUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { AssetsService } from './assets.service';
@Controller('assets')
export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}
@Get()
@RequirePermission('ASSETS', 'view')
list() {
return this.assetsService.list();
}
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
* doit pouvoir désigner l'appareil qu'il signale. */
@Get('options')
options() {
return this.assetsService.options();
}
@Get(':id')
@RequirePermission('ASSETS', 'view')
get(@Param('id', ParseUUIDPipe) id: string) {
return this.assetsService.get(id);
}
@Post()
@RequirePermission('ASSETS', 'create')
create(@Body(new ZodValidationPipe(AssetCreateSchema)) body: AssetCreate) {
return this.assetsService.create(body);
}
@Patch(':id')
@RequirePermission('ASSETS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(AssetUpdateSchema)) body: AssetUpdate,
) {
return this.assetsService.update(id, body);
}
@Post(':id/components')
@RequirePermission('ASSETS', 'edit')
addComponent(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(AssetComponentCreateSchema)) body: AssetComponentCreate,
) {
return this.assetsService.addComponent(id, body);
}
@Delete(':id/components/:componentId')
@RequirePermission('ASSETS', 'edit')
@HttpCode(204)
removeComponent(
@Param('id', ParseUUIDPipe) id: string,
@Param('componentId', ParseUUIDPipe) componentId: string,
) {
return this.assetsService.removeComponent(id, componentId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service';
@Module({
controllers: [AssetsController],
providers: [AssetsService],
exports: [AssetsService],
})
export class AssetsModule {}

View File

@@ -0,0 +1,199 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
AssetComponentCreate,
AssetComponentDto,
AssetCreate,
AssetDetail,
AssetDto,
AssetOptionsResponse,
AssetsResponse,
AssetUpdate,
} from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
const assetInclude = {
category: true,
location: { include: { parent: true } },
_count: { select: { components: true } },
} satisfies Prisma.AssetInclude;
type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
@Injectable()
export class AssetsService {
constructor(private readonly prisma: PrismaService) {}
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
async options(): Promise<AssetOptionsResponse> {
const rows = await this.prisma.asset.findMany({
include: { location: { include: { parent: true } } },
orderBy: { reference: 'asc' },
});
return {
options: rows.map((r) => ({
id: r.id,
reference: r.reference,
siteName: r.location.parent?.name ?? r.location.name,
})),
};
}
async list(): Promise<AssetsResponse> {
const rows = await this.prisma.asset.findMany({
include: assetInclude,
orderBy: { reference: 'asc' },
});
return { assets: rows.map((r) => this.toDto(r)) };
}
async get(id: string): Promise<AssetDetail> {
const row = await this.prisma.asset.findUnique({
where: { id },
include: { ...assetInclude, components: { include: { type: true } } },
});
if (!row) throw new NotFoundException('Appareil inconnu');
return {
...this.toDto(row),
components: row.components.map((c) => this.toComponentDto(c)),
};
}
async create(dto: AssetCreate): Promise<AssetDetail> {
await this.assertEquipmentCategory(dto.categoryId);
await this.assertLocation(dto.locationId);
if (dto.components?.length) {
await this.assertComponentTypes(dto.components.map((c) => c.typeId));
}
try {
const created = await this.prisma.asset.create({
data: {
reference: dto.reference,
brand: dto.brand,
model: dto.model,
serialNumber: dto.serialNumber,
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
loadKg: dto.loadKg,
floors: dto.floors,
categoryId: dto.categoryId,
locationId: dto.locationId,
components: dto.components?.length
? { create: dto.components }
: undefined,
},
select: { id: true },
});
return this.get(created.id);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Cette référence est déjà utilisée');
}
throw e;
}
}
async update(id: string, dto: AssetUpdate): Promise<AssetDetail> {
if (dto.categoryId) await this.assertEquipmentCategory(dto.categoryId);
if (dto.locationId) await this.assertLocation(dto.locationId);
try {
await this.prisma.asset.update({
where: { id },
data: {
...dto,
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
},
select: { id: true },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Appareil inconnu');
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Cette référence est déjà utilisée');
}
throw e;
}
return this.get(id);
}
async addComponent(
assetId: string,
dto: AssetComponentCreate,
): Promise<AssetComponentDto> {
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new NotFoundException('Appareil inconnu');
await this.assertComponentTypes([dto.typeId]);
const created = await this.prisma.assetComponent.create({
data: { assetId, ...dto },
include: { type: true },
});
return this.toComponentDto(created);
}
async removeComponent(assetId: string, componentId: string): Promise<void> {
const { count } = await this.prisma.assetComponent.deleteMany({
where: { id: componentId, assetId },
});
if (count === 0) throw new NotFoundException('Organe inconnu');
}
private async assertEquipmentCategory(categoryId: string): Promise<void> {
const category = await this.prisma.category.findUnique({ where: { id: categoryId } });
if (!category || category.kind !== 'EQUIPMENT' || !category.isActive) {
throw new BadRequestException(
'La catégorie choisie nest pas une catégorie déquipement active',
);
}
}
private async assertLocation(locationId: string): Promise<void> {
const location = await this.prisma.location.findUnique({ where: { id: locationId } });
if (!location) throw new BadRequestException('Emplacement inconnu');
}
private async assertComponentTypes(typeIds: string[]): Promise<void> {
const types = await this.prisma.category.findMany({
where: { id: { in: typeIds } },
});
const valid =
types.length === new Set(typeIds).size &&
types.every((t) => t.kind === 'COMPONENT_TYPE' && t.isActive);
if (!valid) {
throw new BadRequestException('Chaque organe doit avoir un type dorgane actif');
}
}
private toComponentDto(c: { id: string; typeId: string; designation: string | null; type: { name: string } }): AssetComponentDto {
return {
id: c.id,
typeId: c.typeId,
typeName: c.type.name,
designation: c.designation,
};
}
private toDto(row: AssetRow): AssetDto {
return {
id: row.id,
reference: row.reference,
brand: row.brand,
model: row.model,
serialNumber: row.serialNumber,
commissionedAt: row.commissionedAt?.toISOString() ?? null,
loadKg: row.loadKg,
floors: row.floors,
status: row.status,
categoryId: row.categoryId,
categoryName: row.category.name,
locationId: row.locationId,
locationName: row.location.name,
siteName: row.location.parent?.name ?? row.location.name,
componentCount: row._count.components,
};
}
}

View File

@@ -1,5 +1,10 @@
import { Body, Controller, HttpCode, Post } from '@nestjs/common'; import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import { LoginRequestSchema, type LoginRequest } from '@siop/shared'; import {
ActivateRequestSchema,
LoginRequestSchema,
type ActivateRequest,
type LoginRequest,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { Public } from './public.decorator'; import { Public } from './public.decorator';
@@ -14,4 +19,12 @@ export class AuthController {
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) { login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
return this.authService.login(body.email, body.password); return this.authService.login(body.email, body.password);
} }
/** Publique par nature : la personne n'a pas encore de compte actif. */
@Public()
@Post('activate')
@HttpCode(200)
activate(@Body(new ZodValidationPipe(ActivateRequestSchema)) body: ActivateRequest) {
return this.authService.activate(body.token, body.password);
}
} }

View File

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -75,6 +76,35 @@ export class AuthService {
return this.issueToken(user); return this.issueToken(user);
} }
/** R1 — activation d'un compte invité : lien 7 jours, usage unique,
* choisit le mot de passe et connecte directement. */
async activate(token: string, password: string): Promise<AuthResponse> {
const user = await this.prisma.user.findUnique({
where: { activationToken: token },
include: { role: true },
});
if (
!user ||
!user.isActive ||
!user.activationExpiresAt ||
user.activationExpiresAt < new Date()
) {
throw new BadRequestException(
'Ce lien dactivation est invalide ou a expiré — demandez un nouveau lien',
);
}
const activated = await this.prisma.user.update({
where: { id: user.id },
data: {
passwordHash: await argon2.hash(password),
activationToken: null,
activationExpiresAt: null,
},
include: { role: true },
});
return this.issueToken(activated);
}
private async issueToken(user: UserWithRole): Promise<AuthResponse> { private async issueToken(user: UserWithRole): Promise<AuthResponse> {
// Identité seule — les droits restent en base (invariant R0) // Identité seule — les droits restent en base (invariant R0)
const accessToken = await this.jwtService.signAsync({ const accessToken = await this.jwtService.signAsync({

View File

@@ -0,0 +1,44 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
CategoryCreateSchema,
CategoryUpdateSchema,
type CategoryCreate,
type CategoryUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { CategoriesService } from './categories.service';
@Controller('categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
/** Donnée de référence lue par les formulaires — authentification seule. */
@Get()
list() {
return this.categoriesService.list();
}
@Post()
@RequirePermission('SETTINGS', 'create')
create(@Body(new ZodValidationPipe(CategoryCreateSchema)) body: CategoryCreate) {
return this.categoriesService.create(body);
}
@Patch(':id')
@RequirePermission('SETTINGS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(CategoryUpdateSchema)) body: CategoryUpdate,
) {
return this.categoriesService.update(id, body);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}

View File

@@ -0,0 +1,75 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CategoriesResponse,
Category,
CategoryCreate,
CategoryUpdate,
} from '@siop/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<CategoriesResponse> {
const rows = await this.prisma.category.findMany({
include: { _count: { select: { assets: true, components: true } } },
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
});
return {
categories: rows.map((c) => ({
id: c.id,
kind: c.kind,
name: c.name,
isActive: c.isActive,
usageCount: c.kind === 'EQUIPMENT' ? c._count.assets : c._count.components,
})),
};
}
async create(dto: CategoryCreate): Promise<Category> {
try {
const created = await this.prisma.category.create({ data: dto });
return { ...created, usageCount: 0 };
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
}
throw e;
}
}
/** Renommage / (dés)activation — la suppression n'existe pas (invariant R1). */
async update(id: string, dto: CategoryUpdate): Promise<Category> {
try {
const updated = await this.prisma.category.update({
where: { id },
data: dto,
include: { _count: { select: { assets: true, components: true } } },
});
return {
id: updated.id,
kind: updated.kind,
name: updated.name,
isActive: updated.isActive,
usageCount:
updated.kind === 'EQUIPMENT'
? updated._count.assets
: updated._count.components,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Catégorie inconnue');
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
}
throw e;
}
}
}

View File

@@ -0,0 +1,44 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
LocationCreateSchema,
LocationUpdateSchema,
type LocationCreate,
type LocationUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { LocationsService } from './locations.service';
@Controller('locations')
export class LocationsController {
constructor(private readonly locationsService: LocationsService) {}
@Get()
@RequirePermission('LOCATIONS', 'view')
list() {
return this.locationsService.list();
}
@Post()
@RequirePermission('LOCATIONS', 'create')
create(@Body(new ZodValidationPipe(LocationCreateSchema)) body: LocationCreate) {
return this.locationsService.create(body);
}
@Patch(':id')
@RequirePermission('LOCATIONS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(LocationUpdateSchema)) body: LocationUpdate,
) {
return this.locationsService.update(id, body);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LocationsController } from './locations.controller';
import { LocationsService } from './locations.service';
@Module({
controllers: [LocationsController],
providers: [LocationsService],
exports: [LocationsService],
})
export class LocationsModule {}

View File

@@ -0,0 +1,113 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
LocationCreate,
LocationDto,
LocationsResponse,
LocationUpdate,
} from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
type LocationRow = {
id: string;
name: string;
parentId: string | null;
address: string | null;
city: string | null;
guardianName: string | null;
guardianPhone: string | null;
latitude: number | null;
longitude: number | null;
_count: { assets: number };
};
@Injectable()
export class LocationsService {
constructor(private readonly prisma: PrismaService) {}
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
async list(): Promise<LocationsResponse> {
const rows: LocationRow[] = await this.prisma.location.findMany({
include: { _count: { select: { assets: true } } },
orderBy: { name: 'asc' },
});
const childAssets = new Map<string, number>();
for (const row of rows) {
if (row.parentId) {
childAssets.set(
row.parentId,
(childAssets.get(row.parentId) ?? 0) + row._count.assets,
);
}
}
return {
locations: rows.map((row) =>
this.toDto(row, row._count.assets + (childAssets.get(row.id) ?? 0)),
),
};
}
async create(dto: LocationCreate): Promise<LocationDto> {
await this.assertDepth(dto.parentId);
const created = await this.prisma.location.create({
data: dto,
include: { _count: { select: { assets: true } } },
});
return this.toDto(created, 0);
}
async update(id: string, dto: LocationUpdate): Promise<LocationDto> {
const existing = await this.prisma.location.findUnique({
where: { id },
include: { _count: { select: { children: true, assets: true } } },
});
if (!existing) throw new NotFoundException('Emplacement inconnu');
if (dto.parentId) {
if (dto.parentId === id) {
throw new BadRequestException('Un emplacement ne peut pas être son propre parent');
}
if (existing._count.children > 0) {
throw new BadRequestException(
'Ce site a des zones : il ne peut pas devenir une zone (hiérarchie limitée à 2 niveaux)',
);
}
await this.assertDepth(dto.parentId);
}
const updated = await this.prisma.location.update({
where: { id },
data: dto,
include: { _count: { select: { assets: true } } },
});
return this.toDto(updated, updated._count.assets);
}
/** Invariant R1 : site → zone, jamais plus profond. */
private async assertDepth(parentId?: string): Promise<void> {
if (!parentId) return;
const parent = await this.prisma.location.findUnique({ where: { id: parentId } });
if (!parent) throw new BadRequestException('Emplacement parent inconnu');
if (parent.parentId) {
throw new BadRequestException(
'Hiérarchie limitée à 2 niveaux : une zone ne peut pas contenir demplacement',
);
}
}
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
return {
id: row.id,
name: row.name,
parentId: row.parentId,
address: row.address,
city: row.city,
guardianName: row.guardianName,
guardianPhone: row.guardianPhone,
latitude: row.latitude,
longitude: row.longitude,
assetCount,
};
}
}

View File

@@ -0,0 +1,40 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
} from '@nestjs/common';
import {
MeterReadingCreateSchema,
type MeterReadingCreate,
} from '@siop/shared';
import {
AuthenticatedUser,
CurrentUser,
} from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { MetersService } from './meters.service';
@Controller('assets/:id')
export class MetersController {
constructor(private readonly meters: MetersService) {}
@Get('meters')
@RequirePermission('METERS', 'view')
list(@Param('id', ParseUUIDPipe) id: string) {
return this.meters.list(id);
}
@Post('meter-readings')
@RequirePermission('METERS', 'create')
addReading(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(MeterReadingCreateSchema)) body: MeterReadingCreate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.meters.addReading(id, body, user);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { MetersController } from './meters.controller';
import { MetersService } from './meters.service';
@Module({
controllers: [MetersController],
providers: [MetersService],
})
export class MetersModule {}

View File

@@ -0,0 +1,77 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { METER_KINDS, type MeterReadingCreate, type MetersResponse } from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class MetersService {
constructor(private readonly prisma: PrismaService) {}
async list(assetId: string): Promise<MetersResponse> {
await this.assertAsset(assetId);
const meters = await this.prisma.meter.findMany({
where: { assetId },
include: {
readings: {
include: { readBy: true },
orderBy: { createdAt: 'desc' },
take: 10,
},
},
});
// Les deux compteurs existent toujours dans la réponse, même vides
return {
meters: METER_KINDS.map((kind) => {
const meter = meters.find((m) => m.kind === kind);
return {
kind,
readings:
meter?.readings.map((r) => ({
id: r.id,
value: r.value,
readBy: r.readBy
? { id: r.readBy.id, displayName: r.readBy.displayName }
: null,
createdAt: r.createdAt.toISOString(),
})) ?? [],
};
}),
};
}
/** Invariant : un compteur ne redescend jamais. */
async addReading(
assetId: string,
dto: MeterReadingCreate,
user: AuthenticatedUser,
): Promise<MetersResponse> {
await this.assertAsset(assetId);
const meter = await this.prisma.meter.upsert({
where: { assetId_kind: { assetId, kind: dto.kind } },
update: {},
create: { assetId, kind: dto.kind },
});
const dernier = await this.prisma.meterReading.findFirst({
where: { meterId: meter.id },
orderBy: { createdAt: 'desc' },
});
if (dernier && dto.value <= dernier.value) {
throw new BadRequestException(
`Relevé refusé : ${dto.value} est inférieur ou égal au précédent (${dernier.value}) — un compteur ne redescend pas`,
);
}
await this.prisma.meterReading.create({
data: { meterId: meter.id, value: dto.value, readById: user.userId },
});
return this.list(assetId);
}
private async assertAsset(assetId: string): Promise<void> {
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new NotFoundException('Appareil inconnu');
}
}

View File

@@ -0,0 +1,40 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import {
PortalRequestCreateSchema,
type PortalRequestCreate,
} from '@siop/shared';
import { Public } from '../auth/public.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { PortalService } from './portal.service';
/** Portail public (QR cabine) : seules routes @Public métier de l'API —
* protégées par throttling (surface exposée sans compte). */
@Controller('portal')
@UseGuards(ThrottlerGuard)
export class PortalController {
constructor(private readonly portal: PortalService) {}
@Public()
@Get('assets/:reference')
@Throttle({ default: { ttl: 60_000, limit: 60 } })
resolveAsset(@Param('reference') reference: string) {
return this.portal.resolveAsset(reference);
}
@Public()
@Post('requests')
@Throttle({ default: { ttl: 60_000, limit: 10 } }) // anti-spam signalement
createRequest(
@Body(new ZodValidationPipe(PortalRequestCreateSchema)) body: PortalRequestCreate,
) {
return this.portal.createRequest(body);
}
@Public()
@Get('requests/:reference/:token')
@Throttle({ default: { ttl: 60_000, limit: 60 } })
status(@Param('reference') reference: string, @Param('token') token: string) {
return this.portal.status(reference, token);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { ThrottlerModule } from '@nestjs/throttler';
import { PortalController } from './portal.controller';
import { PortalService } from './portal.service';
@Module({
imports: [ThrottlerModule.forRoot([{ ttl: 60_000, limit: 60 }])],
controllers: [PortalController],
providers: [PortalService],
})
export class PortalModule {}

View File

@@ -0,0 +1,106 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
PortalAsset,
PortalRequestCreate,
PortalRequestCreated,
PortalRequestStatus,
PortalStep,
} from '@siop/shared';
import { randomBytes } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
const requestInclude = { workOrder: true } satisfies Prisma.RequestInclude;
type Row = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
@Injectable()
export class PortalService {
constructor(private readonly prisma: PrismaService) {}
/** Le QR encode la référence (imprimée sur l'étiquette). */
async resolveAsset(reference: string): Promise<PortalAsset> {
const asset = await this.prisma.asset.findUnique({
where: { reference },
include: { location: { include: { parent: true } } },
});
if (!asset) throw new NotFoundException('Référence inconnue');
return {
reference: asset.reference,
siteName: asset.location.parent?.name ?? asset.location.name,
locationName: asset.location.name,
};
}
async createRequest(dto: PortalRequestCreate): Promise<PortalRequestCreated> {
const asset = await this.prisma.asset.findUnique({
where: { reference: dto.assetReference },
});
if (!asset) throw new NotFoundException('Référence inconnue');
for (let essai = 0; ; essai++) {
try {
const created = await this.prisma.request.create({
data: {
reference: await this.nextReference(),
description: dto.description,
isPersonTrapped: dto.isPersonTrapped ?? false,
assetId: asset.id,
requesterName: dto.requesterName || 'Portail (QR cabine)',
publicToken: randomBytes(24).toString('base64url'),
},
include: requestInclude,
});
return { ...this.toStatus(created), publicToken: created.publicToken! };
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002' &&
essai < 3
) {
continue; // collision de référence — on retente
}
throw e;
}
}
}
/** Suivi par jeton opaque — pas de jeton, pas de lecture (aucune fuite). */
async status(reference: string, token: string): Promise<PortalRequestStatus> {
const request = await this.prisma.request.findUnique({
where: { reference },
include: requestInclude,
});
if (!request || !request.publicToken || request.publicToken !== token) {
throw new NotFoundException('Signalement inconnu ou jeton invalide');
}
return this.toStatus(request);
}
private async nextReference(): Promise<string> {
const annee = new Date().getFullYear();
const dernier = await this.prisma.request.findFirst({
where: { reference: { startsWith: `DEM-${annee}-` } },
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
}
private toStatus(row: Row): PortalRequestStatus {
const step: PortalStep =
row.workOrder?.status === 'DONE'
? 'RESOLVED'
: row.workOrder && row.workOrder.startedAt
? 'IN_PROGRESS'
: 'RECEIVED';
return {
reference: row.reference,
description: row.description,
isPersonTrapped: row.isPersonTrapped,
status: row.status,
step,
rejectionReason: row.rejectionReason,
createdAt: row.createdAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,69 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
PreventiveGenerateSchema,
TaskTemplateCreateSchema,
TaskTemplateUpdateSchema,
type PreventiveGenerate,
type TaskTemplateCreate,
type TaskTemplateUpdate,
} from '@siop/shared';
import {
AuthenticatedUser,
CurrentUser,
} from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { PreventiveService } from './preventive.service';
@Controller('preventive')
export class PreventiveController {
constructor(private readonly preventive: PreventiveService) {}
@Get('templates')
@RequirePermission('WORK_ORDERS', 'view')
listTemplates() {
return this.preventive.listTemplates();
}
@Post('templates')
@RequirePermission('SETTINGS', 'create')
createTemplate(
@Body(new ZodValidationPipe(TaskTemplateCreateSchema)) body: TaskTemplateCreate,
) {
return this.preventive.createTemplate(body);
}
@Patch('templates/:id')
@RequirePermission('SETTINGS', 'edit')
updateTemplate(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(TaskTemplateUpdateSchema)) body: TaskTemplateUpdate,
) {
return this.preventive.updateTemplate(id, body);
}
@Post('generate')
@HttpCode(200) // idempotent : rien n'est « créé » de plus au second appel
@RequirePermission('WORK_ORDERS', 'create')
generate(
@Body(new ZodValidationPipe(PreventiveGenerateSchema)) body: PreventiveGenerate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.preventive.generate(body.month, user);
}
@Get('status')
@RequirePermission('WORK_ORDERS', 'view')
status() {
return this.preventive.status();
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PreventiveController } from './preventive.controller';
import { PreventiveService } from './preventive.service';
@Module({
controllers: [PreventiveController],
providers: [PreventiveService],
})
export class PreventiveModule {}

View File

@@ -0,0 +1,234 @@
import {
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
PreventiveGenerationResult,
PreventiveStatus,
TaskTemplateCreate,
TaskTemplateDto,
TaskTemplatesResponse,
TaskTemplateUpdate,
} from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PrismaService } from '../prisma/prisma.service';
const templateInclude = {
componentType: true,
} satisfies Prisma.TaskTemplateInclude;
type TemplateRow = Prisma.TaskTemplateGetPayload<{ include: typeof templateInclude }>;
const MOIS_FR = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
];
/** « AAAA-MM » → index absolu de mois. */
const indexMois = (period: string): number => {
const [annee, mois] = period.split('-').map(Number);
return annee! * 12 + (mois! - 1);
};
@Injectable()
export class PreventiveService {
private readonly logger = new Logger(PreventiveService.name);
constructor(private readonly prisma: PrismaService) {}
// ————— Gabarits —————
async listTemplates(): Promise<TaskTemplatesResponse> {
const rows = await this.prisma.taskTemplate.findMany({
include: templateInclude,
orderBy: [{ periodMonths: 'asc' }, { label: 'asc' }],
});
return { templates: rows.map((t) => this.toDto(t)) };
}
async createTemplate(dto: TaskTemplateCreate): Promise<TaskTemplateDto> {
try {
const created = await this.prisma.taskTemplate.create({
data: dto,
include: templateInclude,
});
return this.toDto(created);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce libellé de tâche existe déjà');
}
throw e;
}
}
async updateTemplate(id: string, dto: TaskTemplateUpdate): Promise<TaskTemplateDto> {
try {
const updated = await this.prisma.taskTemplate.update({
where: { id },
data: dto,
include: templateInclude,
});
return this.toDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Gabarit inconnu');
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce libellé de tâche existe déjà');
}
throw e;
}
}
// ————— Génération mensuelle (IDEMPOTENTE) —————
/**
* Une grille par appareil sous contrat et par mois — l'unicité
* [assetId, periodKey] est en BASE : regénérer ne double jamais rien,
* même en cas d'appels concurrents.
* Tâches dues : périodicité ancrée sur la mise en service (à défaut, tout
* mois) ; appareil sans historique préventif → « premier contrôle » (tout).
*/
async generate(
month: string | undefined,
user: AuthenticatedUser,
): Promise<PreventiveGenerationResult> {
const period = month ?? new Date().toISOString().slice(0, 7);
const [annee, mois] = period.split('-').map(Number);
const dernierJour = new Date(Date.UTC(annee!, mois!, 0, 23, 59, 59));
const libelleMois = `${MOIS_FR[mois! - 1]} ${annee}`;
const [assets, templates] = await Promise.all([
this.prisma.asset.findMany({
where: { underContract: true },
include: { _count: { select: { workOrders: { where: { type: 'PREVENTIVE' } } } } },
}),
this.prisma.taskTemplate.findMany({ where: { isActive: true } }),
]);
let created = 0;
let skipped = 0;
let firstControls = 0;
const references: string[] = [];
for (const asset of assets) {
const premierControle = asset._count.workOrders === 0;
const dues = premierControle
? templates
: templates.filter((t) => {
if (t.periodMonths === 1) return true;
const ancre = asset.commissionedAt
? asset.commissionedAt.getUTCFullYear() * 12 + asset.commissionedAt.getUTCMonth()
: indexMois(period);
return (indexMois(period) - ancre) % t.periodMonths === 0;
});
if (dues.length === 0) {
skipped++;
continue;
}
let issue: 'créé' | 'sauté' | null = null;
for (let essai = 0; essai < 4 && issue === null; essai++) {
try {
const ot = await this.prisma.workOrder.create({
data: {
reference: await this.nextReference(),
title: premierControle
? `Premier contrôle — ${libelleMois}`
: `Grille du mois — ${libelleMois}`,
type: 'PREVENTIVE',
priority: 'LOW',
assetId: asset.id,
periodKey: period,
dueDate: dernierJour,
createdById: user.userId,
checklist: {
create: dues.map((t) => ({ label: t.label, templateId: t.id })),
},
events: {
create: {
kind: 'GENERATED',
message: `Généré (${premierControle ? 'premier contrôle' : 'grille mensuelle'} · ${dues.length} tâches)`,
byId: user.userId,
},
},
},
select: { reference: true },
});
created++;
if (premierControle) firstControls++;
references.push(ot.reference);
issue = 'créé';
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
const cible = (e.meta?.target as string[] | string | undefined) ?? '';
if (String(cible).includes('periodKey')) {
issue = 'sauté'; // grille déjà en place : idempotence
}
// sinon : collision de référence (course avec une création d'OT)
// → on recalcule et on retente
} else if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2003'
) {
issue = 'sauté'; // appareil supprimé entre lecture et écriture
} else {
throw e;
}
}
}
if (issue !== 'créé') skipped++;
}
this.logger.log(
`Préventif ${period} : ${created} générés, ${skipped} déjà en place, ${firstControls} premiers contrôles.`,
);
return { month: period, created, skipped, firstControls, references };
}
async status(): Promise<PreventiveStatus> {
const period = new Date().toISOString().slice(0, 7);
const [assetsUnderContract, grilles] = await Promise.all([
this.prisma.asset.count({ where: { underContract: true } }),
this.prisma.workOrder.findMany({
where: { periodKey: period },
select: { status: true, dueDate: true, title: true },
}),
]);
const maintenant = new Date();
return {
month: period,
assetsUnderContract,
generated: grilles.length,
done: grilles.filter((g) => g.status === 'DONE').length,
late: grilles.filter(
(g) => g.status !== 'DONE' && g.status !== 'CANCELLED' && g.dueDate! < maintenant,
).length,
firstControls: grilles.filter((g) => g.title.startsWith('Premier contrôle')).length,
};
}
private async nextReference(): Promise<string> {
const annee = new Date().getFullYear();
const dernier = await this.prisma.workOrder.findFirst({
where: { reference: { startsWith: `OT-${annee}-` } },
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `OT-${annee}-${String(n).padStart(4, '0')}`;
}
private toDto(row: TemplateRow): TaskTemplateDto {
return {
id: row.id,
label: row.label,
periodMonths: row.periodMonths,
isRegulatory: row.isRegulatory,
isActive: row.isActive,
componentTypeId: row.componentTypeId,
componentTypeName: row.componentType?.name ?? null,
};
}
}

View File

@@ -0,0 +1,46 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
ReferenceValueCreateSchema,
ReferenceValueUpdateSchema,
type ReferenceValueCreate,
type ReferenceValueUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { ReferenceValuesService } from './reference-values.service';
@Controller('reference-values')
export class ReferenceValuesController {
constructor(private readonly referenceValues: ReferenceValuesService) {}
/** Lu par le formulaire de bilan — authentification seule. */
@Get()
list() {
return this.referenceValues.list();
}
@Post()
@RequirePermission('SETTINGS', 'create')
create(
@Body(new ZodValidationPipe(ReferenceValueCreateSchema)) body: ReferenceValueCreate,
) {
return this.referenceValues.create(body);
}
@Patch(':id')
@RequirePermission('SETTINGS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(ReferenceValueUpdateSchema)) body: ReferenceValueUpdate,
) {
return this.referenceValues.update(id, body);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ReferenceValuesController } from './reference-values.controller';
import { ReferenceValuesService } from './reference-values.service';
@Module({
controllers: [ReferenceValuesController],
providers: [ReferenceValuesService],
exports: [ReferenceValuesService],
})
export class ReferenceValuesModule {}

View File

@@ -0,0 +1,93 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
ReferenceValueCreate,
ReferenceValueDto,
ReferenceValuesResponse,
ReferenceValueUpdate,
} from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
const usageInclude = {
_count: {
select: {
doorStates: true,
cabinPositions: true,
anomalies: true,
externalCauses: true,
actionsTaken: true,
componentsConcerned: true,
},
},
} satisfies Prisma.ReferenceValueInclude;
type Row = Prisma.ReferenceValueGetPayload<{ include: typeof usageInclude }>;
@Injectable()
export class ReferenceValuesService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<ReferenceValuesResponse> {
const rows = await this.prisma.referenceValue.findMany({
include: usageInclude,
orderBy: [{ field: 'asc' }, { label: 'asc' }],
});
return { referenceValues: rows.map((r) => this.toDto(r)) };
}
async create(dto: ReferenceValueCreate): Promise<ReferenceValueDto> {
try {
const created = await this.prisma.referenceValue.create({
data: dto,
include: usageInclude,
});
return this.toDto(created);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce libellé existe déjà pour ce champ');
}
throw e;
}
}
/** Renommage / (dés)activation — jamais de suppression (même règle que Category). */
async update(id: string, dto: ReferenceValueUpdate): Promise<ReferenceValueDto> {
try {
const updated = await this.prisma.referenceValue.update({
where: { id },
data: dto,
include: usageInclude,
});
return this.toDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Valeur inconnue');
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce libellé existe déjà pour ce champ');
}
throw e;
}
}
private toDto(row: Row): ReferenceValueDto {
const c = row._count;
return {
id: row.id,
field: row.field,
label: row.label,
isActive: row.isActive,
usageCount:
c.doorStates +
c.cabinPositions +
c.anomalies +
c.externalCauses +
c.actionsTaken +
c.componentsConcerned,
};
}
}

View File

@@ -0,0 +1,65 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Post,
} from '@nestjs/common';
import {
RequestApproveSchema,
RequestCreateSchema,
RequestRejectSchema,
type RequestApprove,
type RequestCreate,
type RequestReject,
} from '@siop/shared';
import {
AuthenticatedUser,
CurrentUser,
} from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { RequestsService } from './requests.service';
@Controller('requests')
export class RequestsController {
constructor(private readonly requests: RequestsService) {}
@Get()
@RequirePermission('REQUESTS', 'view')
list(@CurrentUser() user: AuthenticatedUser) {
return this.requests.list(user);
}
@Post()
@RequirePermission('REQUESTS', 'create')
create(
@Body(new ZodValidationPipe(RequestCreateSchema)) body: RequestCreate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.requests.create(body, user);
}
@Post(':id/approve')
@RequirePermission('REQUESTS', 'edit')
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(RequestApproveSchema)) body: RequestApprove,
@CurrentUser() user: AuthenticatedUser,
) {
return this.requests.approve(id, body, user);
}
@Post(':id/reject')
@HttpCode(200) // le contrat : 200, la demande est mise à jour
@RequirePermission('REQUESTS', 'edit')
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(RequestRejectSchema)) body: RequestReject,
@CurrentUser() user: AuthenticatedUser,
) {
return this.requests.reject(id, body, user);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { WorkOrdersModule } from '../work-orders/work-orders.module';
import { RequestsController } from './requests.controller';
import { RequestsService } from './requests.service';
@Module({
imports: [WorkOrdersModule],
controllers: [RequestsController],
providers: [RequestsService],
})
export class RequestsModule {}

View File

@@ -0,0 +1,170 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
RequestApprove,
RequestCreate,
RequestReject,
RequestsResponse,
RequestSummary,
WorkOrderDetail,
} from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { WorkOrdersService } from '../work-orders/work-orders.service';
const requestInclude = {
asset: { include: { location: { include: { parent: true } } } },
requestedBy: true,
workOrder: true,
} satisfies Prisma.RequestInclude;
type RequestRow = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
@Injectable()
export class RequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly workOrders: WorkOrdersService,
) {}
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
const viewOther = await this.permissions.can(user.roleId, 'REQUESTS', 'viewOther');
return viewOther ? {} : { requestedById: user.userId };
}
async list(user: AuthenticatedUser): Promise<RequestsResponse> {
const rows = await this.prisma.request.findMany({
where: await this.scope(user),
include: requestInclude,
orderBy: { createdAt: 'desc' },
});
rows.sort((a, b) => Number(b.isPersonTrapped) - Number(a.isPersonTrapped));
return { requests: rows.map((r) => this.toDto(r)) };
}
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
if (!asset) throw new BadRequestException('Équipement inconnu');
for (let essai = 0; ; essai++) {
try {
const created = await this.prisma.request.create({
data: {
reference: await this.nextReference(),
description: dto.description,
isPersonTrapped: dto.isPersonTrapped ?? false,
assetId: dto.assetId,
requestedById: user.userId,
},
include: requestInclude,
});
return this.toDto(created);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002' &&
essai < 3
) {
continue;
}
throw e;
}
}
}
/** Approuver = créer l'OT lié (1-1). Une demande ne se traite qu'une fois. */
async approve(
id: string,
dto: RequestApprove,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const request = await this.prisma.request.findUnique({
where: { id },
include: requestInclude,
});
if (!request) throw new NotFoundException('Demande inconnue');
if (request.status !== 'RECEIVED') {
throw new ConflictException('Cette demande a déjà été traitée');
}
const created = await this.workOrders.createRaw({
title: dto.title ?? request.description.slice(0, 120),
description: `${request.description}\n\n(Demande ${request.reference}${this.requesterLabel(request)})`,
type: 'CORRECTIVE',
priority: dto.priority ?? (request.isPersonTrapped ? 'PERSON_TRAPPED' : 'MEDIUM'),
assetId: request.assetId,
dueDate: dto.dueDate,
assigneeIds: dto.assigneeIds,
createdById: user.userId,
eventKind: 'FROM_REQUEST',
eventMessage: `OT créé depuis la demande ${request.reference}`,
});
await this.prisma.request.update({
where: { id },
data: { status: 'APPROVED', workOrderId: created.id },
});
return this.workOrders.get(created.id, user);
}
async reject(
id: string,
dto: RequestReject,
user: AuthenticatedUser,
): Promise<RequestSummary> {
void user;
const request = await this.prisma.request.findUnique({ where: { id } });
if (!request) throw new NotFoundException('Demande inconnue');
if (request.status !== 'RECEIVED') {
throw new ConflictException('Cette demande a déjà été traitée');
}
const updated = await this.prisma.request.update({
where: { id },
data: { status: 'REJECTED', rejectionReason: dto.reason },
include: requestInclude,
});
return this.toDto(updated);
}
private async nextReference(): Promise<string> {
const annee = new Date().getFullYear();
const dernier = await this.prisma.request.findFirst({
where: { reference: { startsWith: `DEM-${annee}-` } },
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
}
private requesterLabel(row: RequestRow): string {
return row.requestedBy?.displayName ?? row.requesterName ?? 'Portail';
}
private toDto(row: RequestRow): RequestSummary {
return {
id: row.id,
reference: row.reference,
description: row.description,
isPersonTrapped: row.isPersonTrapped,
status: row.status,
rejectionReason: row.rejectionReason,
assetId: row.assetId,
assetReference: row.asset.reference,
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
requesterLabel: this.requesterLabel(row),
workOrder: row.workOrder
? {
id: row.workOrder.id,
reference: row.workOrder.reference,
status: row.workOrder.status,
}
: null,
createdAt: row.createdAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,44 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
TeamCreateSchema,
TeamUpdateSchema,
type TeamCreate,
type TeamUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { TeamsService } from './teams.service';
@Controller('teams')
export class TeamsController {
constructor(private readonly teamsService: TeamsService) {}
@Get()
@RequirePermission('PEOPLE_TEAMS', 'view')
list() {
return this.teamsService.list();
}
@Post()
@RequirePermission('PEOPLE_TEAMS', 'create')
create(@Body(new ZodValidationPipe(TeamCreateSchema)) body: TeamCreate) {
return this.teamsService.create(body);
}
@Patch(':id')
@RequirePermission('PEOPLE_TEAMS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(TeamUpdateSchema)) body: TeamUpdate,
) {
return this.teamsService.update(id, body);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TeamsController } from './teams.controller';
import { TeamsService } from './teams.service';
@Module({
controllers: [TeamsController],
providers: [TeamsService],
exports: [TeamsService],
})
export class TeamsModule {}

View File

@@ -0,0 +1,92 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Team, TeamCreate, TeamsResponse, TeamUpdate } from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
const teamInclude = {
members: { include: { role: true }, orderBy: { displayName: 'asc' } },
} satisfies Prisma.TeamInclude;
type TeamRow = Prisma.TeamGetPayload<{ include: typeof teamInclude }>;
@Injectable()
export class TeamsService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<TeamsResponse> {
const rows = await this.prisma.team.findMany({
include: teamInclude,
orderBy: { name: 'asc' },
});
return { teams: rows.map((t) => this.toDto(t)) };
}
async create(dto: TeamCreate): Promise<Team> {
try {
const created = await this.prisma.team.create({
data: {
name: dto.name,
description: dto.description,
members: dto.memberIds?.length
? { connect: dto.memberIds.map((id) => ({ id })) }
: undefined,
},
include: teamInclude,
});
return this.toDto(created);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom déquipe existe déjà');
}
throw e;
}
}
async update(id: string, dto: TeamUpdate): Promise<Team> {
try {
const updated = await this.prisma.team.update({
where: { id },
data: {
name: dto.name,
description: dto.description,
members: dto.memberIds
? { set: dto.memberIds.map((memberId) => ({ id: memberId })) }
: undefined,
},
include: teamInclude,
});
return this.toDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Équipe inconnue');
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom déquipe existe déjà');
}
throw e;
}
}
private toDto(row: TeamRow): Team {
return {
id: row.id,
name: row.name,
description: row.description,
members: row.members.map((m) => ({
id: m.id,
displayName: m.displayName,
roleName: m.role.name,
initials: m.displayName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]!.toUpperCase())
.join(''),
})),
};
}
}

View File

@@ -1,21 +1,41 @@
import { Controller, Get, NotFoundException } from '@nestjs/common'; import {
import type { MeResponse, RoleName } from '@siop/shared'; Body,
Controller,
Get,
NotFoundException,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
InvitationCreateSchema,
UserUpdateSchema,
type InvitationCreate,
type MeResponse,
type RoleName,
type UserUpdate,
} from '@siop/shared';
import { import {
AuthenticatedUser, AuthenticatedUser,
CurrentUser, CurrentUser,
} from '../auth/current-user.decorator'; } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { PermissionsService } from '../permissions/permissions.service'; import { PermissionsService } from '../permissions/permissions.service';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from './users.service';
@Controller('users') @Controller()
export class UsersController { export class UsersController {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissionsService: PermissionsService, private readonly permissionsService: PermissionsService,
private readonly usersService: UsersService,
) {} ) {}
/** R0 : lecture du profil courant (gestion complète des utilisateurs en R1). */ /** Profil courant — pas de permission : chacun lit le sien. */
@Get('me') @Get('users/me')
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> { async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id: current.userId }, where: { id: current.userId },
@@ -31,4 +51,37 @@ export class UsersController {
permissions: await this.permissionsService.getForRole(user.roleId), permissions: await this.permissionsService.getForRole(user.roleId),
}; };
} }
@Get('users')
@RequirePermission('PEOPLE_TEAMS', 'view')
list() {
return this.usersService.list();
}
@Get('roles')
@RequirePermission('PEOPLE_TEAMS', 'view')
roles() {
return this.usersService.roles();
}
@Post('users/invitations')
@RequirePermission('PEOPLE_TEAMS', 'create')
invite(@Body(new ZodValidationPipe(InvitationCreateSchema)) body: InvitationCreate) {
return this.usersService.invite(body);
}
@Post('users/:id/invitation')
@RequirePermission('PEOPLE_TEAMS', 'edit')
resendInvitation(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.resendInvitation(id);
}
@Patch('users/:id')
@RequirePermission('PEOPLE_TEAMS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(UserUpdateSchema)) body: UserUpdate,
) {
return this.usersService.update(id, body);
}
} }

View File

@@ -1,7 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({ @Module({
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
}) })
export class UsersModule {} export class UsersModule {}

View File

@@ -0,0 +1,141 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
INVITATION_TTL_DAYS,
type InvitationCreate,
type InvitationResponse,
type RoleName,
type RolesResponse,
type UserAdmin,
type UsersResponse,
type UserUpdate,
} from '@siop/shared';
import { randomBytes } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
const userInclude = {
role: true,
teams: { orderBy: { name: 'asc' } },
} satisfies Prisma.UserInclude;
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<UsersResponse> {
const rows = await this.prisma.user.findMany({
include: userInclude,
orderBy: { displayName: 'asc' },
});
return { users: rows.map((u) => this.toDto(u)) };
}
async roles(): Promise<RolesResponse> {
const roles = await this.prisma.role.findMany({ orderBy: { name: 'asc' } });
return { roles: roles.map((r) => ({ id: r.id, name: r.name as RoleName })) };
}
/** Invitation : compte créé SANS mot de passe + lien d'activation 7 jours.
* L'envoi d'email viendra plus tard — le web affiche le lien à copier. */
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
if (!role) throw new NotFoundException('Rôle inconnu');
try {
const user = await this.prisma.user.create({
data: {
email: dto.email,
displayName: dto.displayName,
phone: dto.phone,
roleId: dto.roleId,
teams: dto.teamIds?.length
? { connect: dto.teamIds.map((id) => ({ id })) }
: undefined,
...this.freshToken(),
},
});
return {
userId: user.id,
activationToken: user.activationToken!,
expiresAt: user.activationExpiresAt!.toISOString(),
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Cet email a déjà un compte');
}
throw e;
}
}
async resendInvitation(userId: string): Promise<InvitationResponse> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException('Personne inconnue');
if (user.passwordHash) {
throw new ConflictException('Ce compte est déjà activé');
}
const updated = await this.prisma.user.update({
where: { id: userId },
data: this.freshToken(),
});
return {
userId: updated.id,
activationToken: updated.activationToken!,
expiresAt: updated.activationExpiresAt!.toISOString(),
};
}
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
try {
const updated = await this.prisma.user.update({
where: { id: userId },
data: {
displayName: dto.displayName,
phone: dto.phone,
roleId: dto.roleId,
isActive: dto.isActive,
teams: dto.teamIds
? { set: dto.teamIds.map((id) => ({ id })) }
: undefined,
},
include: userInclude,
});
return this.toDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Personne inconnue');
}
throw e;
}
}
private freshToken() {
return {
activationToken: randomBytes(32).toString('base64url'),
activationExpiresAt: new Date(
Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000,
),
};
}
private toDto(row: UserRow): UserAdmin {
return {
id: row.id,
email: row.email,
displayName: row.displayName,
phone: row.phone,
role: { id: row.role.id, name: row.role.name as RoleName },
teams: row.teams.map((t) => ({ id: t.id, name: t.name })),
status: !row.isActive
? 'disabled'
: row.passwordHash
? 'active'
: 'invited',
isDemo: row.isDemo,
};
}
}

View File

@@ -0,0 +1,110 @@
import {
Body,
Controller,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
} from '@nestjs/common';
import {
AssigneesUpdateSchema,
ChecklistPatchSchema,
CommentCreateSchema,
ReportUpsertSchema,
TransitionRequestSchema,
WorkOrderCreateSchema,
type AssigneesUpdate,
type ChecklistPatch,
type CommentCreate,
type ReportUpsert,
type TransitionRequest,
type WorkOrderCreate,
} from '@siop/shared';
import {
AuthenticatedUser,
CurrentUser,
} from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { WorkOrdersService } from './work-orders.service';
@Controller('work-orders')
export class WorkOrdersController {
constructor(private readonly workOrders: WorkOrdersService) {}
@Get()
@RequirePermission('WORK_ORDERS', 'view')
list(@CurrentUser() user: AuthenticatedUser) {
return this.workOrders.list(user);
}
@Get(':id')
@RequirePermission('WORK_ORDERS', 'view')
get(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
return this.workOrders.get(id, user);
}
@Post()
@RequirePermission('WORK_ORDERS', 'create')
create(
@Body(new ZodValidationPipe(WorkOrderCreateSchema)) body: WorkOrderCreate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.create(body, user);
}
@Post(':id/transition')
@HttpCode(200) // le contrat : 200, l'état change — rien n'est « créé »
@RequirePermission('WORK_ORDERS', 'edit')
transition(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(TransitionRequestSchema)) body: TransitionRequest,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.transition(id, body, user);
}
@Post(':id/comments')
@RequirePermission('WORK_ORDERS', 'edit')
comment(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(CommentCreateSchema)) body: CommentCreate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.comment(id, body.message, user);
}
@Put(':id/assignees')
@RequirePermission('WORK_ORDERS', 'edit')
setAssignees(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(AssigneesUpdateSchema)) body: AssigneesUpdate,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.setAssignees(id, body, user);
}
@Put(':id/report')
@RequirePermission('WORK_ORDERS', 'edit')
upsertReport(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(ReportUpsertSchema)) body: ReportUpsert,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.upsertReport(id, body, user);
}
@Patch(':id/checklist/:itemId')
@RequirePermission('WORK_ORDERS', 'edit')
patchChecklist(
@Param('id', ParseUUIDPipe) id: string,
@Param('itemId', ParseUUIDPipe) itemId: string,
@Body(new ZodValidationPipe(ChecklistPatchSchema)) body: ChecklistPatch,
@CurrentUser() user: AuthenticatedUser,
) {
return this.workOrders.patchChecklist(id, itemId, body, user);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WorkOrdersController } from './work-orders.controller';
import { WorkOrdersService } from './work-orders.service';
@Module({
controllers: [WorkOrdersController],
providers: [WorkOrdersService],
exports: [WorkOrdersService],
})
export class WorkOrdersModule {}

View File

@@ -0,0 +1,437 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
BILAN_FIELD_LABELS,
REQUIRED_BILAN_FIELDS,
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TRANSITIONS,
type AssigneesUpdate,
type BilanField,
type ChecklistItemDto,
type ChecklistPatch,
type ReportUpsert,
type TransitionRequest,
type WorkOrderCreate,
type WorkOrderDetail,
type WorkOrderStatus,
type WorkOrdersResponse,
type WorkOrderSummary,
} from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
const detailInclude = {
asset: { include: { location: { include: { parent: true } } } },
assignees: true,
createdBy: true,
request: { include: { requestedBy: true } },
events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } },
checklist: { include: { doneBy: true }, orderBy: { label: 'asc' as const } },
report: {
include: {
doorState: true,
cabinPosition: true,
anomaly: true,
externalCause: true,
actionTaken: true,
componentConcerned: true,
},
},
} satisfies Prisma.WorkOrderInclude;
type DetailRow = Prisma.WorkOrderGetPayload<{ include: typeof detailInclude }>;
const initialsOf = (name: string) =>
name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]!.toUpperCase()).join('');
const toPersonne = (u: { id: string; displayName: string }) => ({
id: u.id,
displayName: u.displayName,
initials: initialsOf(u.displayName),
});
/** Champ du bilan → colonne du rapport (validation des référentiels). */
const REPORT_FIELDS: Record<string, BilanField> = {
doorStateId: 'DOOR_STATE',
cabinPositionId: 'CABIN_POSITION',
anomalyId: 'ANOMALY',
externalCauseId: 'EXTERNAL_CAUSE',
actionTakenId: 'ACTION_TAKEN',
componentConcernedId: 'COMPONENT_CONCERNED',
};
@Injectable()
export class WorkOrdersService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
/** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */
private async scope(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
if (viewOther) return {};
return {
OR: [
{ assignees: { some: { id: user.userId } } },
{ createdById: user.userId },
],
};
}
async list(user: AuthenticatedUser): Promise<WorkOrdersResponse> {
const rows = await this.prisma.workOrder.findMany({
where: await this.scope(user),
include: {
asset: { include: { location: { include: { parent: true } } } },
assignees: true,
},
// Dernière activité d'abord : un OT qui vient d'être clôturé remonte
// (« Interventions récentes » du tableau de bord = tête de liste).
orderBy: { updatedAt: 'desc' },
});
// « personne bloquée » ACTIVE saute en tête, toujours (tri stable)
const urgenceActive = (r: (typeof rows)[number]) =>
r.priority === 'PERSON_TRAPPED' && r.status !== 'DONE' && r.status !== 'CANCELLED';
rows.sort((a, b) => Number(urgenceActive(b)) - Number(urgenceActive(a)));
return { workOrders: rows.map((r) => this.toSummary(r)) };
}
async get(id: string, user: AuthenticatedUser): Promise<WorkOrderDetail> {
const row = await this.prisma.workOrder.findFirst({
where: { AND: [{ id }, await this.scope(user)] },
include: detailInclude,
});
if (!row) throw new NotFoundException('OT inconnu');
return this.toDetail(row);
}
async create(dto: WorkOrderCreate, user: AuthenticatedUser): Promise<WorkOrderDetail> {
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
if (!asset) throw new BadRequestException('Équipement inconnu');
const created = await this.createRaw({
...dto,
createdById: user.userId,
eventKind: 'CREATED',
eventMessage: 'OT créé',
});
return this.get(created.id, user);
}
/** Création partagée (OT manuel, approbation de demande, préventif R2.2). */
async createRaw(input: {
title: string;
description?: string;
type: WorkOrderCreate['type'];
priority?: WorkOrderCreate['priority'];
assetId: string;
dueDate?: string;
assigneeIds?: string[];
createdById?: string;
eventKind: string;
eventMessage: string;
}): Promise<{ id: string }> {
for (let essai = 0; ; essai++) {
try {
return await this.prisma.workOrder.create({
data: {
reference: await this.nextReference(),
title: input.title,
description: input.description,
type: input.type,
priority: input.priority ?? 'NONE',
assetId: input.assetId,
dueDate: input.dueDate ? new Date(input.dueDate) : undefined,
createdById: input.createdById,
assignees: input.assigneeIds?.length
? { connect: input.assigneeIds.map((id) => ({ id })) }
: undefined,
events: {
create: {
kind: input.eventKind,
message: input.eventMessage,
byId: input.createdById,
},
},
},
select: { id: true },
});
} catch (e) {
// collision de référence (concurrence) : on retente
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002' &&
essai < 3
) {
continue;
}
throw e;
}
}
}
private async nextReference(): Promise<string> {
const annee = new Date().getFullYear();
const dernier = await this.prisma.workOrder.findFirst({
where: { reference: { startsWith: `OT-${annee}-` } },
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `OT-${annee}-${String(n).padStart(4, '0')}`;
}
/** Machine à états stricte + garde de clôture. */
async transition(
id: string,
dto: TransitionRequest,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
const detail = await this.get(id, user);
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
throw new ConflictException(
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]}${WORK_ORDER_STATUS_LABELS[dto.to]}`,
);
}
if (dto.to === 'DONE' && detail.closureBlockers.length > 0) {
throw new ConflictException(
`Clôture bloquée : ${detail.closureBlockers.join(' ; ')}`,
);
}
const horodatage: Prisma.WorkOrderUpdateInput =
dto.to === 'IN_PROGRESS' && !detail.startedAt
? { startedAt: new Date() }
: dto.to === 'DONE'
? { completedAt: new Date() }
: dto.to === 'CANCELLED'
? { cancelledAt: new Date() }
: {};
await this.prisma.workOrder.update({
where: { id },
data: {
status: dto.to,
...horodatage,
events: {
create: {
kind: 'STATUS_CHANGED',
message:
`${WORK_ORDER_STATUS_LABELS[detail.status]}${WORK_ORDER_STATUS_LABELS[dto.to]}` +
(dto.comment ? `${dto.comment}` : ''),
byId: user.userId,
},
},
},
});
return this.get(id, user);
}
async comment(
id: string,
message: string,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
await this.get(id, user); // périmètre
await this.prisma.workOrderEvent.create({
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
});
return this.get(id, user);
}
async setAssignees(
id: string,
dto: AssigneesUpdate,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
await this.get(id, user);
const users = await this.prisma.user.findMany({
where: { id: { in: dto.assigneeIds } },
});
if (users.length !== new Set(dto.assigneeIds).size) {
throw new BadRequestException('Personne inconnue dans la liste');
}
await this.prisma.workOrder.update({
where: { id },
data: {
assignees: { set: dto.assigneeIds.map((assigneeId) => ({ id: assigneeId })) },
events: {
create: {
kind: 'ASSIGNED',
message: users.length
? `Assigné(s) : ${users.map((u) => u.displayName).join(', ')}`
: 'Assignation retirée',
byId: user.userId,
},
},
},
});
return this.get(id, user);
}
async upsertReport(
id: string,
dto: ReportUpsert,
user: AuthenticatedUser,
): Promise<WorkOrderDetail> {
await this.get(id, user);
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
const valeur = dto[colonne as keyof ReportUpsert];
if (typeof valeur === 'string') {
const ref = await this.prisma.referenceValue.findUnique({ where: { id: valeur } });
if (!ref || ref.field !== field || !ref.isActive) {
throw new BadRequestException(
`Valeur hors référentiel pour « ${BILAN_FIELD_LABELS[field]} »`,
);
}
}
}
const donnees = {
note: dto.note,
doorStateId: dto.doorStateId,
cabinPositionId: dto.cabinPositionId,
anomalyId: dto.anomalyId,
externalCauseId: dto.externalCauseId,
actionTakenId: dto.actionTakenId,
componentConcernedId: dto.componentConcernedId,
};
await this.prisma.interventionReport.upsert({
where: { workOrderId: id },
update: donnees,
create: { workOrderId: id, ...donnees },
});
return this.get(id, user);
}
async patchChecklist(
id: string,
itemId: string,
dto: ChecklistPatch,
user: AuthenticatedUser,
): Promise<ChecklistItemDto> {
await this.get(id, user);
const { count } = await this.prisma.checklistItem.updateMany({
where: { id: itemId, workOrderId: id },
data: {
state: dto.state,
doneById: dto.state === 'PENDING' ? null : user.userId,
doneAt: dto.state === 'PENDING' ? null : new Date(),
},
});
if (count === 0) throw new NotFoundException('Tâche inconnue');
const item = await this.prisma.checklistItem.findUniqueOrThrow({
where: { id: itemId },
include: { doneBy: true },
});
return {
id: item.id,
label: item.label,
state: item.state,
doneBy: item.doneBy ? toPersonne(item.doneBy) : null,
doneAt: item.doneAt?.toISOString() ?? null,
};
}
// ————— mapping —————
private toSummary(
row: Prisma.WorkOrderGetPayload<{
include: {
asset: { include: { location: { include: { parent: true } } } };
assignees: true;
};
}>,
): WorkOrderSummary {
return {
id: row.id,
reference: row.reference,
title: row.title,
type: row.type,
status: row.status,
priority: row.priority,
assetId: row.assetId,
assetReference: row.asset.reference,
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
dueDate: row.dueDate?.toISOString() ?? null,
assignees: row.assignees.map(toPersonne),
createdAt: row.createdAt.toISOString(),
};
}
private toDetail(row: DetailRow): WorkOrderDetail {
const report = row.report;
const blockers: string[] = [];
if (row.checklist.some((c) => c.state === 'PENDING')) {
blockers.push('la checklist est incomplète : traitez chaque tâche (Fait ou N-A)');
}
const manquants = REQUIRED_BILAN_FIELDS.filter((field) => {
const parChamp: Record<BilanField, string | null | undefined> = {
DOOR_STATE: report?.doorStateId,
CABIN_POSITION: report?.cabinPositionId,
ANOMALY: report?.anomalyId,
EXTERNAL_CAUSE: report?.externalCauseId,
ACTION_TAKEN: report?.actionTakenId,
COMPONENT_CONCERNED: report?.componentConcernedId,
};
return !parChamp[field];
});
if (manquants.length) {
blockers.push(
`bilan incomplet : ${manquants.map((f) => `« ${BILAN_FIELD_LABELS[f]} »`).join(', ')}`,
);
}
const versValeur = (v: { id: string; label: string } | null) =>
v ? { id: v.id, label: v.label } : null;
return {
...this.toSummary(row),
description: row.description,
locationName: row.asset.location.name,
startedAt: row.startedAt?.toISOString() ?? null,
completedAt: row.completedAt?.toISOString() ?? null,
cancelledAt: row.cancelledAt?.toISOString() ?? null,
createdBy: row.createdBy ? toPersonne(row.createdBy) : null,
request: row.request
? {
id: row.request.id,
reference: row.request.reference,
requesterLabel:
row.request.requestedBy?.displayName ??
row.request.requesterName ??
'Portail',
}
: null,
events: row.events.map((e) => ({
id: e.id,
kind: e.kind,
message: e.message,
by: e.by ? toPersonne(e.by) : null,
createdAt: e.createdAt.toISOString(),
})),
checklist: row.checklist.map((c) => ({
id: c.id,
label: c.label,
state: c.state,
doneBy: c.doneBy ? toPersonne(c.doneBy) : null,
doneAt: c.doneAt?.toISOString() ?? null,
})),
report: report
? {
note: report.note,
doorState: versValeur(report.doorState),
cabinPosition: versValeur(report.cabinPosition),
anomaly: versValeur(report.anomaly),
externalCause: versValeur(report.externalCause),
actionTaken: versValeur(report.actionTaken),
componentConcerned: versValeur(report.componentConcerned),
}
: null,
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
closureBlockers: blockers,
};
}
}

View File

@@ -0,0 +1,221 @@
/**
* E2E R1 — administration du référentiel : équipes, renommages, mises à jour
* et TOUS les refus (conflits 409, inconnus 404, invalides 400) — le contrat
* d'erreur fait partie du contrat.
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
const GHOST = '00000000-0000-4000-8000-000000000000';
describe('Administration du référentiel (e2e)', () => {
let app: INestApplication;
let admin: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const suffix = `adm-${Date.now().toString(36)}`;
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const compte = body.accounts.find(
(a: { roleName: string }) => a.roleName === 'Administrateur',
);
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken;
});
afterAll(async () => {
await prisma.team.deleteMany({ where: { name: { contains: suffix } } });
await prisma.category.deleteMany({ where: { name: { contains: suffix } } });
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
const auth = () => ({ Authorization: `Bearer ${admin}` });
it('équipes : création, doublon 409, membres, 404', async () => {
const { body: users } = await http().get('/users').set(auth());
const ahmed = users.users.find(
(u: { email: string }) => u.email === 'technicien@demo.siop.ma',
);
const team = await http()
.post('/teams')
.set(auth())
.send({ name: `Équipe ${suffix}`, description: 'Zone de test' })
.expect(201);
expect(team.body.members).toHaveLength(0);
await http()
.post('/teams')
.set(auth())
.send({ name: `Équipe ${suffix}` })
.expect(409);
const updated = await http()
.patch(`/teams/${team.body.id}`)
.set(auth())
.send({ memberIds: [ahmed.id], description: 'Zone mise à jour' })
.expect(200);
expect(updated.body.members.map((m: { id: string }) => m.id)).toEqual([ahmed.id]);
const { body: teams } = await http().get('/teams').set(auth()).expect(200);
expect(teams.teams.some((t: { id: string }) => t.id === team.body.id)).toBe(true);
await http().patch(`/teams/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
await http()
.patch(`/teams/${team.body.id}`)
.set(auth())
.send({ name: 'Casablanca Centre' })
.expect(409);
});
it('catégories : création, doublon 409, renommage, 404', async () => {
const created = await http()
.post('/categories')
.set(auth())
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
.expect(201);
await http()
.post('/categories')
.set(auth())
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
.expect(409);
const renamed = await http()
.patch(`/categories/${created.body.id}`)
.set(auth())
.send({ name: `Catégorie ${suffix} v2` })
.expect(200);
expect(renamed.body.name).toBe(`Catégorie ${suffix} v2`);
await http()
.patch(`/categories/${GHOST}`)
.set(auth())
.send({ name: 'X' })
.expect(404);
});
it('emplacements : renommage, parent inconnu, auto-parent, site avec zones, 404', async () => {
const site = await http()
.post('/locations')
.set(auth())
.send({ name: `Site ${suffix}` })
.expect(201);
const zone = await http()
.post('/locations')
.set(auth())
.send({ name: `Zone ${suffix}`, parentId: site.body.id })
.expect(201);
await http()
.patch(`/locations/${zone.body.id}`)
.set(auth())
.send({ guardianName: 'Gardien Test', guardianPhone: '06 00 00 00 00' })
.expect(200);
await http()
.post('/locations')
.set(auth())
.send({ name: `Orphelin ${suffix}`, parentId: GHOST })
.expect(400);
await http()
.patch(`/locations/${zone.body.id}`)
.set(auth())
.send({ parentId: zone.body.id })
.expect(400);
// Le site a une zone : il ne peut pas devenir zone lui-même
await http()
.patch(`/locations/${site.body.id}`)
.set(auth())
.send({ parentId: zone.body.id })
.expect(400);
await http().patch(`/locations/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
});
it('appareils : statut, référence en conflit, emplacement inconnu, 404', async () => {
const { body } = await http().get('/assets').set(auth());
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
const b1 = body.assets.find((a: { reference: string }) => a.reference === 'B1');
const updated = await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ status: 'UNDER_MAINTENANCE' })
.expect(200);
expect(updated.body.status).toBe('UNDER_MAINTENANCE');
await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ status: 'IN_SERVICE' })
.expect(200);
await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ reference: a1.reference })
.expect(409);
await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ locationId: GHOST })
.expect(400);
await http().get(`/assets/${GHOST}`).set(auth()).expect(404);
await http()
.patch(`/assets/${GHOST}`)
.set(auth())
.send({ brand: 'X' })
.expect(404);
await http()
.post(`/assets/${GHOST}/components`)
.set(auth())
.send({ typeId: GHOST })
.expect(404);
await http()
.delete(`/assets/${b1.id}/components/${GHOST}`)
.set(auth())
.expect(404);
});
it('personnes : mise à jour rôle/équipes, invitation sur rôle inconnu, 404', async () => {
const { body: users } = await http().get('/users').set(auth());
const youssef = users.users.find(
(u: { email: string }) => u.email === 'technicien-limite@demo.siop.ma',
);
const { body: teams } = await http().get('/teams').set(auth());
const mohammedia = teams.teams.find((t: { name: string }) => t.name === 'Mohammedia');
const updated = await http()
.patch(`/users/${youssef.id}`)
.set(auth())
.send({ phone: '06 12 34 56 78', teamIds: [mohammedia.id] })
.expect(200);
expect(updated.body.teams.map((t: { name: string }) => t.name)).toContain('Mohammedia');
// remise en l'état (seed idempotent : les équipes ne sont pas réécrites)
const casa = teams.teams.find((t: { name: string }) => t.name === 'Casablanca Centre');
await http()
.patch(`/users/${youssef.id}`)
.set(auth())
.send({ teamIds: [casa.id] })
.expect(200);
await http().patch(`/users/${GHOST}`).set(auth()).send({ phone: '0' }).expect(404);
await http()
.post('/users/invitations')
.set(auth())
.send({ email: `x-${suffix}@spelev.ma`, displayName: 'X', roleId: GHOST })
.expect(404);
await http().post(`/users/${GHOST}/invitation`).set(auth()).expect(404);
});
});

View File

@@ -0,0 +1,366 @@
/**
* E2E R2 — exploitation : machine à états stricte, garde de clôture
* (bilan + checklist), demande → OT (1-1), scoping « voir autre ».
* Rejoue la recette officielle : demande gardien → approbation → OT →
* intervention → bilan codé → clôture → suivi demandeur.
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Exploitation (e2e)', () => {
let app: INestApplication;
let salma: string; // Dispatcher — voir autre, approuve
let ahmed: string; // Technicien — SES OT seulement
let karim: string; // Demandeur — SES demandes seulement
let ahmedId: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const suffix = Date.now().toString(36);
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
const res = await http().post('/auth/demo-login').send({ userId: compte.id });
return res.body.accessToken as string;
};
salma = await login('Dispatcher');
ahmed = await login('Technicien');
karim = await login('Demandeur');
ahmedId = (
await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } })
).id;
});
afterAll(async () => {
await prisma.request.deleteMany({ where: { description: { contains: suffix } } });
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
// 1. Karim (gardien) signale
const { body: assets } = await http().get('/assets').set(auth(salma));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const demande = await http()
.post('/requests')
.set(auth(karim))
.send({ assetId: a1.id, description: `Porte qui grince (${suffix})` })
.expect(201);
expect(demande.body.status).toBe('RECEIVED');
// 2. Salma approuve → OT créé et lié (1-1)
const ot = await http()
.post(`/requests/${demande.body.id}/approve`)
.set(auth(salma))
.send({ title: `Réparer porte qui grince (${suffix})`, priority: 'MEDIUM', assigneeIds: [ahmedId] })
.expect(201);
expect(ot.body.status).toBe('OPEN');
expect(ot.body.request.reference).toBe(demande.body.reference);
// double approbation → refus
await http()
.post(`/requests/${demande.body.id}/approve`)
.set(auth(salma))
.send({})
.expect(409);
// 3. Ahmed démarre, commente
const id = ot.body.id;
await http()
.post(`/work-orders/${id}/transition`)
.set(auth(ahmed))
.send({ to: 'IN_PROGRESS' })
.expect(200);
await http()
.post(`/work-orders/${id}/comments`)
.set(auth(ahmed))
.send({ message: 'Charnière usée, graissage effectué.' })
.expect(201);
// 4. Clôture SANS bilan → bloquée (garde)
const refus = await http()
.post(`/work-orders/${id}/transition`)
.set(auth(ahmed))
.send({ to: 'DONE' })
.expect(409);
expect(refus.body.message).toContain('bilan incomplet');
// 5. Bilan codé (3 champs requis) puis clôture
const { body: refs } = await http().get('/reference-values').set(auth(ahmed));
const valeur = (field: string, label: string) =>
refs.referenceValues.find(
(v: { field: string; label: string }) => v.field === field && v.label === label,
).id;
await http()
.put(`/work-orders/${id}/report`)
.set(auth(ahmed))
.send({
doorStateId: valeur('DOOR_STATE', 'Fonctionnement normal'),
actionTakenId: valeur('ACTION_TAKEN', 'Nettoyage / graissage'),
componentConcernedId: valeur('COMPONENT_CONCERNED', 'Portes'),
})
.expect(200);
const clos = await http()
.post(`/work-orders/${id}/transition`)
.set(auth(ahmed))
.send({ to: 'DONE', comment: 'RAS après graissage' })
.expect(200);
expect(clos.body.status).toBe('DONE');
expect(clos.body.completedAt).toBeTruthy();
// 6. Karim suit SA demande : l'OT lié est terminé
const { body: mesDemandes } = await http().get('/requests').set(auth(karim)).expect(200);
const laMienne = mesDemandes.requests.find(
(r: { id: string }) => r.id === demande.body.id,
);
expect(laMienne.workOrder.status).toBe('DONE');
// 6 bis. Régression recette R2 : l'OT fraîchement clôturé remonte EN TÊTE
// de la liste de Salma (après les urgences actives) — il doit apparaître
// dans les « Interventions récentes » de tout le monde, pas seulement
// chez l'assigné.
const { body: listeSalma } = await http()
.get('/work-orders')
.set(auth(salma))
.expect(200);
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
const urgencesActives = listeSalma.workOrders.filter(
(w: { priority: string; status: string }) =>
w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
).length;
expect(position).toBeGreaterThanOrEqual(0);
expect(position).toBeLessThanOrEqual(urgencesActives);
// 7. Terminal : plus aucune transition
await http()
.post(`/work-orders/${id}/transition`)
.set(auth(ahmed))
.send({ to: 'IN_PROGRESS' })
.expect(409);
});
it('machine à états : OPEN → DONE direct interdit ; OPEN → ON_HOLD interdit', async () => {
const { body } = await http().get('/work-orders').set(auth(salma));
const ouvert = body.workOrders.find(
(w: { status: string; priority: string }) =>
w.status === 'OPEN' && w.priority === 'PERSON_TRAPPED',
);
await http()
.post(`/work-orders/${ouvert.id}/transition`)
.set(auth(salma))
.send({ to: 'DONE' })
.expect(409);
await http()
.post(`/work-orders/${ouvert.id}/transition`)
.set(auth(salma))
.send({ to: 'ON_HOLD' })
.expect(409);
});
it('garde de clôture : la checklist incomplète bloque la grille du mois', async () => {
const { body } = await http().get('/work-orders').set(auth(ahmed));
const grille = body.workOrders.find((w: { type: string }) => w.type === 'PREVENTIVE');
const detail = await http().get(`/work-orders/${grille.id}`).set(auth(ahmed)).expect(200);
expect(detail.body.closureBlockers.join(' ')).toContain('checklist');
// Régler toutes les tâches (Fait/N-A) → le blocage checklist disparaît
for (const [i, item] of detail.body.checklist.entries()) {
await http()
.patch(`/work-orders/${grille.id}/checklist/${item.id}`)
.set(auth(ahmed))
.send({ state: i % 2 ? 'NA' : 'DONE' })
.expect(200);
}
const apres = await http().get(`/work-orders/${grille.id}`).set(auth(ahmed)).expect(200);
expect(apres.body.closureBlockers.join(' ')).not.toContain('checklist');
expect(apres.body.checklist.every((c: { doneBy: unknown }) => c.doneBy)).toBe(true);
// remise en état (seed idempotent)
for (const item of apres.body.checklist) {
await http()
.patch(`/work-orders/${grille.id}/checklist/${item.id}`)
.set(auth(ahmed))
.send({ state: 'PENDING' })
.expect(200);
}
});
it('« voir autre » : Ahmed ne voit que SES OT ; Karim que SES demandes', async () => {
const { body: tous } = await http().get('/work-orders').set(auth(salma));
const { body: siens } = await http().get('/work-orders').set(auth(ahmed));
expect(tous.workOrders.length).toBeGreaterThan(siens.workOrders.length);
expect(
siens.workOrders.every((w: { assignees: { id: string }[] }) =>
w.assignees.some((a) => a.id === ahmedId),
),
).toBe(true);
// un OT non assigné à Ahmed lui est invisible (404, pas 403 — pas de fuite)
const autre = tous.workOrders.find(
(w: { assignees: { id: string }[] }) => !w.assignees.some((a) => a.id === ahmedId),
);
await http().get(`/work-orders/${autre.id}`).set(auth(ahmed)).expect(404);
const { body: demandes } = await http().get('/requests').set(auth(karim));
expect(demandes.requests.length).toBeGreaterThan(0);
// Karim (create seulement) ne peut pas approuver
await http()
.post(`/requests/${demandes.requests[0].id}/approve`)
.set(auth(karim))
.send({})
.expect(403);
});
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
const { body: assets } = await http().get('/assets').set(auth(salma));
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1');
const demande = await http()
.post('/requests')
.set(auth(karim))
.send({ assetId: c1.id, description: `À rejeter (${suffix})` })
.expect(201);
await http()
.post(`/requests/${demande.body.id}/reject`)
.set(auth(salma))
.send({ reason: '' })
.expect(400);
const rejetee = await http()
.post(`/requests/${demande.body.id}/reject`)
.set(auth(salma))
.send({ reason: 'Doublon de la demande précédente.' })
.expect(200);
expect(rejetee.body.rejectionReason).toContain('Doublon');
// bilan : une valeur ACTION_TAKEN dans le champ DOOR_STATE → 400
const { body: refs } = await http().get('/reference-values').set(auth(salma));
const action = refs.referenceValues.find((v: { field: string }) => v.field === 'ACTION_TAKEN');
const { body: wos } = await http().get('/work-orders').set(auth(salma));
const enCours = wos.workOrders.find((w: { status: string }) => w.status === 'IN_PROGRESS');
await http()
.put(`/work-orders/${enCours.id}/report`)
.set(auth(salma))
.send({ doorStateId: action.id })
.expect(400);
});
it('« personne bloquée » saute en tête de liste', async () => {
const { body } = await http().get('/work-orders').set(auth(salma));
expect(body.workOrders[0].priority).toBe('PERSON_TRAPPED');
});
it('OT manuel : cycle complet En attente ↔ En cours, puis annulation motivée', async () => {
const { body: assets } = await http().get('/assets').set(auth(salma));
const e2 = assets.assets.find((a: { reference: string }) => a.reference === 'E2');
const ot = await http()
.post('/work-orders')
.set(auth(salma))
.send({
title: `Travaux cabine (${suffix})`,
type: 'WORKS',
priority: 'LOW',
assetId: e2.id,
dueDate: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
assigneeIds: [ahmedId],
})
.expect(201);
const id = ot.body.id;
await http().post(`/work-orders/${id}/transition`).set(auth(salma)).send({ to: 'IN_PROGRESS' }).expect(200);
const attente = await http()
.post(`/work-orders/${id}/transition`)
.set(auth(salma))
.send({ to: 'ON_HOLD', comment: 'Attente de pièce' })
.expect(200);
expect(attente.body.allowedTransitions).toEqual(['IN_PROGRESS', 'CANCELLED']);
await http().post(`/work-orders/${id}/transition`).set(auth(salma)).send({ to: 'IN_PROGRESS' }).expect(200);
const annule = await http()
.post(`/work-orders/${id}/transition`)
.set(auth(salma))
.send({ to: 'CANCELLED', comment: 'Travaux reportés au trimestre prochain' })
.expect(200);
expect(annule.body.cancelledAt).toBeTruthy();
});
it('refus propres : équipement inconnu, personne inconnue, tâche inconnue, OT invisible', async () => {
const GHOST = '00000000-0000-4000-8000-000000000000';
await http()
.post('/work-orders')
.set(auth(salma))
.send({ title: 'X', type: 'CORRECTIVE', assetId: GHOST })
.expect(400);
await http()
.post('/requests')
.set(auth(karim))
.send({ assetId: GHOST, description: 'X' })
.expect(400);
const { body: wos } = await http().get('/work-orders').set(auth(salma));
const un = wos.workOrders[0];
await http()
.put(`/work-orders/${un.id}/assignees`)
.set(auth(salma))
.send({ assigneeIds: [GHOST] })
.expect(400);
await http()
.patch(`/work-orders/${un.id}/checklist/${GHOST}`)
.set(auth(salma))
.send({ state: 'DONE' })
.expect(404);
await http().get(`/work-orders/${GHOST}`).set(auth(salma)).expect(404);
await http()
.post(`/requests/${GHOST}/reject`)
.set(auth(salma))
.send({ reason: 'motif' })
.expect(404);
});
it('référentiels du bilan : ajout (admin), doublon 409, renommage, 404', async () => {
const { body } = await http().get('/auth/demo-accounts');
const adminCompte = body.accounts.find(
(a: { roleName: string }) => a.roleName === 'Administrateur',
);
const admin = (
await http().post('/auth/demo-login').send({ userId: adminCompte.id })
).body.accessToken as string;
const creee = await http()
.post('/reference-values')
.set(auth(admin))
.send({ field: 'ANOMALY', label: `Anomalie test ${suffix}` })
.expect(201);
await http()
.post('/reference-values')
.set(auth(admin))
.send({ field: 'ANOMALY', label: `Anomalie test ${suffix}` })
.expect(409);
await http()
.patch(`/reference-values/${creee.body.id}`)
.set(auth(admin))
.send({ isActive: false })
.expect(200);
await http()
.patch(`/reference-values/00000000-0000-4000-8000-000000000000`)
.set(auth(admin))
.send({ label: 'X' })
.expect(404);
// Salma (Dispatcher, sans SETTINGS.create) → 403
await http()
.post('/reference-values')
.set(auth(salma))
.send({ field: 'ANOMALY', label: 'Interdit' })
.expect(403);
await prisma.referenceValue.delete({ where: { id: creee.body.id } });
});
});

View File

@@ -0,0 +1,128 @@
/**
* E2E R1 — invitation & activation : lien 7 jours, usage unique,
* aucun compte actif avant activation (décision maquettes R1).
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Invitation → activation (e2e)', () => {
let app: INestApplication;
let admin: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const email = `invite-${Date.now().toString(36)}@spelev.ma`;
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const compte = body.accounts.find(
(a: { roleName: string }) => a.roleName === 'Administrateur',
);
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken;
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { email } });
await app?.close();
await prisma.$disconnect();
});
it('invite, empêche la connexion avant activation, active, connecte', async () => {
const { body: roles } = await http()
.get('/roles')
.set('Authorization', `Bearer ${admin}`)
.expect(200);
const technicien = roles.roles.find(
(r: { name: string }) => r.name === 'Technicien',
);
// 1. Invitation → compte « invited », lien émis
const invitation = await http()
.post('/users/invitations')
.set('Authorization', `Bearer ${admin}`)
.send({ email, displayName: 'Recrue Test', roleId: technicien.id })
.expect(201);
expect(invitation.body.activationToken).toBeTruthy();
const { body: users } = await http()
.get('/users')
.set('Authorization', `Bearer ${admin}`);
const invited = users.users.find((u: { email: string }) => u.email === email);
expect(invited.status).toBe('invited');
// 2. Pas de connexion possible avant activation
await http()
.post('/auth/login')
.send({ email, password: 'MotDePasse!123' })
.expect(401);
// 3. Renvoi du lien : l'ancien devient invalide (usage unique)
const renvoi = await http()
.post(`/users/${invitation.body.userId}/invitation`)
.set('Authorization', `Bearer ${admin}`)
.expect(201);
await http()
.post('/auth/activate')
.send({ token: invitation.body.activationToken, password: 'MotDePasse!123' })
.expect(400);
// 4. Activation → connecté directement, statut « active »
const activation = await http()
.post('/auth/activate')
.send({ token: renvoi.body.activationToken, password: 'MotDePasse!123' })
.expect(200);
expect(activation.body.user.role.name).toBe('Technicien');
await http()
.get('/users/me')
.set('Authorization', `Bearer ${activation.body.accessToken}`)
.expect(200);
// 5. Le lien est consommé ; la connexion classique fonctionne désormais
await http()
.post('/auth/activate')
.send({ token: renvoi.body.activationToken, password: 'Autre!12345' })
.expect(400);
await http()
.post('/auth/login')
.send({ email, password: 'MotDePasse!123' })
.expect(200);
// 6. Renvoyer un lien sur un compte activé → refus
await http()
.post(`/users/${invitation.body.userId}/invitation`)
.set('Authorization', `Bearer ${admin}`)
.expect(409);
});
it('refuse un email déjà connu (409) et un lien fantaisiste (400)', async () => {
const { body: roles } = await http()
.get('/roles')
.set('Authorization', `Bearer ${admin}`);
await http()
.post('/users/invitations')
.set('Authorization', `Bearer ${admin}`)
.send({
email: 'dispatcher@demo.siop.ma',
displayName: 'Doublon',
roleId: roles.roles[0].id,
})
.expect(409);
await http()
.post('/auth/activate')
.send({ token: 'jeton-inexistant-123', password: 'MotDePasse!123' })
.expect(400);
});
});

View File

@@ -0,0 +1,208 @@
/**
* E2E R2.2 — préventif : génération IDEMPOTENTE (l'unicité est en base),
* premier contrôle (tout), périodicités ancrées sur la mise en service ;
* compteurs strictement croissants.
* Les générations de test utilisent des mois lointains (2031) puis sont purgées.
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Préventif & compteurs (e2e)', () => {
let app: INestApplication;
let admin: string;
let ahmed: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
beforeAll(async () => {
await seed(prisma);
await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } });
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken as string;
};
admin = await login('Administrateur');
ahmed = await login('Technicien');
});
afterAll(async () => {
await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } });
await prisma.taskTemplate.deleteMany({ where: { label: { contains: 'TEST-' } } });
await prisma.meterReading.deleteMany({ where: { value: { gte: 90000000 } } });
await app?.close();
await prisma.$disconnect();
});
// Les assertions portent sur les 8 appareils du SEED : d'autres specs Jest
// créent/détruisent des appareils en parallèle sur la même base.
const SEED_REFS = ['A1', 'A2', 'B1', 'B2', 'C1', 'D1', 'E2', 'M1'];
const grillesSeed = (period: string) =>
prisma.workOrder.count({
where: { periodKey: period, asset: { reference: { in: SEED_REFS } } },
});
it('génère une grille par appareil sous contrat — et regénérer ne double RIEN', async () => {
const premiere = await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-01' })
.expect(200);
expect(premiere.body.created).toBeGreaterThanOrEqual(SEED_REFS.length);
expect(premiere.body.references.length).toBe(premiere.body.created);
expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length);
const seconde = await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-01' })
.expect(200);
expect(seconde.body.skipped).toBeGreaterThanOrEqual(SEED_REFS.length);
expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length); // toujours 8, pas 16
});
it('premier contrôle (toutes les tâches) pour un appareil sans historique ; grille normale sinon', async () => {
const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } });
const mensuelles = await prisma.taskTemplate.count({
where: { isActive: true, periodMonths: 1 },
});
// Un appareil VIERGE créé par ce test (aucune génération n'a pu le toucher)
const categorie = await prisma.category.findFirstOrThrow({ where: { kind: 'EQUIPMENT' } });
const site = await prisma.location.findFirstOrThrow({ where: { parentId: null } });
const vierge = await prisma.asset.create({
data: {
reference: `PVT-${Date.now().toString(36)}`,
brand: 'Test',
categoryId: categorie.id,
locationId: site.id,
},
});
try {
await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-09' })
.expect(200);
const grille = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: vierge.id, periodKey: '2031-09' } },
include: { _count: { select: { checklist: true } } },
});
expect(grille.title).toContain('Premier contrôle');
expect(grille._count.checklist).toBe(templatesActifs);
} finally {
await prisma.workOrder.deleteMany({ where: { assetId: vierge.id } });
await prisma.asset.delete({ where: { id: vierge.id } });
}
// A1 a toujours un historique préventif (seed) → grille normale.
// Mise en service mars 2020 : janvier 2031 (écart 130 mois) n'est le mois
// ni du 3, ni du 6, ni du 12 → seules les tâches MENSUELLES sont dues.
const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
const grilleA1 = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-01' } },
include: { _count: { select: { checklist: true } } },
});
expect(grilleA1.title).toContain('Grille du mois');
expect(grilleA1._count.checklist).toBe(mensuelles);
});
it('mois anniversaire de mise en service : les périodicités longues tombent (mars 2031 pour A1)', async () => {
await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-03' })
.expect(200);
// écart mars 2020 → mars 2031 = 132 mois : divisible par 3, 6 et 12
const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } });
const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
const grille = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-03' } },
include: { checklist: true },
});
expect(grille.checklist.length).toBe(templatesActifs);
expect(grille.checklist.some((c) => c.label === 'Essai du parachute')).toBe(true);
});
it('gabarits : ajout, doublon 409, désactivation (exclu des générations suivantes), 404', async () => {
const cree = await http()
.post('/preventive/templates')
.set(auth(admin))
.send({ label: 'TEST-Contrôle éclairage gaine', periodMonths: 1 })
.expect(201);
await http()
.post('/preventive/templates')
.set(auth(admin))
.send({ label: 'TEST-Contrôle éclairage gaine', periodMonths: 1 })
.expect(409);
await http()
.patch(`/preventive/templates/${cree.body.id}`)
.set(auth(admin))
.send({ isActive: false })
.expect(200);
await http()
.patch('/preventive/templates/00000000-0000-4000-8000-000000000000')
.set(auth(admin))
.send({ label: 'X' })
.expect(404);
// désactivé → absent de la génération d'un nouveau mois
await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-05' })
.expect(200);
const labels = await prisma.checklistItem.findMany({
where: { workOrder: { periodKey: '2031-05' } },
select: { label: true },
});
expect(labels.some((l) => l.label.startsWith('TEST-'))).toBe(false);
// le statut du mois courant répond (structure)
const status = await http().get('/preventive/status').set(auth(ahmed)).expect(200);
expect(status.body).toHaveProperty('generated');
expect(status.body).toHaveProperty('late');
});
it('compteurs : relevé croissant accepté, régression refusée, appareil inconnu 404', async () => {
const { body: assets } = await http().get('/assets').set(auth(ahmed));
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
const liste = await http().get(`/assets/${a1.id}/meters`).set(auth(ahmed)).expect(200);
expect(liste.body.meters).toHaveLength(2); // les 2 compteurs, même vides
const heures = liste.body.meters.find((m: { kind: string }) => m.kind === 'RUNNING_HOURS');
const dernier = heures.readings[0]?.value ?? 0;
const apres = await http()
.post(`/assets/${a1.id}/meter-readings`)
.set(auth(ahmed))
.send({ kind: 'RUNNING_HOURS', value: 90000000 + dernier })
.expect(201);
const majHeures = apres.body.meters.find(
(m: { kind: string }) => m.kind === 'RUNNING_HOURS',
);
expect(majHeures.readings[0].value).toBe(90000000 + dernier);
expect(majHeures.readings[0].readBy.displayName).toBe('Ahmed Benali');
await http()
.post(`/assets/${a1.id}/meter-readings`)
.set(auth(ahmed))
.send({ kind: 'RUNNING_HOURS', value: dernier })
.expect(400);
await http()
.get('/assets/00000000-0000-4000-8000-000000000000/meters')
.set(auth(ahmed))
.expect(404);
});
});

View File

@@ -0,0 +1,185 @@
/**
* E2E R1 — référentiel : parcours de recette (site → zone → appareil → organes)
* + invariants (profondeur 2, types d'organes, matrice de permissions vivante).
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Référentiel (e2e)', () => {
let app: INestApplication;
let admin: string; // jetons
let technicien: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const suffix = Date.now().toString(36); // données de test uniques et repérables
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
const res = await http().post('/auth/demo-login').send({ userId: compte.id });
return res.body.accessToken as string;
};
admin = await login('Administrateur');
technicien = await login('Technicien');
});
afterAll(async () => {
await prisma.asset.deleteMany({ where: { reference: { contains: suffix } } });
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('parcours de recette : site → zone → appareil avec organes', async () => {
const site = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({
name: `Site Recette ${suffix}`,
city: 'Casablanca',
latitude: 33.59,
longitude: -7.61,
})
.expect(201);
expect(site.body.parentId).toBeNull();
const zone = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Hall Recette ${suffix}`, parentId: site.body.id })
.expect(201);
const { body: cats } = await http()
.get('/categories')
.set('Authorization', `Bearer ${admin}`);
const equipement = cats.categories.find(
(c: { kind: string }) => c.kind === 'EQUIPMENT',
);
const typeOrgane = cats.categories.find(
(c: { kind: string; isActive: boolean }) => c.kind === 'COMPONENT_TYPE' && c.isActive,
);
const asset = await http()
.post('/assets')
.set('Authorization', `Bearer ${admin}`)
.send({
reference: `T-${suffix}`,
brand: 'Otis',
model: 'Gen2',
categoryId: equipement.id,
locationId: zone.body.id,
components: [{ typeId: typeOrgane.id, designation: 'Organe de test' }],
})
.expect(201);
expect(asset.body.components).toHaveLength(1);
expect(asset.body.siteName).toBe(`Site Recette ${suffix}`);
// Ajout puis retrait d'un organe
const organe = await http()
.post(`/assets/${asset.body.id}/components`)
.set('Authorization', `Bearer ${admin}`)
.send({ typeId: typeOrgane.id })
.expect(201);
await http()
.delete(`/assets/${asset.body.id}/components/${organe.body.id}`)
.set('Authorization', `Bearer ${admin}`)
.expect(204);
});
it('refuse une hiérarchie de profondeur 3 (site → zone → ?)', async () => {
const site = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Site Profond ${suffix}` })
.expect(201);
const zone = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Zone Profonde ${suffix}`, parentId: site.body.id })
.expect(201);
await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Sous-zone ${suffix}`, parentId: zone.body.id })
.expect(400);
});
it('refuse un appareil dont la catégorie nest pas un équipement', async () => {
const { body: cats } = await http()
.get('/categories')
.set('Authorization', `Bearer ${admin}`);
const typeOrgane = cats.categories.find((c: { kind: string }) => c.kind === 'COMPONENT_TYPE');
const { body: locs } = await http()
.get('/locations')
.set('Authorization', `Bearer ${admin}`);
await http()
.post('/assets')
.set('Authorization', `Bearer ${admin}`)
.send({
reference: `KO-${suffix}`,
brand: 'Test',
categoryId: typeOrgane.id, // kind COMPONENT_TYPE → refus
locationId: locs.locations[0].id,
})
.expect(400);
});
it('la matrice vit : un Technicien lit le parc mais ne crée rien', async () => {
await http().get('/assets').set('Authorization', `Bearer ${technicien}`).expect(200);
await http().get('/locations').set('Authorization', `Bearer ${technicien}`).expect(200);
await http()
.post('/locations')
.set('Authorization', `Bearer ${technicien}`)
.send({ name: `Interdit ${suffix}` })
.expect(403);
await http().get('/users').set('Authorization', `Bearer ${technicien}`).expect(403);
});
it('le seed de la maquette est en place (A1 et ses organes, statuts)', async () => {
const { body } = await http()
.get('/assets')
.set('Authorization', `Bearer ${admin}`)
.expect(200);
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
expect(a1.siteName).toBe('Résidence Al Manar');
expect(a1.componentCount).toBe(4);
const b2 = body.assets.find((a: { reference: string }) => a.reference === 'B2');
expect(b2.status).toBe('OUT_OF_SERVICE');
});
it('catégorie utilisée : désactivable, jamais supprimable (la route nexiste pas)', async () => {
const { body: cats } = await http()
.get('/categories')
.set('Authorization', `Bearer ${admin}`);
const used = cats.categories.find((c: { usageCount: number }) => c.usageCount > 0);
await http()
.delete(`/categories/${used.id}`)
.set('Authorization', `Bearer ${admin}`)
.expect(404); // pas de DELETE dans le contrat
const off = await http()
.patch(`/categories/${used.id}`)
.set('Authorization', `Bearer ${admin}`)
.send({ isActive: false })
.expect(200);
expect(off.body.isActive).toBe(false);
await http()
.patch(`/categories/${used.id}`)
.set('Authorization', `Bearer ${admin}`)
.send({ isActive: true })
.expect(200);
});
});

View File

@@ -1,11 +1,14 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
/** Seed idempotent avant le parcours : mêmes comptes en local et en CI. */ /** Seed idempotent + purge des restes du run précédent : mêmes données
* en local et en CI. */
export default function globalSetup() { export default function globalSetup() {
execSync('pnpm --filter @siop/api seed', { const cwd = fileURLToPath(new URL('../../..', import.meta.url));
cwd: fileURLToPath(new URL('../../..', import.meta.url)), execSync('pnpm --filter @siop/api exec tsx prisma/cleanup-e2e.ts', {
cwd,
stdio: 'inherit', stdio: 'inherit',
env: process.env, env: process.env,
}); });
execSync('pnpm --filter @siop/api seed', { cwd, stdio: 'inherit', env: process.env });
} }

View File

@@ -0,0 +1,77 @@
import { expect, test } from '@playwright/test';
/**
* R2.4 — LA boucle produit : le gardien scanne le QR (aucun compte), signale,
* l'équipe traite (via API), le gardien voit « Résolu » sur son téléphone.
*/
const suffix = Date.now().toString(36);
const API = 'http://localhost:3000';
test('portail QR : signalement sans compte → traitement → suivi Résolu', async ({
page,
request,
}) => {
// 1 · Le gardien scanne le QR de l'ascenseur A1 → formulaire prérempli
await page.goto('/q/A1');
await expect(page.getByText(/Ascenseur A1/)).toBeVisible();
await expect(page.getByText('détecté par le QR')).toBeVisible();
await page.getByLabel('Décrivez le problème').fill(`E2E ${suffix} — voyant cabine éteint`);
await page.getByRole('button', { name: 'Envoyer le signalement' }).click();
await expect(page.getByText('l\'équipe SPELEV est prévenue')).toBeVisible();
// « Mes signalements » : Reçu, sans jargon
const ligne = page.locator('.item-liste', { hasText: suffix });
await expect(ligne).toBeVisible();
await expect(ligne.locator('.etape.fait')).toHaveText('✓ Reçu');
// 2 · Côté SPELEV (via API — le parcours UI est couvert par parcours-r2) :
// approbation → démarrage → bilan → clôture
const comptes = await (await request.get(`${API}/auth/demo-accounts`)).json();
const salmaId = comptes.accounts.find((a: { roleName: string }) => a.roleName === 'Dispatcher').id;
const jeton = (await (await request.post(`${API}/auth/demo-login`, { data: { userId: salmaId } })).json()).accessToken;
const auth = { Authorization: `Bearer ${jeton}` };
const demandes = await (await request.get(`${API}/requests`, { headers: auth })).json();
const demande = demandes.requests.find((r: { description: string }) => r.description.includes(suffix));
expect(demande).toBeTruthy();
const ot = await (
await request.post(`${API}/requests/${demande.id}/approve`, { headers: auth, data: {} })
).json();
await request.post(`${API}/work-orders/${ot.id}/transition`, {
headers: auth,
data: { to: 'IN_PROGRESS' },
});
// 3 · Le gardien recharge : « Intervention » franchie
await page.reload();
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Intervention')).toBeVisible();
// 4 · Clôture avec bilan → « Résolu »
const refs = await (await request.get(`${API}/reference-values`, { headers: auth })).json();
const valeur = (field: string) =>
refs.referenceValues.find((v: { field: string; isActive: boolean }) => v.field === field && v.isActive).id;
await request.put(`${API}/work-orders/${ot.id}/report`, {
headers: auth,
data: {
doorStateId: valeur('DOOR_STATE'),
actionTakenId: valeur('ACTION_TAKEN'),
componentConcernedId: valeur('COMPONENT_CONCERNED'),
},
});
const cloture = await request.post(`${API}/work-orders/${ot.id}/transition`, {
headers: auth,
data: { to: 'DONE' },
});
expect(cloture.ok()).toBe(true);
await page.reload();
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Résolu')).toBeVisible();
});
test('QR inconnu : message clair, pas décran technique', async ({ page }) => {
await page.goto('/q/INTROUVABLE-99');
await expect(page.getByText(/aucun appareil connu/)).toBeVisible();
});

View File

@@ -0,0 +1,96 @@
import { expect, test } from '@playwright/test';
/**
* Recette R1 (plan de releases) : créer un site → un ascenseur → ses organes
* → imprimer l'étiquette QR → inviter un technicien (activation incluse).
*/
const suffix = Date.now().toString(36);
test('recette R1 complète, de la création du site à lactivation de linvité', async ({
page,
}) => {
// Connexion démo (Administrateur — création tous azimuts)
await page.goto('/connexion');
await page.getByRole('button', { name: /Yasmine Alaoui/ }).click();
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
// 1 · Sites : le parc seedé est là, on crée un site
await page.getByRole('link', { name: 'Sites' }).click();
await expect(page.getByRole('cell', { name: /Tour Atlas/ })).toBeVisible();
await page.getByRole('button', { name: '+ Nouveau site' }).click();
await page.getByLabel('Nom du site *').fill(`Site E2E ${suffix}`);
await page.getByLabel('Ville').fill('Casablanca');
await page.getByRole('button', { name: 'Créer le site' }).click();
// → fiche du site créé ; on lui ajoute une zone
await expect(page.getByRole('heading', { name: `Site E2E ${suffix}` })).toBeVisible();
await page.getByRole('button', { name: '+ Ajouter un emplacement' }).click();
await page.getByLabel('Nom de la zone *').fill(`Hall E2E ${suffix}`);
await page.getByRole('button', { name: 'Ajouter', exact: true }).click();
await expect(page.locator('.arbre')).toContainText(`Hall E2E ${suffix}`);
// 2 · Nouvel ascenseur avec un organe
await page.getByRole('button', { name: '+ Nouvel ascenseur' }).click();
await page.getByLabel('Référence *').fill(`E2E-${suffix}`);
await page.getByLabel('Marque *').fill('Otis');
await page.getByLabel('Catégorie *').selectOption({ label: 'Ascenseur électrique' });
await page.getByLabel('Emplacement dans le site *').selectOption({ label: `Hall E2E ${suffix}` });
await page.getByLabel("Type de l'organe 1").selectOption({ label: 'Portes cabine / palières' });
await page.getByLabel("Désignation de l'organe 1").fill('Fermator 40/10');
await page.getByRole('button', { name: "Créer l'ascenseur (QR généré)" }).click();
// → fiche appareil : identité, organe, QR
await expect(
page.getByRole('heading', { name: new RegExp(`Ascenseur E2E-${suffix}`) }),
).toBeVisible();
await expect(page.getByText('Portes cabine / palières')).toBeVisible();
// 3 · Étiquette QR imprimable
await page.getByRole('button', { name: 'Étiquette QR' }).click();
await expect(page.locator('.etiquette')).toContainText(`Ascenseur E2E-${suffix}`);
await expect(page.locator('.etiquette .consigne')).toContainText('Scannez ce code');
// 4 · Inviter un technicien → récupérer le lien d'activation
await page.getByRole('link', { name: 'Personnes' }).click();
await page.getByRole('button', { name: '+ Inviter' }).click();
await page.getByLabel('Nom complet *').fill('Recrue E2E');
await page.getByLabel('Email professionnel *').fill(`recrue-${suffix}@spelev.ma`);
await page.getByLabel('Rôle *').selectOption({ label: 'Technicien' });
await page.getByRole('button', { name: "Envoyer l'invitation" }).click();
const lien = await page.getByTestId('lien-activation').textContent();
expect(lien).toContain('/activation?token=');
await page.getByRole('button', { name: 'Fermer' }).click();
await expect(page.getByRole('cell', { name: /Recrue E2E/ })).toBeVisible();
await expect(page.getByText('Invitation envoyée')).toBeVisible();
// 5 · La recrue active son compte via le lien et entre directement
await page.locator('.compte').click();
await page.getByRole('menuitem', { name: 'Se déconnecter' }).click();
await page.goto(lien!);
await page.getByLabel('Mot de passe (8 caractères minimum)').fill('MotDePasse!123');
await page.getByLabel('Confirmez le mot de passe').fill('MotDePasse!123');
await page.getByRole('button', { name: 'Activer mon compte' }).click();
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
await expect(page.locator('.topbar')).toContainText('Recrue E2E');
await expect(page.locator('.topbar')).toContainText('Technicien');
});
test('la matrice pilote lUI : un technicien voit le parc sans boutons de création', async ({
page,
}) => {
await page.goto('/connexion');
await page.getByRole('button', { name: /Ahmed Benali/ }).click();
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
// Catégories (SETTINGS) absent de sa sidebar ; Ascenseurs accessible
await expect(page.locator('.nav')).not.toContainText('Catégories');
await page.getByRole('link', { name: 'Ascenseurs' }).click();
await expect(page.getByRole('cell', { name: 'A1', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '+ Nouvel ascenseur' })).toHaveCount(0);
// Fiche : statuts visibles, pas de retrait d'organe
await page.getByRole('cell', { name: 'A1', exact: true }).click();
await expect(page.getByText('Treuil / machinerie')).toBeVisible();
await expect(page.getByRole('button', { name: /Retirer/ })).toHaveCount(0);
});

View File

@@ -0,0 +1,117 @@
import { expect, test } from '@playwright/test';
/**
* Recette R2 (plan de releases) : demande gardien → approbation → OT →
* intervention → bilan codé → clôture → suivi demandeur ; génération du
* préventif du mois (idempotente) ; compteur croissant.
*/
const suffix = Date.now().toString(36);
test('recette R2 : demande → OT → bilan codé → clôture → suivi demandeur', async ({
page,
}) => {
// 1 · Karim (demandeur) signale depuis l'app
await page.goto('/connexion');
await page.getByRole('button', { name: /Karim Doukkali/ }).click();
await expect(page.getByRole('heading', { name: 'Mes signalements' })).toBeVisible();
await page.getByRole('link', { name: /Demandes/ }).click();
await page.getByRole('button', { name: '+ Signaler' }).click();
await page.getByLabel('Équipement *').selectOption({ index: 1 });
await page.getByLabel('Décrivez le problème *').fill(`E2E ${suffix} — porte qui claque très fort`);
await page.getByRole('button', { name: 'Envoyer le signalement' }).click();
await expect(page.getByRole('cell', { name: new RegExp(suffix) })).toBeVisible();
// 2 · Salma (dispatcher) approuve → OT créé, assigné à Ahmed
await page.locator('.compte').click();
await page.getByRole('menuitem', { name: /Salma Idrissi/ }).click();
await page.getByRole('link', { name: /Demandes/ }).click();
await page.getByRole('cell', { name: new RegExp(suffix) }).click();
await page.getByLabel('Assigner à').selectOption({ index: 1 }); // Ahmed Benali
await page.getByRole('button', { name: "Approuver → créer l'OT" }).click();
// → fiche OT
await expect(page.getByRole('heading', { name: new RegExp(suffix) })).toBeVisible();
await expect(page.locator('.entete-page .st')).toHaveText('Ouvert');
// 3 · Démarrer, tenter de clôturer (bloqué : bilan), remplir le bilan, clôturer
await page.getByRole('button', { name: 'Démarrer' }).click();
await expect(page.locator('.entete-page .st')).toHaveText('En cours');
const boutonCloture = page.getByRole('button', { name: "Clôturer l'intervention" });
await expect(boutonCloture).toBeDisabled(); // garde visible dans l'UI
await expect(page.locator('.avertissement')).toContainText('bilan incomplet');
await page.getByLabel(/État des portes/).selectOption({ label: 'Fonctionnement normal' });
await page.getByLabel(/Action réalisée/).selectOption({ label: 'Réglage' });
await page.getByLabel(/Élément concerné/).selectOption({ label: 'Portes' });
await expect(boutonCloture).toBeEnabled();
await boutonCloture.click();
await expect(page.locator('.entete-page .st')).toHaveText('Terminé');
// 4 · Karim suit : sa demande est Résolue
await page.locator('.compte').click();
await page.getByRole('menuitem', { name: /Karim Doukkali/ }).click();
await page.getByRole('link', { name: /Demandes/ }).click();
const ligne = page.locator('tr', { hasText: suffix });
await expect(ligne.locator('.st')).toHaveText('Résolue');
});
test('préventif : générer le mois — puis regénérer ne double rien', async ({ page }) => {
await page.goto('/connexion');
await page.getByRole('button', { name: /Yasmine Alaoui/ }).click();
await page.getByRole('link', { name: 'Préventif' }).click();
await expect(page.getByRole('heading', { name: 'Préventif' })).toBeVisible();
await expect(page.getByText('Essai du parachute')).toBeVisible();
await page.getByRole('button', { name: /Générer la grille de/ }).click();
await expect(page.locator('.gen-info')).toContainText('regénérer ne double rien');
// Second appel : 0 créé
await page.getByRole('button', { name: /Générer la grille de/ }).click();
await expect(page.locator('.gen-info')).toContainText('0 OT créés');
});
test('urgence « personne bloquée » : interrupteur → chip topbar + bandeau + tête de liste', async ({
page,
}) => {
await page.goto('/connexion');
await page.getByRole('button', { name: /Salma Idrissi/ }).click();
await page.getByRole('link', { name: /Ordres de travail/ }).click();
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
await page.getByLabel('Objet *').fill(`E2E ${suffix} — personne bloquée cage B`);
await page.getByLabel('Équipement *').selectOption({ index: 1 });
await page.getByRole('switch', { name: 'Personne bloquée' }).click();
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
await expect(page.getByRole('heading', { name: new RegExp(suffix) })).toBeVisible();
// chip topbar (pulse) présent et bandeau sur le tableau de bord
await expect(page.locator('.topbar .chip-urgence')).toContainText('personne');
await page.getByRole('link', { name: 'Tableau de bord' }).click();
await expect(page.locator('.bandeau-urgence').first()).toBeVisible();
// tête de liste OT
await page.getByRole('link', { name: /Ordres de travail/ }).click();
await expect(page.locator('tbody tr').first()).toHaveClass(/ligne-bloque/);
// nettoyage : annuler l'OT d'urgence créé (motif via prompt)
await page.locator('tbody tr').first().click();
page.once('dialog', (d) => d.accept('Test e2e — nettoyage'));
await page.getByRole('button', { name: 'Annuler', exact: true }).click();
await expect(page.locator('.entete-page .st')).toHaveText('Annulé');
});
test('compteurs : relevé croissant accepté, régression refusée', async ({ page }) => {
await page.goto('/connexion');
await page.getByRole('button', { name: /Ahmed Benali/ }).click();
await page.getByRole('link', { name: 'Ascenseurs' }).click();
await page.getByRole('cell', { name: 'A1', exact: true }).click();
await page.getByRole('button', { name: 'Compteurs' }).click();
await expect(page.getByRole('heading', { name: /Compteurs — Ascenseur A1/ })).toBeVisible();
const saisie = page.getByLabel('Nouveau relevé — Heures de marche');
await saisie.fill('99999999');
await page.getByRole('button', { name: 'Enregistrer le relevé' }).first().click();
await expect(page.locator('.chrono').first()).toContainText('99 999 999');
await saisie.fill('12');
await page.getByRole('button', { name: 'Enregistrer le relevé' }).first().click();
await expect(page.locator('.erreur-form')).toContainText('ne redescend pas');
});

View File

@@ -15,21 +15,26 @@
}, },
"dependencies": { "dependencies": {
"@fontsource/manrope": "^5.1.0", "@fontsource/manrope": "^5.1.0",
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-dropdown-menu": "^2.1.4", "@radix-ui/react-dropdown-menu": "^2.1.4",
"@siop/shared": "workspace:*", "@siop/shared": "workspace:*",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"openapi-fetch": "^0.13.4", "openapi-fetch": "^0.13.4",
"qrcode.react": "^4.2.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.1.0", "react-router-dom": "^7.1.0",
"tailwind-merge": "^2.6.0" "tailwind-merge": "^2.6.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.61.1", "@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.1.0", "@tailwindcss/vite": "^4.1.0",
"@types/leaflet": "^1.9.21",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",

View File

@@ -1,36 +1,58 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Route, Routes } from 'react-router-dom';
import type { ReactNode } from 'react';
import { Coquille } from '@/layout/coquille'; import { Coquille } from '@/layout/coquille';
import PageActivation from '@/pages/activation';
import PageAscenseurs from '@/pages/ascenseurs';
import PageCategories from '@/pages/categories';
import PageCompteurs from '@/pages/compteurs';
import PageConnexion from '@/pages/connexion'; import PageConnexion from '@/pages/connexion';
import PageDemandes from '@/pages/demandes';
import PageDesign from '@/pages/design'; import PageDesign from '@/pages/design';
import PageEtiquette from '@/pages/etiquette';
import PageFicheAscenseur from '@/pages/fiche-ascenseur';
import PageFicheOT from '@/pages/fiche-ot';
import PageFicheSite from '@/pages/fiche-site';
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
import PageNouvelOT from '@/pages/nouvel-ot';
import PageOrdresTravail from '@/pages/ordres-travail';
import PagePersonnes from '@/pages/personnes';
import PagePortail from '@/pages/portail';
import PagePreventif from '@/pages/preventif';
import PageSites from '@/pages/sites';
import PageTableauDeBord from '@/pages/tableau-de-bord'; import PageTableauDeBord from '@/pages/tableau-de-bord';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } }, defaultOptions: { queries: { refetchOnWindowFocus: false } },
}); });
const dansCoquille = (page: ReactNode) => <Coquille>{page}</Coquille>;
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/connexion" element={<PageConnexion />} /> <Route path="/connexion" element={<PageConnexion />} />
<Route <Route path="/activation" element={<PageActivation />} />
path="/" {/* Portail public — cible du QR cabine, AUCUN compte */}
element={ <Route path="/q/:reference" element={<PagePortail />} />
<Coquille> <Route path="/" element={dansCoquille(<PageTableauDeBord />)} />
<PageTableauDeBord /> <Route path="/design" element={dansCoquille(<PageDesign />)} />
</Coquille> <Route path="/sites" element={dansCoquille(<PageSites />)} />
} <Route path="/sites/:id" element={dansCoquille(<PageFicheSite />)} />
/> <Route path="/ascenseurs" element={dansCoquille(<PageAscenseurs />)} />
<Route <Route path="/ascenseurs/nouveau" element={dansCoquille(<PageNouvelAscenseur />)} />
path="/design" <Route path="/ascenseurs/:id" element={dansCoquille(<PageFicheAscenseur />)} />
element={ <Route path="/ascenseurs/:id/etiquette" element={dansCoquille(<PageEtiquette />)} />
<Coquille> <Route path="/ascenseurs/:id/compteurs" element={dansCoquille(<PageCompteurs />)} />
<PageDesign /> <Route path="/personnes" element={dansCoquille(<PagePersonnes />)} />
</Coquille> <Route path="/categories" element={dansCoquille(<PageCategories />)} />
} <Route path="/ot" element={dansCoquille(<PageOrdresTravail />)} />
/> <Route path="/ot/nouveau" element={dansCoquille(<PageNouvelOT />)} />
<Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} />
<Route path="/demandes" element={dansCoquille(<PageDemandes />)} />
<Route path="/preventif" element={dansCoquille(<PagePreventif />)} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>

View File

@@ -0,0 +1,235 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
AssigneesUpdate,
ChecklistPatch,
MeterReadingCreate,
PreventiveGenerate,
ReportUpsert,
RequestApprove,
RequestCreate,
RequestReject,
TaskTemplateCreate,
TaskTemplateUpdate,
TransitionRequest,
WorkOrderCreate,
} from '@siop/shared';
import { api } from './client';
/** Hooks R2 — client typé, invalidations ciblées. */
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.error || res.data === undefined) {
const message =
(res.error as { message?: string } | undefined)?.message ??
`Le serveur a répondu ${res.response.status}`;
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
}
return res.data;
}
function useInvalidate(...keys: string[]) {
const queryClient = useQueryClient();
return () =>
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
}
// ————— Ordres de travail —————
export function useWorkOrders(enabled = true) {
return useQuery({
queryKey: ['work-orders'],
enabled,
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
});
}
export function useWorkOrder(id: string | undefined) {
return useQuery({
queryKey: ['work-orders', id],
enabled: !!id,
queryFn: async () =>
unwrap(await api.GET('/work-orders/{id}', { params: { path: { id: id! } } })),
});
}
export function useCreateWorkOrder() {
const invalidate = useInvalidate('work-orders');
return useMutation({
mutationFn: async (body: WorkOrderCreate) =>
unwrap(await api.POST('/work-orders', { body })),
onSuccess: invalidate,
});
}
export function useTransitionWorkOrder(id: string) {
const invalidate = useInvalidate('work-orders', 'requests', 'preventive-status');
return useMutation({
mutationFn: async (body: TransitionRequest) =>
unwrap(await api.POST('/work-orders/{id}/transition', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useCommentWorkOrder(id: string) {
const invalidate = useInvalidate('work-orders');
return useMutation({
mutationFn: async (message: string) =>
unwrap(
await api.POST('/work-orders/{id}/comments', {
params: { path: { id } },
body: { message },
}),
),
onSuccess: invalidate,
});
}
export function useSetAssignees(id: string) {
const invalidate = useInvalidate('work-orders');
return useMutation({
mutationFn: async (body: AssigneesUpdate) =>
unwrap(await api.PUT('/work-orders/{id}/assignees', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useUpsertReport(id: string) {
const invalidate = useInvalidate('work-orders');
return useMutation({
mutationFn: async (body: ReportUpsert) =>
unwrap(await api.PUT('/work-orders/{id}/report', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function usePatchChecklist(id: string) {
const invalidate = useInvalidate('work-orders');
return useMutation({
mutationFn: async ({ itemId, ...body }: ChecklistPatch & { itemId: string }) =>
unwrap(
await api.PATCH('/work-orders/{id}/checklist/{itemId}', {
params: { path: { id, itemId } },
body,
}),
),
onSuccess: invalidate,
});
}
// ————— Demandes —————
export function useRequests() {
return useQuery({
queryKey: ['requests'],
queryFn: async () => (await unwrap(await api.GET('/requests'))).requests,
});
}
export function useCreateRequest() {
const invalidate = useInvalidate('requests');
return useMutation({
mutationFn: async (body: RequestCreate) => unwrap(await api.POST('/requests', { body })),
onSuccess: invalidate,
});
}
export function useApproveRequest() {
const invalidate = useInvalidate('requests', 'work-orders');
return useMutation({
mutationFn: async ({ id, ...body }: RequestApprove & { id: string }) =>
unwrap(await api.POST('/requests/{id}/approve', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useRejectRequest() {
const invalidate = useInvalidate('requests');
return useMutation({
mutationFn: async ({ id, ...body }: RequestReject & { id: string }) =>
unwrap(await api.POST('/requests/{id}/reject', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
// ————— Référentiels du bilan —————
export function useReferenceValues() {
return useQuery({
queryKey: ['reference-values'],
staleTime: 5 * 60_000,
queryFn: async () =>
(await unwrap(await api.GET('/reference-values'))).referenceValues,
});
}
// ————— Préventif —————
export function useTaskTemplates() {
return useQuery({
queryKey: ['task-templates'],
queryFn: async () => (await unwrap(await api.GET('/preventive/templates'))).templates,
});
}
export function useCreateTaskTemplate() {
const invalidate = useInvalidate('task-templates');
return useMutation({
mutationFn: async (body: TaskTemplateCreate) =>
unwrap(await api.POST('/preventive/templates', { body })),
onSuccess: invalidate,
});
}
export function useUpdateTaskTemplate() {
const invalidate = useInvalidate('task-templates');
return useMutation({
mutationFn: async ({ id, ...body }: TaskTemplateUpdate & { id: string }) =>
unwrap(
await api.PATCH('/preventive/templates/{id}', { params: { path: { id } }, body }),
),
onSuccess: invalidate,
});
}
export function usePreventiveStatus(enabled = true) {
return useQuery({
queryKey: ['preventive-status'],
enabled,
queryFn: async () => unwrap(await api.GET('/preventive/status')),
});
}
export function useGeneratePreventive() {
const invalidate = useInvalidate('preventive-status', 'work-orders');
return useMutation({
mutationFn: async (body: PreventiveGenerate) =>
unwrap(await api.POST('/preventive/generate', { body })),
onSuccess: invalidate,
});
}
// ————— Compteurs —————
export function useMeters(assetId: string | undefined) {
return useQuery({
queryKey: ['meters', assetId],
enabled: !!assetId,
queryFn: async () =>
(await unwrap(await api.GET('/assets/{id}/meters', { params: { path: { id: assetId! } } })))
.meters,
});
}
export function useAddMeterReading(assetId: string) {
const invalidate = useInvalidate('meters');
return useMutation({
mutationFn: async (body: MeterReadingCreate) =>
unwrap(
await api.POST('/assets/{id}/meter-readings', {
params: { path: { id: assetId } },
body,
}),
),
onSuccess: invalidate,
});
}

View File

@@ -0,0 +1,198 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
AssetComponentCreate,
AssetCreate,
AssetUpdate,
CategoryCreate,
CategoryUpdate,
InvitationCreate,
LocationCreate,
TeamCreate,
UserUpdate,
} from '@siop/shared';
import { api } from './client';
/** Hooks R1 — chaque appel passe par le client typé (règle d'or). */
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.error || res.data === undefined) {
const message =
(res.error as { message?: string } | undefined)?.message ??
`Le serveur a répondu ${res.response.status}`;
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
}
return res.data;
}
export function useLocations() {
return useQuery({
queryKey: ['locations'],
queryFn: async () => (await unwrap(await api.GET('/locations'))).locations,
});
}
export function useAssets() {
return useQuery({
queryKey: ['assets'],
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
});
}
/** Options minimales (signalement) — accessibles à tout rôle authentifié. */
export function useAssetOptions() {
return useQuery({
queryKey: ['asset-options'],
queryFn: async () => (await unwrap(await api.GET('/assets/options'))).options,
});
}
export function useAsset(id: string | undefined) {
return useQuery({
queryKey: ['assets', id],
enabled: !!id,
queryFn: async () =>
unwrap(await api.GET('/assets/{id}', { params: { path: { id: id! } } })),
});
}
export function useCategories() {
return useQuery({
queryKey: ['categories'],
queryFn: async () => (await unwrap(await api.GET('/categories'))).categories,
});
}
export function useTeams() {
return useQuery({
queryKey: ['teams'],
queryFn: async () => (await unwrap(await api.GET('/teams'))).teams,
});
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => (await unwrap(await api.GET('/users'))).users,
});
}
export function useRoles() {
return useQuery({
queryKey: ['roles'],
queryFn: async () => (await unwrap(await api.GET('/roles'))).roles,
});
}
function useInvalidate(...keys: string[]) {
const queryClient = useQueryClient();
return () =>
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
}
export function useCreateLocation() {
const invalidate = useInvalidate('locations');
return useMutation({
mutationFn: async (body: LocationCreate) =>
unwrap(await api.POST('/locations', { body })),
onSuccess: invalidate,
});
}
export function useCreateAsset() {
const invalidate = useInvalidate('assets', 'locations');
return useMutation({
mutationFn: async (body: AssetCreate) => unwrap(await api.POST('/assets', { body })),
onSuccess: invalidate,
});
}
export function useUpdateAsset(id: string) {
const invalidate = useInvalidate('assets');
return useMutation({
mutationFn: async (body: AssetUpdate) =>
unwrap(await api.PATCH('/assets/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useAddComponent(assetId: string) {
const invalidate = useInvalidate('assets');
return useMutation({
mutationFn: async (body: AssetComponentCreate) =>
unwrap(
await api.POST('/assets/{id}/components', {
params: { path: { id: assetId } },
body,
}),
),
onSuccess: invalidate,
});
}
export function useRemoveComponent(assetId: string) {
const invalidate = useInvalidate('assets');
return useMutation({
mutationFn: async (componentId: string) => {
const res = await api.DELETE('/assets/{id}/components/{componentId}', {
params: { path: { id: assetId, componentId } },
});
if (res.response.status >= 400) throw new Error('Suppression impossible');
},
onSuccess: invalidate,
});
}
export function useCreateCategory() {
const invalidate = useInvalidate('categories');
return useMutation({
mutationFn: async (body: CategoryCreate) =>
unwrap(await api.POST('/categories', { body })),
onSuccess: invalidate,
});
}
export function useUpdateCategory() {
const invalidate = useInvalidate('categories');
return useMutation({
mutationFn: async ({ id, ...body }: CategoryUpdate & { id: string }) =>
unwrap(await api.PATCH('/categories/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useCreateTeam() {
const invalidate = useInvalidate('teams', 'users');
return useMutation({
mutationFn: async (body: TeamCreate) => unwrap(await api.POST('/teams', { body })),
onSuccess: invalidate,
});
}
export function useInviteUser() {
const invalidate = useInvalidate('users');
return useMutation({
mutationFn: async (body: InvitationCreate) =>
unwrap(await api.POST('/users/invitations', { body })),
onSuccess: invalidate,
});
}
export function useResendInvitation() {
const invalidate = useInvalidate('users');
return useMutation({
mutationFn: async (userId: string) =>
unwrap(
await api.POST('/users/{id}/invitation', { params: { path: { id: userId } } }),
),
onSuccess: invalidate,
});
}
export function useUpdateUser() {
const invalidate = useInvalidate('users', 'teams');
return useMutation({
mutationFn: async ({ id, ...body }: UserUpdate & { id: string }) =>
unwrap(await api.PATCH('/users/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
import type { ObjectCategory, PermissionRight } from '@siop/shared';
import { useMe } from './use-auth';
/** Lecture UI de la matrice (l'API re-vérifie chaque requête, invariant R0). */
export function usePermissions() {
const { data: me } = useMe();
const can = (category: ObjectCategory, right: PermissionRight): boolean => {
const entry = me?.permissions.find((p) => p.objectCategory === category);
if (!entry) return false;
switch (right) {
case 'view': return entry.canView;
case 'viewOther': return entry.canViewOther;
case 'create': return entry.canCreate;
case 'edit': return entry.canEdit;
case 'delete': return entry.canDelete;
}
};
return { can, chargé: !!me };
}

View File

@@ -1,8 +1,7 @@
import type { RoleName } from '@siop/shared';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
/** Couleur d'avatar par rôle (tokens uniquement — répartition de la maquette). */ /** Couleur d'avatar par rôle (tokens uniquement — répartition de la maquette). */
const ROLE_TOKEN: Record<RoleName, string> = { const ROLE_TOKEN: Record<string, string> = {
Administrateur: 'var(--graf-1)', Administrateur: 'var(--graf-1)',
Dispatcher: 'var(--primaire)', Dispatcher: 'var(--primaire)',
Technicien: 'var(--graf-3)', Technicien: 'var(--graf-3)',
@@ -18,13 +17,14 @@ export function Avatar({
className, className,
}: { }: {
initials: string; initials: string;
roleName?: RoleName; roleName?: string;
className?: string; className?: string;
}) { }) {
const couleur = roleName ? ROLE_TOKEN[roleName] : undefined;
return ( return (
<span <span
className={cn('avatar', className)} className={cn('avatar', className)}
style={roleName ? { background: ROLE_TOKEN[roleName] } : undefined} style={couleur ? { background: couleur } : undefined}
aria-hidden="true" aria-hidden="true"
> >
{initials} {initials}

View File

@@ -0,0 +1,77 @@
import { divIcon } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { MapContainer, Marker, TileLayer, Tooltip, useMapEvents } from 'react-leaflet';
import { cn } from '@/lib/cn';
/** Carte Leaflet/OSM (positions PostGIS) — pins au style de la maquette. */
export interface PinGeo {
id: string;
latitude: number;
longitude: number;
libelle: string;
alerte?: boolean;
onClick?: () => void;
}
const CENTRE_CASABLANCA: [number, number] = [33.59, -7.6];
function iconePin(alerte?: boolean) {
return divIcon({
className: '',
html: `<div class="pin-geo${alerte ? ' alerte' : ''}"></div>`,
iconSize: [26, 26],
iconAnchor: [13, 26],
});
}
function ClicCarte({ onClick }: { onClick?: (lat: number, lng: number) => void }) {
useMapEvents({
click(e) {
onClick?.(Number(e.latlng.lat.toFixed(5)), Number(e.latlng.lng.toFixed(5)));
},
});
return null;
}
export function CarteGeo({
pins,
className,
zoom = 11,
centre,
onClick,
}: {
pins: PinGeo[];
className?: string;
zoom?: number;
centre?: [number, number];
onClick?: (lat: number, lng: number) => void;
}) {
const centreEffectif =
centre ??
(pins.length
? ([pins[0]!.latitude, pins[0]!.longitude] as [number, number])
: CENTRE_CASABLANCA);
return (
<div className={cn('geo', className)}>
<MapContainer center={centreEffectif} zoom={zoom} scrollWheelZoom={false}>
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ClicCarte onClick={onClick} />
{pins.map((pin) => (
<Marker
key={pin.id}
position={[pin.latitude, pin.longitude]}
icon={iconePin(pin.alerte)}
eventHandlers={pin.onClick ? { click: pin.onClick } : undefined}
>
<Tooltip direction="bottom" offset={[0, 6]}>{pin.libelle}</Tooltip>
</Marker>
))}
</MapContainer>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import {
REQUEST_STATUS_LABELS,
WORK_ORDER_PRIORITY_LABELS,
WORK_ORDER_STATUS_LABELS,
type RequestStatus,
type WorkOrderPriority,
type WorkOrderStatus,
} from '@siop/shared';
import { cn } from '@/lib/cn';
const ST_CLASSES: Record<WorkOrderStatus, string> = {
OPEN: 'ouvert',
IN_PROGRESS: 'encours',
ON_HOLD: 'attente',
DONE: 'termine',
CANCELLED: 'annule',
};
/** Statut OT — les couleurs réservées de la charte. */
export function StatutOT({ statut }: { statut: WorkOrderStatus }) {
return (
<span className={cn('st', ST_CLASSES[statut])}>
{WORK_ORDER_STATUS_LABELS[statut]}
</span>
);
}
const PRIO_CLASSES: Record<WorkOrderPriority, string | null> = {
NONE: null,
LOW: 'basse',
MEDIUM: 'moyenne',
HIGH: 'haute',
PERSON_TRAPPED: 'bloque',
};
/** Priorité — pastille + libellé, jamais la couleur seule (charte). */
export function PrioriteOT({ priorite }: { priorite: WorkOrderPriority }) {
const classe = PRIO_CLASSES[priorite];
if (!classe) return <span className="text-encre-3"></span>;
return (
<span className={cn('prio', classe)}>
<i />
{WORK_ORDER_PRIORITY_LABELS[priorite]}
</span>
);
}
const DEM_CLASSES: Record<RequestStatus, string> = {
RECEIVED: 'recue',
APPROVED: 'approuvee',
REJECTED: 'rejetee',
};
/** Statut de demande — « Résolue » quand l'OT lié est terminé. */
export function StatutDemande({
statut,
otStatut,
}: {
statut: RequestStatus;
otStatut?: WorkOrderStatus | null;
}) {
if (statut === 'APPROVED' && otStatut === 'DONE') {
return <span className="st resolue">Résolue</span>;
}
return <span className={cn('st', DEM_CLASSES[statut])}>{REQUEST_STATUS_LABELS[statut]}</span>;
}

View File

@@ -89,6 +89,12 @@ export const IcoPersonnes = () => (
</svg> </svg>
); );
export const IcoCategories = () => (
<svg {...base}>
<path d="M4 7h16M4 12h16M4 17h10" />
</svg>
);
export const IcoRecherche = () => ( export const IcoRecherche = () => (
<svg {...base}> <svg {...base}>
<circle cx="11" cy="11" r="7" /> <circle cx="11" cy="11" r="7" />

View File

@@ -0,0 +1,13 @@
import { ASSET_STATUS_LABELS, type AssetStatus } from '@siop/shared';
import { cn } from '@/lib/cn';
const CLASSES: Record<AssetStatus, string> = {
IN_SERVICE: 'termine',
OUT_OF_SERVICE: 'arret',
UNDER_MAINTENANCE: 'maintenance',
};
/** Statut d'ÉQUIPEMENT — pastille + libellé, jamais la couleur seule (charte). */
export function StatutEquipement({ statut }: { statut: AssetStatus }) {
return <span className={cn('st', CLASSES[statut])}>{ASSET_STATUS_LABELS[statut]}</span>;
}

View File

@@ -0,0 +1,29 @@
import * as Dialog from '@radix-ui/react-dialog';
import type { ReactNode } from 'react';
/** Modale au style de la maquette, portée par Radix (focus, Échap, overlay). */
export function Modale({
titre,
ouverte,
surFermeture,
children,
}: {
titre: string;
ouverte: boolean;
surFermeture: (ouverte: boolean) => void;
children: ReactNode;
}) {
return (
<Dialog.Root open={ouverte} onOpenChange={surFermeture}>
<Dialog.Portal>
<Dialog.Overlay className="voile-radix" />
<Dialog.Content className="modale" aria-describedby={undefined}>
<Dialog.Title asChild>
<h3>{titre}</h3>
</Dialog.Title>
{children}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -6,8 +6,13 @@ import { getToken } from '@/api/client';
import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth'; import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth';
import { Avatar, initialsOf } from '@/components/avatar'; import { Avatar, initialsOf } from '@/components/avatar';
import { Logo } from '@/components/logo'; import { Logo } from '@/components/logo';
import { useQuery } from '@tanstack/react-query';
import type { ObjectCategory, PermissionRight } from '@siop/shared';
import { api } from '@/api/client';
import { usePermissions } from '@/auth/use-permissions';
import { import {
IcoAscenseurs, IcoAscenseurs,
IcoCategories,
IcoDemandes, IcoDemandes,
IcoFichiers, IcoFichiers,
IcoOrdresDeTravail, IcoOrdresDeTravail,
@@ -29,25 +34,33 @@ import {
} from '@/theme/theme'; } from '@/theme/theme';
/** Navigation de la maquette validée. Les écrans à venir restent visibles /** Navigation de la maquette validée. Les écrans à venir restent visibles
* (périmètre annoncé) mais inertes, marqués de leur release. */ * (périmètre annoncé) mais inertes, marqués de leur release ; les écrans
const NAVIGATION: { * livrés sont filtrés par la matrice (canView). */
groupe: string; interface LienNav {
liens: { libelle: string; icone: () => ReactNode; route?: string; release?: string }[]; libelle: string;
}[] = [ icone: () => ReactNode;
route?: string;
release?: string;
permission?: [ObjectCategory, PermissionRight];
badge?: 'urgences' | 'demandes';
}
const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
{ {
groupe: 'Exploitation', groupe: 'Exploitation',
liens: [ liens: [
{ libelle: 'Tableau de bord', icone: IcoTableauDeBord, route: '/' }, { libelle: 'Tableau de bord', icone: IcoTableauDeBord, route: '/' },
{ libelle: 'Ordres de travail', icone: IcoOrdresDeTravail, release: 'R2' }, { libelle: 'Ordres de travail', icone: IcoOrdresDeTravail, route: '/ot', permission: ['WORK_ORDERS', 'view'], badge: 'urgences' },
{ libelle: 'Demandes', icone: IcoDemandes, release: 'R2' }, { libelle: 'Demandes', icone: IcoDemandes, route: '/demandes', permission: ['REQUESTS', 'view'], badge: 'demandes' },
{ libelle: 'Préventif', icone: IcoPreventif, release: 'R2' }, { libelle: 'Préventif', icone: IcoPreventif, route: '/preventif', permission: ['WORK_ORDERS', 'view'] },
], ],
}, },
{ {
groupe: 'Parc', groupe: 'Parc',
liens: [ liens: [
{ libelle: 'Ascenseurs', icone: IcoAscenseurs, release: 'R1' }, { libelle: 'Ascenseurs', icone: IcoAscenseurs, route: '/ascenseurs', permission: ['ASSETS', 'view'] },
{ libelle: 'Sites', icone: IcoSites, release: 'R1' }, { libelle: 'Sites', icone: IcoSites, route: '/sites', permission: ['LOCATIONS', 'view'] },
{ libelle: 'Catégories', icone: IcoCategories, route: '/categories', permission: ['SETTINGS', 'view'] },
], ],
}, },
{ {
@@ -62,7 +75,7 @@ const NAVIGATION: {
groupe: 'Pilotage', groupe: 'Pilotage',
liens: [ liens: [
{ libelle: 'Statistiques', icone: IcoStatistiques, release: 'R3' }, { libelle: 'Statistiques', icone: IcoStatistiques, release: 'R3' },
{ libelle: 'Personnes', icone: IcoPersonnes, release: 'R1' }, { libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] },
], ],
}, },
]; ];
@@ -73,16 +86,49 @@ const THEME_ICONS: Record<ThemeMode, typeof Sun> = {
dark: Moon, dark: Moon,
}; };
/** L'urgence « personne bloquée » et la file de demandes — visibles partout
* dans la coquille (chip topbar + badges), selon les droits du rôle. */
function useSignauxExploitation(peutVoirOT: boolean, peutVoirDemandes: boolean) {
const { data: workOrders } = useQuery({
queryKey: ['work-orders'],
enabled: peutVoirOT,
refetchInterval: 60_000,
queryFn: async () => (await api.GET('/work-orders')).data?.workOrders ?? [],
});
const { data: requests } = useQuery({
queryKey: ['requests'],
enabled: peutVoirDemandes,
refetchInterval: 60_000,
queryFn: async () => (await api.GET('/requests')).data?.requests ?? [],
});
return {
urgences: (workOrders ?? []).filter(
(w) => w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
),
demandesATraiter: (requests ?? []).filter((r) => r.status === 'RECEIVED').length,
};
}
export function Coquille({ children }: { children: ReactNode }) { export function Coquille({ children }: { children: ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: me } = useMe(); const { data: me } = useMe();
const { data: demoAccounts } = useDemoAccounts(); const { data: demoAccounts } = useDemoAccounts();
const demoLogin = useDemoLogin(); const demoLogin = useDemoLogin();
const logout = useLogout(); const logout = useLogout();
const { can } = usePermissions();
const [themeMode, setThemeMode] = useState<ThemeMode>(getThemeMode); const [themeMode, setThemeMode] = useState<ThemeMode>(getThemeMode);
const { urgences, demandesATraiter } = useSignauxExploitation(
can('WORK_ORDERS', 'view'),
can('REQUESTS', 'view'),
);
if (!getToken()) return <Navigate to="/connexion" replace />; if (!getToken()) return <Navigate to="/connexion" replace />;
const badges: Record<'urgences' | 'demandes', number> = {
urgences: urgences.length,
demandes: demandesATraiter,
};
const cyclerTheme = () => { const cyclerTheme = () => {
const mode = nextThemeMode(themeMode); const mode = nextThemeMode(themeMode);
applyThemeMode(mode); applyThemeMode(mode);
@@ -103,16 +149,21 @@ export function Coquille({ children }: { children: ReactNode }) {
{NAVIGATION.map(({ groupe, liens }) => ( {NAVIGATION.map(({ groupe, liens }) => (
<nav key={groupe} aria-label={groupe}> <nav key={groupe} aria-label={groupe}>
<div className="groupe">{groupe}</div> <div className="groupe">{groupe}</div>
{liens.map(({ libelle, icone: Icone, route, release }) => {liens
.filter(({ permission }) => !permission || can(...permission))
.map(({ libelle, icone: Icone, route, release, badge }) =>
route ? ( route ? (
<NavLink <NavLink
key={libelle} key={libelle}
to={route} to={route}
end end={route === '/'}
className={({ isActive }) => (isActive ? 'actif' : undefined)} className={({ isActive }) => (isActive ? 'actif' : undefined)}
> >
<Icone /> <Icone />
{libelle} {libelle}
{badge && badges[badge] > 0 ? (
<span className="badge-nav num">{badges[badge]}</span>
) : null}
</NavLink> </NavLink>
) : ( ) : (
<a <a
@@ -138,6 +189,19 @@ export function Coquille({ children }: { children: ReactNode }) {
<IcoRecherche /> <IcoRecherche />
Rechercher un OT, un ascenseur, un site&ensp;K Rechercher un OT, un ascenseur, un site&ensp;K
</div> </div>
{urgences.length > 0 ? (
<button
type="button"
className="chip-urgence"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/ot/${urgences[0]!.id}`)}
>
<span className="point-pulse" />
{urgences.length === 1
? '1 personne bloquée'
: `${urgences.length} personnes bloquées`}
</button>
) : null}
<button <button
type="button" type="button"
className="btn ghost" className="btn ghost"

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { construireArbre } from './lieux';
import type { LocationDto } from '@siop/shared';
const loc = (over: Partial<LocationDto>): LocationDto => ({
id: crypto.randomUUID(),
name: 'X',
parentId: null,
address: null,
city: null,
guardianName: null,
guardianPhone: null,
latitude: null,
longitude: null,
assetCount: 0,
...over,
});
describe('construireArbre (liste plate → site → zones)', () => {
it('rattache les zones à leur site et trie par nombre dappareils', () => {
const atlas = loc({ name: 'Tour Atlas', assetCount: 4 });
const manar = loc({ name: 'Al Manar', assetCount: 1 });
const hall = loc({ name: 'Hall', parentId: atlas.id, assetCount: 2 });
const arbre = construireArbre([manar, atlas, hall]);
expect(arbre.map((s) => s.name)).toEqual(['Tour Atlas', 'Al Manar']);
expect(arbre[0]!.zones.map((z) => z.name)).toEqual(['Hall']);
expect(arbre[1]!.zones).toHaveLength(0);
});
});

21
apps/web/src/lib/lieux.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { LocationDto } from '@siop/shared';
export interface SiteAvecZones extends LocationDto {
zones: LocationDto[];
}
/** L'API renvoie une liste plate ; l'arbre (site → zones) se construit ici. */
export function construireArbre(locations: LocationDto[]): SiteAvecZones[] {
const sites = locations
.filter((l) => l.parentId === null)
.map((site) => ({
...site,
zones: locations.filter((l) => l.parentId === site.id),
}));
return sites.sort((a, b) => b.assetCount - a.assetCount || a.name.localeCompare(b.name));
}
export function formatDateFr(iso: string | null): string {
if (!iso) return '—';
return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(iso));
}

View File

@@ -0,0 +1,96 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api, setToken } from '@/api/client';
import { Logo } from '@/components/logo';
import { Button } from '@/components/ui/button';
/** Cible du lien d'invitation (R1) : la personne choisit son mot de passe et
* entre directement. Même moule visuel que la connexion (écran validé R0). */
export default function PageActivation() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [params] = useSearchParams();
const token = params.get('token') ?? '';
const [password, setPassword] = useState('');
const [confirmation, setConfirmation] = useState('');
const activation = useMutation({
mutationFn: async () => {
const { data, error } = await api.POST('/auth/activate', {
body: { token, password },
});
if (error || !data) {
throw new Error('Ce lien est invalide ou a expiré — demandez un nouveau lien.');
}
return data;
},
onSuccess: (data) => {
setToken(data.accessToken);
queryClient.clear();
navigate('/', { replace: true });
},
});
const surEnvoi = (e: FormEvent) => {
e.preventDefault();
activation.mutate();
};
const differents = confirmation.length > 0 && password !== confirmation;
return (
<div className="conn">
<div className="carte-conn">
<Logo sousTitre="Activation de votre compte" />
{token ? (
<form onSubmit={surEnvoi} className="flex flex-col gap-[14px]" aria-label="Activation">
<p className="text-[13px] text-encre-2">
Bienvenue ! Choisissez votre mot de passe pour activer votre compte.
</p>
<div className="champ">
<label htmlFor="act-mdp">Mot de passe (8 caractères minimum)</label>
<input
id="act-mdp"
type="password"
autoComplete="new-password"
minLength={8}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="champ">
<label htmlFor="act-conf">Confirmez le mot de passe</label>
<input
id="act-conf"
type="password"
autoComplete="new-password"
required
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
/>
</div>
{differents ? (
<p className="erreur-form">Les deux saisies ne correspondent pas.</p>
) : null}
{activation.isError ? (
<p className="erreur-form" role="alert">{activation.error.message}</p>
) : null}
<Button
type="submit"
variant="prim"
className="justify-center"
disabled={activation.isPending || differents || password.length < 8}
>
Activer mon compte
</Button>
</form>
) : (
<p className="erreur-form" role="alert">
Lien incomplet utilisez le lien d'activation transmis par votre administrateur.
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
import { useAssets } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { StatutEquipement } from '@/components/statut-equipement';
import { Button } from '@/components/ui/button';
/** Écran 3 des maquettes R1 : inventaire dense du parc. */
export default function PageAscenseurs() {
const navigate = useNavigate();
const { data: assets } = useAssets();
const { can } = usePermissions();
const [site, setSite] = useState('');
const [statut, setStatut] = useState<'' | AssetStatus>('');
const tous = assets ?? [];
const sites = [...new Set(tous.map((a) => a.siteName))].sort();
const visibles = tous.filter(
(a) => (!site || a.siteName === site) && (!statut || a.status === statut),
);
const arret = tous.filter((a) => a.status === 'OUT_OF_SERVICE').length;
return (
<>
<div className="entete-page">
<h1>Ascenseurs</h1>
<span className="filajout">
{tous.length} appareils{arret ? ` · ${arret} à larrêt` : ''}
</span>
<div className="actions">
{can('ASSETS', 'create') ? (
<Button variant="prim" onClick={() => navigate('/ascenseurs/nouveau')}>
+ Nouvel ascenseur
</Button>
) : null}
</div>
</div>
<div className="filtres">
<select aria-label="Filtrer par site" value={site} onChange={(e) => setSite(e.target.value)}>
<option value="">Site : tous</option>
{sites.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<select
aria-label="Filtrer par statut"
value={statut}
onChange={(e) => setStatut(e.target.value as '' | AssetStatus)}
>
<option value="">Statut : tous</option>
{ASSET_STATUSES.map((s) => (
<option key={s} value={s}>{ASSET_STATUS_LABELS[s]}</option>
))}
</select>
</div>
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>Appareil</th>
<th>Marque · modèle</th>
<th>Site / emplacement</th>
<th>Catégorie</th>
<th>Statut</th>
<th>Organes</th>
</tr>
</thead>
<tbody>
{visibles.map((a) => (
<tr
key={a.id}
className={a.status === 'OUT_OF_SERVICE' ? 'ligne-bloque' : undefined}
onClick={() => navigate(`/ascenseurs/${a.id}`)}
style={{ cursor: 'pointer' }}
>
<td className="ref num">{a.reference}</td>
<td>{a.brand} {a.model ?? ''}</td>
<td>
{a.siteName}
{a.locationName !== a.siteName ? (
<span className="sous">{a.locationName}</span>
) : null}
</td>
<td>{a.categoryName}</td>
<td><StatutEquipement statut={a.status} /></td>
<td className="num">{a.componentCount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,136 @@
import { useState, type FormEvent } from 'react';
import { CATEGORY_KINDS, type Category, type CategoryKind } from '@siop/shared';
import { useCategories, useCreateCategory, useUpdateCategory } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { Button } from '@/components/ui/button';
const TITRES: Record<CategoryKind, string> = {
EQUIPMENT: 'Catégories déquipement',
COMPONENT_TYPE: 'Types dorganes',
};
/** Écran 7 des maquettes R1 : référentiels administrables — renommer,
* ajouter, (dés)activer. Jamais de suppression si utilisé. */
export default function PageCategories() {
return (
<>
<div className="entete-page"><h1>Catégories</h1></div>
<div className="grille-2">
{CATEGORY_KINDS.map((kind) => (
<ListeCategories key={kind} kind={kind} />
))}
</div>
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
Une catégorie <b>utilisée</b> ne peut pas être supprimée seulement renommée ou
désactivée (elle disparaît des sélecteurs, l'historique reste lisible).
</div>
</>
);
}
function ListeCategories({ kind }: { kind: CategoryKind }) {
const { data: categories } = useCategories();
const { can } = usePermissions();
const creation = useCreateCategory();
const [nom, setNom] = useState('');
const liste = (categories ?? []).filter((c) => c.kind === kind);
const peutEditer = can('SETTINGS', 'edit');
const surAjout = (e: FormEvent) => {
e.preventDefault();
if (!nom.trim()) return;
creation.mutate({ kind, name: nom.trim() }, { onSuccess: () => setNom('') });
};
return (
<div className="carte">
<h2>{TITRES[kind]}</h2>
{liste.map((categorie) => (
<LigneCategorie key={categorie.id} categorie={categorie} peutEditer={peutEditer} />
))}
{can('SETTINGS', 'create') ? (
<form className="ligne-organe" style={{ border: 0, marginTop: 6 }} onSubmit={surAjout}>
<input
aria-label={`Nouvelle entrée — ${TITRES[kind]}`}
style={{ flex: 1 }}
placeholder={kind === 'EQUIPMENT' ? 'Nouvelle catégorie' : 'Nouveau type'}
value={nom}
onChange={(e) => setNom(e.target.value)}
/>
<Button type="submit" variant="prim" disabled={creation.isPending}>
Ajouter
</Button>
</form>
) : null}
{creation.isError ? (
<p className="erreur-form" role="alert">{creation.error.message}</p>
) : null}
</div>
);
}
function LigneCategorie({
categorie,
peutEditer,
}: {
categorie: Category;
peutEditer: boolean;
}) {
const maj = useUpdateCategory();
const [enEdition, setEnEdition] = useState(false);
const [nom, setNom] = useState(categorie.name);
const renommer = (e: FormEvent) => {
e.preventDefault();
maj.mutate({ id: categorie.id, name: nom.trim() }, { onSuccess: () => setEnEdition(false) });
};
if (enEdition) {
return (
<form className="ligne-organe" onSubmit={renommer}>
<input
aria-label={`Renommer ${categorie.name}`}
style={{ flex: 1 }}
value={nom}
onChange={(e) => setNom(e.target.value)}
autoFocus
/>
<Button type="submit" variant="prim" disabled={maj.isPending}>OK</Button>
<Button onClick={() => setEnEdition(false)}>Annuler</Button>
</form>
);
}
return (
<div className="item-liste" style={!categorie.isActive ? { color: 'var(--encre-3)' } : undefined}>
{categorie.name}
{!categorie.isActive ? <span style={{ fontSize: 11 }}> (désactivée)</span> : null}
<span className="fin">
<span className="num">
{categorie.usageCount} {categorie.kind === 'EQUIPMENT' ? 'appareils' : ''}
</span>
{peutEditer ? (
<>
·{' '}
<button
type="button"
className="border-0 bg-transparent font-semibold text-primaire"
onClick={() => setEnEdition(true)}
>
Renommer
</button>
·{' '}
<button
type="button"
className="border-0 bg-transparent font-semibold text-primaire"
onClick={() => maj.mutate({ id: categorie.id, isActive: !categorie.isActive })}
>
{categorie.isActive ? 'Désactiver' : 'Réactiver'}
</button>
</>
) : null}
</span>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { METER_KIND_LABELS, type MeterDto, type MeterKind } from '@siop/shared';
import { useAddMeterReading, useMeters } from '@/api/exploitation';
import { useAsset } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { Button } from '@/components/ui/button';
const UNITES: Record<MeterKind, string> = { RUNNING_HOURS: 'h', STARTS: 'cycles' };
const fmt = new Intl.NumberFormat('fr-FR');
/** Écran 5 des maquettes R2 : relevés simples du carnet, strictement croissants. */
export default function PageCompteurs() {
const { id } = useParams<{ id: string }>();
const { data: asset } = useAsset(id);
const { data: meters } = useMeters(id);
if (!asset) return null;
return (
<>
<div className="filajout">
<Link to="/ascenseurs">Ascenseurs</Link> /{' '}
<Link to={`/ascenseurs/${asset.id}`}>Ascenseur {asset.reference}</Link> / Compteurs
</div>
<div className="entete-page"><h1>Compteurs Ascenseur {asset.reference}</h1></div>
<div className="grille-2">
{(meters ?? []).map((meter) => (
<CarteCompteur key={meter.kind} assetId={asset.id} meter={meter} />
))}
</div>
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
Un relevé <b>inférieur au précédent</b> est refusé (un compteur ne redescend pas).
Les seuils d'alerte (préventif conditionnel) sont au backlog — R2 stocke et trace.
</div>
</>
);
}
function CarteCompteur({ assetId, meter }: { assetId: string; meter: MeterDto }) {
const { can } = usePermissions();
const ajout = useAddMeterReading(assetId);
const [valeur, setValeur] = useState('');
return (
<div className="carte">
<h2>{METER_KIND_LABELS[meter.kind]}</h2>
{can('METERS', 'create') ? (
<form
className="releve-saisie"
onSubmit={(e) => {
e.preventDefault();
const v = Number(valeur.replace(/\s/g, ''));
if (!Number.isFinite(v)) return;
ajout.mutate({ kind: meter.kind, value: v }, { onSuccess: () => setValeur('') });
}}
>
<input
aria-label={`Nouveau relevé — ${METER_KIND_LABELS[meter.kind]}`}
className="num"
inputMode="numeric"
placeholder={meter.readings[0] ? fmt.format(meter.readings[0].value) : '0'}
value={valeur}
onChange={(e) => setValeur(e.target.value)}
/>
<span style={{ color: 'var(--encre-2)', fontSize: 13 }}>{UNITES[meter.kind]}</span>
<Button type="submit" variant="prim" disabled={ajout.isPending}>
Enregistrer le relevé
</Button>
</form>
) : null}
{ajout.isError ? <p className="erreur-form mt-2">{ajout.error.message}</p> : null}
<ul className="chrono" style={{ marginTop: 14 }}>
{meter.readings.map((r) => (
<li key={r.id} className="fait">
<span className="num">{fmt.format(r.value)} {UNITES[meter.kind]}</span>
<br />
<span className="quand">
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(r.createdAt))}
{r.readBy ? ` · ${r.readBy.displayName}` : ''}
</span>
</li>
))}
{meter.readings.length === 0 ? (
<li className="text-encre-3" style={{ padding: 0 }}>Aucun relevé pour l'instant.</li>
) : null}
</ul>
</div>
);
}

View File

@@ -0,0 +1,301 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
WORK_ORDER_PRIORITY_LABELS,
type RequestSummary,
} from '@siop/shared';
import {
useApproveRequest,
useCreateRequest,
useRejectRequest,
useRequests,
} from '@/api/exploitation';
import { useAssetOptions, useUsers } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { StatutDemande } from '@/components/chips-ot';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
/** Écran 1 des maquettes R2 : la file d'attente + le panneau d'approbation.
* Une demande approuvée devient UN OT (1-1) ; un rejet exige un motif. */
export default function PageDemandes() {
const { data: requests } = useRequests();
const { can } = usePermissions();
const [selectionnee, setSelectionnee] = useState<string | null>(null);
const [modaleSignalement, setModaleSignalement] = useState(false);
const toutes = requests ?? [];
const aTraiter = toutes.filter((r) => r.status === 'RECEIVED').length;
const demande = toutes.find((r) => r.id === selectionnee) ??
toutes.find((r) => r.status === 'RECEIVED') ?? null;
const peutTraiter = can('REQUESTS', 'edit');
return (
<>
<div className="entete-page">
<h1>Demandes</h1>
<span className="filajout">
{aTraiter} à traiter · {toutes.length} au total
</span>
<div className="actions">
{can('REQUESTS', 'create') ? (
<Button variant="prim" onClick={() => setModaleSignalement(true)}>
+ Signaler
</Button>
) : null}
</div>
</div>
<div className="grille-2" style={{ gridTemplateColumns: peutTraiter ? '1.4fr 1fr' : '1fr' }}>
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>N°</th>
<th>Signalement</th>
<th>Équipement · Site</th>
<th>Par</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
{toutes.map((r) => (
<tr
key={r.id}
className={r.isPersonTrapped && r.status === 'RECEIVED' ? 'ligne-bloque' : undefined}
style={{
cursor: peutTraiter ? 'pointer' : undefined,
background: r.id === demande?.id && peutTraiter ? 'var(--primaire-doux)' : undefined,
}}
onClick={() => setSelectionnee(r.id)}
>
<td className="ref num">{r.reference}</td>
<td>{r.isPersonTrapped ? <b>{r.description}</b> : r.description}</td>
<td>
Asc. {r.assetReference}
<span className="sous">{r.siteName}</span>
</td>
<td>{r.requesterLabel}</td>
<td>
{r.workOrder ? (
<Link to={`/ot/${r.workOrder.id}`} onClick={(e) => e.stopPropagation()}>
<StatutDemande statut={r.status} otStatut={r.workOrder.status} />
</Link>
) : (
<StatutDemande statut={r.status} />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{peutTraiter && demande ? <PanneauApprobation demande={demande} /> : null}
</div>
<ModaleSignalement ouverte={modaleSignalement} surFermeture={setModaleSignalement} />
</>
);
}
function PanneauApprobation({ demande }: { demande: RequestSummary }) {
const navigate = useNavigate();
const { data: users } = useUsers();
const approbation = useApproveRequest();
const rejet = useRejectRequest();
const [motif, setMotif] = useState<string | null>(null); // null = fermé
const [priorite, setPriorite] = useState('HIGH');
const [assigne, setAssigne] = useState('');
const techniciens = (users ?? []).filter(
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
);
const traitee = demande.status !== 'RECEIVED';
return (
<div className="panneau">
<h2 style={{ fontSize: 13, fontWeight: 700, letterSpacing: '.04em', textTransform: 'uppercase', color: 'var(--encre-2)' }}>
{demande.reference} {traitee ? 'déjà traitée' : 'approbation'}
</h2>
<dl className="infos" style={{ gridTemplateColumns: '110px 1fr' }}>
<dt>Équipement</dt>
<dd>
<Link to={`/ascenseurs/${demande.assetId}`} style={{ color: 'var(--primaire)', fontWeight: 600 }}>
Ascenseur {demande.assetReference}
</Link>{' '}
{demande.siteName}
</dd>
<dt>Signalé par</dt>
<dd>{demande.requesterLabel}</dd>
<dt>Description</dt>
<dd>« {demande.description} »</dd>
{demande.rejectionReason ? (
<>
<dt>Motif du rejet</dt>
<dd>{demande.rejectionReason}</dd>
</>
) : null}
</dl>
{!traitee ? (
<>
<div className="champ">
<label htmlFor="app-prio">Priorité de l'OT</label>
<select
id="app-prio"
value={demande.isPersonTrapped ? 'PERSON_TRAPPED' : priorite}
disabled={demande.isPersonTrapped}
onChange={(e) => setPriorite(e.target.value)}
>
{(['LOW', 'MEDIUM', 'HIGH', 'PERSON_TRAPPED'] as const).map((p) => (
<option key={p} value={p}>{WORK_ORDER_PRIORITY_LABELS[p]}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="app-assigne">Assigner à</label>
<select id="app-assigne" value={assigne} onChange={(e) => setAssigne(e.target.value)}>
<option value="">— plus tard —</option>
{techniciens.map((t) => (
<option key={t.id} value={t.id}>
{t.displayName}
{t.teams.length ? ` — équipe ${t.teams[0]!.name}` : ''}
</option>
))}
</select>
</div>
{(approbation.isError || rejet.isError) ? (
<p className="erreur-form" role="alert">
{approbation.error?.message ?? rejet.error?.message}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button variant="dangerLeger" onClick={() => setMotif('')}>
Rejeter (motif requis)
</Button>
<Button
variant="prim"
disabled={approbation.isPending}
onClick={() =>
approbation.mutate(
{
id: demande.id,
priority: (demande.isPersonTrapped ? 'PERSON_TRAPPED' : priorite) as never,
assigneeIds: assigne ? [assigne] : undefined,
},
{ onSuccess: (ot) => navigate(`/ot/${ot.id}`) },
)
}
>
Approuver → créer l'OT
</Button>
</div>
<p style={{ fontSize: 12, color: 'var(--encre-3)' }}>
L'OT reprend équipement et description — la demande suit ensuite l'OT
(Reçu Intervention Résolu côté demandeur).
</p>
</>
) : null}
<Modale titre="Rejeter la demande" ouverte={motif !== null} surFermeture={() => setMotif(null)}>
<form
onSubmit={(e: FormEvent) => {
e.preventDefault();
rejet.mutate({ id: demande.id, reason: motif ?? '' }, { onSuccess: () => setMotif(null) });
}}
className="flex flex-col gap-3"
>
<div className="champ">
<label htmlFor="rej-motif">Motif (lisible par le demandeur, sans jargon) *</label>
<input
id="rej-motif"
required
minLength={3}
value={motif ?? ''}
onChange={(e) => setMotif(e.target.value)}
placeholder="Ex. : fausse alerte confirmée sur place"
/>
</div>
{rejet.isError ? <p className="erreur-form">{rejet.error.message}</p> : null}
<div className="pied">
<Button onClick={() => setMotif(null)}>Annuler</Button>
<Button type="submit" variant="dangerLeger" disabled={rejet.isPending}>
Rejeter la demande
</Button>
</div>
</form>
</Modale>
</div>
);
}
/** Signalement interne (le portail public QR arrive en R2.4). */
function ModaleSignalement({
ouverte,
surFermeture,
}: {
ouverte: boolean;
surFermeture: (o: boolean) => void;
}) {
const { data: options } = useAssetOptions();
const creation = useCreateRequest();
const [personneBloquee, setPersonneBloquee] = useState(false);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const donnees = new FormData(e.currentTarget);
creation.mutate(
{
assetId: String(donnees.get('assetId')),
description: String(donnees.get('description')),
isPersonTrapped: personneBloquee,
},
{
onSuccess: () => {
surFermeture(false);
setPersonneBloquee(false);
},
},
);
};
return (
<Modale titre="Signaler un problème" ouverte={ouverte} surFermeture={surFermeture}>
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
<div className="champ">
<label htmlFor="sig-asset">Équipement *</label>
<select id="sig-asset" name="assetId" required defaultValue="">
<option value="" disabled>Choisir</option>
{(options ?? []).map((a) => (
<option key={a.id} value={a.id}>{a.reference} {a.siteName}</option>
))}
</select>
</div>
<div className="gros-toggle">
<b>Une personne est-elle bloquée ?</b>
<button
type="button"
className="interrupteur"
role="switch"
aria-checked={personneBloquee}
aria-label="Personne bloquée"
onClick={() => setPersonneBloquee((v) => !v)}
/>
</div>
<div className="champ">
<label htmlFor="sig-desc">Décrivez le problème *</label>
<input
id="sig-desc"
name="description"
required
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
/>
</div>
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
<div className="pied">
<Button onClick={() => surFermeture(false)}>Annuler</Button>
<Button type="submit" variant="prim" disabled={creation.isPending}>
Envoyer le signalement
</Button>
</div>
</form>
</Modale>
);
}

View File

@@ -0,0 +1,76 @@
import { QRCodeSVG } from 'qrcode.react';
import { Link, useParams } from 'react-router-dom';
import { useAsset } from '@/api/referentiel';
import { Button } from '@/components/ui/button';
/** Écran 5 des maquettes R1 : l'étiquette A6 imprimable — objet PAPIER
* (noir sur blanc, même en thème sombre). window.print n'imprime qu'elle. */
export default function PageEtiquette() {
const { id } = useParams<{ id: string }>();
const { data: asset } = useAsset(id);
if (!asset) return null;
const url = `${window.location.origin}/q/${asset.reference}`;
return (
<>
<div className="filajout">
<Link to="/ascenseurs">Ascenseurs</Link> /{' '}
<Link to={`/ascenseurs/${asset.id}`}>Ascenseur {asset.reference}</Link> / Étiquette QR
</div>
<div className="entete-page">
<h1>Étiquette cabine Ascenseur {asset.reference}</h1>
<div className="actions">
<Button variant="prim" onClick={() => window.print()}>
Imprimer (A6)
</Button>
</div>
</div>
<div className="zone-impression">
<div className="etiquette">
<div className="tete">
<span className="marque" aria-hidden="true"><br /></span>
<b>SIOP</b>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#55647a' }}>
Maintenance d'ascenseurs
<br />
SPELEV
</span>
</div>
<div className="corps">
<QRCodeSVG className="qr-grand" value={url} size={120} marginSize={1} />
<div>
<h3>Ascenseur {asset.reference}</h3>
<div className="ou">
{asset.siteName}
<br />
{asset.locationName !== asset.siteName ? `${asset.locationName} — ` : ''}
{asset.brand} {asset.model ?? ''}
</div>
</div>
</div>
<div className="consigne">
Un problème ? Une personne bloquée ?
<br />
📱 Scannez ce code pour signaler
</div>
<div className="ref">
SIOP · {asset.reference}
{asset.serialNumber ? ` · ${asset.serialNumber}` : ''} · {url.replace(/^https?:\/\//, '')}
</div>
</div>
<div className="carte" style={{ flex: 1, minWidth: 260 }}>
<h2>Ce que fait le scan</h2>
<dl className="infos" style={{ gridTemplateColumns: '96px 1fr' }}>
<dt>Gardien</dt>
<dd>ouvrira le <b>portail de signalement</b>, équipement prérempli (R2)</dd>
<dt>Technicien</dt>
<dd>connecté → ouvre la <b>fiche de l'appareil</b></dd>
<dt>Impression</dt>
<dd>A6 autocollant, noir sur blanc</dd>
</dl>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,233 @@
import { QRCodeSVG } from 'qrcode.react';
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
import { useWorkOrders } from '@/api/exploitation';
import {
useAddComponent,
useAsset,
useCategories,
useRemoveComponent,
useUpdateAsset,
} from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { StatutOT } from '@/components/chips-ot';
import { StatutEquipement } from '@/components/statut-equipement';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
import { formatDateFr } from '@/lib/lieux';
/** Fiche appareil — écran validé en R0 (maquette-web, écran 5) ; historique
* (R2) et documents (R3) sont annoncés, pas simulés. */
export default function PageFicheAscenseur() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: asset } = useAsset(id);
const { can } = usePermissions();
const [modaleOrgane, setModaleOrgane] = useState(false);
const retrait = useRemoveComponent(id ?? '');
const majStatut = useUpdateAsset(id ?? '');
if (!asset) return null;
return (
<>
<div className="filajout">
<Link to="/ascenseurs">Ascenseurs</Link> / Ascenseur {asset.reference}
</div>
<div className="entete-page">
<h1>
Ascenseur {asset.reference} {asset.brand} {asset.model ?? ''}
</h1>
<StatutEquipement statut={asset.status} />
<div className="actions">
{can('ASSETS', 'edit') ? (
<select
aria-label="Changer le statut"
className="btn"
value={asset.status}
onChange={(e) => majStatut.mutate({ status: e.target.value as AssetStatus })}
>
{ASSET_STATUSES.map((s) => (
<option key={s} value={s}>{ASSET_STATUS_LABELS[s]}</option>
))}
</select>
) : null}
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/compteurs`)}>
Compteurs
</Button>
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/etiquette`)}>
Étiquette QR
</Button>
</div>
</div>
<div className="grille-2">
<div className="carte">
<h2>Identité</h2>
<dl className="infos">
<dt>N° de série</dt>
<dd className="num">{asset.serialNumber ?? '—'}</dd>
<dt>Marque · modèle</dt>
<dd>{asset.brand} {asset.model ?? '—'}</dd>
<dt>Mise en service</dt>
<dd className="num">{formatDateFr(asset.commissionedAt)}</dd>
<dt>Site</dt>
<dd>
{asset.siteName}
{asset.locationName !== asset.siteName ? `${asset.locationName}` : ''}
</dd>
<dt>Catégorie</dt>
<dd>{asset.categoryName}</dd>
<dt>Charge · niveaux</dt>
<dd className="num">
{asset.loadKg ? `${asset.loadKg} kg` : '—'} · {asset.floors ?? '—'} niveaux
</dd>
</dl>
</div>
<div className="carte">
<h2>Étiquette cabine</h2>
<div className="flex items-center gap-4">
<QRCodeSVG
value={`${window.location.origin}/q/${asset.reference}`}
size={86}
marginSize={2}
className="rounded-lg border border-bordure bg-white p-1"
/>
<div className="text-[12.5px] text-encre-2">
Collée en cabine.<br />
Scan gardien signalement prérempli (R2).<br />
Scan technicien cette fiche.
</div>
</div>
</div>
</div>
<div className="grille-2">
<div className="carte">
<h2>Organes</h2>
{asset.components.map((c) => (
<div className="item-liste" key={c.id}>
<div>
<b>{c.typeName}</b>
{c.designation ? <span className="sous">{c.designation}</span> : null}
</div>
<span className="fin">
{can('ASSETS', 'edit') ? (
<button
type="button"
className="btn-suppr"
aria-label={`Retirer ${c.typeName}`}
onClick={() => retrait.mutate(c.id)}
>
</button>
) : null}
</span>
</div>
))}
{asset.components.length === 0 ? (
<p className="text-encre-3">Aucun organe déclaré.</p>
) : null}
{can('ASSETS', 'edit') ? (
<Button variant="ghost" className="mt-2" onClick={() => setModaleOrgane(true)}>
+ Ajouter un organe
</Button>
) : null}
</div>
<div className="flex flex-col gap-4">
<CarteHistorique assetId={asset.id} />
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Documents</h2>
<p className="text-encre-3">
Notices et certificats rejoindront la bibliothèque en R3.
</p>
</div>
</div>
</div>
<NouvelOrgane assetId={asset.id} ouverte={modaleOrgane} surFermeture={setModaleOrgane} />
</>
);
}
/** Historique récent (écran 5 validé R0) — les OT de CET appareil. */
function CarteHistorique({ assetId }: { assetId: string }) {
const { can } = usePermissions();
const { data: workOrders } = useWorkOrders(can('WORK_ORDERS', 'view'));
const historique = (workOrders ?? []).filter((w) => w.assetId === assetId).slice(0, 4);
if (!can('WORK_ORDERS', 'view')) return null;
return (
<div className="carte">
<h2>Historique récent</h2>
{historique.length ? (
<ul className="chrono">
{historique.map((w) => (
<li key={w.id} className="fait">
<Link to={`/ot/${w.id}`} className="num" style={{ color: 'var(--primaire)', fontWeight: 600 }}>
{w.reference}
</Link>{' '}
· {w.title} <StatutOT statut={w.status} />
<br />
<span className="quand">
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(w.createdAt))}
</span>
</li>
))}
</ul>
) : (
<p className="text-encre-3">Aucune intervention enregistrée.</p>
)}
</div>
);
}
function NouvelOrgane({
assetId,
ouverte,
surFermeture,
}: {
assetId: string;
ouverte: boolean;
surFermeture: (o: boolean) => void;
}) {
const { data: categories } = useCategories();
const ajout = useAddComponent(assetId);
const types = (categories ?? []).filter((c) => c.kind === 'COMPONENT_TYPE' && c.isActive);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const donnees = new FormData(e.currentTarget);
ajout.mutate(
{
typeId: String(donnees.get('typeId')),
designation: String(donnees.get('designation')) || undefined,
},
{ onSuccess: () => surFermeture(false) },
);
};
return (
<Modale titre="Ajouter un organe" ouverte={ouverte} surFermeture={surFermeture}>
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
<div className="champ">
<label htmlFor="no-type">Type dorgane *</label>
<select id="no-type" name="typeId" required defaultValue="">
<option value="" disabled>Choisir</option>
{types.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="no-designation">Désignation</label>
<input id="no-designation" name="designation" placeholder="Ex. : Fermator 40/10" />
</div>
{ajout.isError ? (
<p className="erreur-form" role="alert">{ajout.error.message}</p>
) : null}
<div className="pied">
<Button onClick={() => surFermeture(false)}>Annuler</Button>
<Button type="submit" variant="prim" disabled={ajout.isPending}>Ajouter</Button>
</div>
</form>
</Modale>
);
}

View File

@@ -0,0 +1,354 @@
import { useState, type FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
import {
BILAN_FIELD_LABELS,
REQUIRED_BILAN_FIELDS,
WORK_ORDER_TYPE_LABELS,
type BilanField,
type WorkOrderDetail,
type WorkOrderStatus,
} from '@siop/shared';
import {
useCommentWorkOrder,
usePatchChecklist,
useReferenceValues,
useSetAssignees,
useTransitionWorkOrder,
useUpsertReport,
useWorkOrder,
} from '@/api/exploitation';
import { useUsers } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar';
import { PrioriteOT, StatutOT } from '@/components/chips-ot';
import { Button } from '@/components/ui/button';
import { formatDateFr } from '@/lib/lieux';
const BOUTONS_TRANSITION: Record<WorkOrderStatus, { libelle: string; variant?: 'prim' | 'succes' | 'dangerLeger' | 'ghost' }> = {
IN_PROGRESS: { libelle: 'Démarrer', variant: 'prim' },
ON_HOLD: { libelle: 'En attente', variant: 'ghost' },
DONE: { libelle: "Clôturer l'intervention", variant: 'succes' },
CANCELLED: { libelle: 'Annuler', variant: 'dangerLeger' },
OPEN: { libelle: 'Rouvrir' },
};
/** Écran 4 validé R0 (+ checklist maquette R2) : tout le carnet sur un écran. */
export default function PageFicheOT() {
const { id } = useParams<{ id: string }>();
const { data: ot } = useWorkOrder(id);
const { can } = usePermissions();
const transition = useTransitionWorkOrder(id ?? '');
if (!ot) return null;
const peutEditer = can('WORK_ORDERS', 'edit');
const cloturable = ot.closureBlockers.length === 0;
return (
<>
<div className="filajout">
<Link to="/ot">Ordres de travail</Link> / {ot.reference}
</div>
<div className="entete-page">
<h1>{ot.title}</h1>
<StatutOT statut={ot.status} />
<div className="actions">
{peutEditer
? ot.allowedTransitions.map((cible) => {
const bouton = BOUTONS_TRANSITION[cible];
const bloquee = cible === 'DONE' && !cloturable;
return (
<Button
key={cible}
variant={bouton.variant}
disabled={transition.isPending || bloquee}
title={bloquee ? ot.closureBlockers.join(' ; ') : undefined}
onClick={() => {
const commentaire =
cible === 'CANCELLED'
? window.prompt('Motif dannulation (visible dans lactivité) :') ?? undefined
: undefined;
if (cible === 'CANCELLED' && !commentaire) return;
transition.mutate({ to: cible, comment: commentaire });
}}
>
{bouton.libelle}
</Button>
);
})
: null}
</div>
</div>
{transition.isError ? (
<p className="erreur-form" role="alert">{transition.error.message}</p>
) : null}
<div className="grille-2">
<CarteIntervention ot={ot} peutEditer={peutEditer} />
<CarteActivite ot={ot} peutEditer={peutEditer} />
</div>
<div className="grille-2">
<div className="flex flex-col gap-4">
{ot.checklist.length > 0 ? <CarteChecklist ot={ot} peutEditer={peutEditer} /> : null}
<CarteBilan ot={ot} peutEditer={peutEditer} />
</div>
<div className="flex flex-col gap-4">
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Pièces & main-d'œuvre</h2>
<p className="text-encre-3">Consommations et coûts arrivent avec la gestion (R3).</p>
</div>
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Documents</h2>
<p className="text-encre-3">Photos et pièces jointes arrivent avec la bibliothèque (R3).</p>
</div>
</div>
</div>
</>
);
}
function CarteIntervention({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
const { data: users } = useUsers();
const assignation = useSetAssignees(ot.id);
const [enAssignation, setEnAssignation] = useState(false);
const techniciens = (users ?? []).filter(
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
);
return (
<div className="carte">
<h2>Intervention</h2>
<dl className="infos">
<dt>N°</dt>
<dd className="num">{ot.reference} · {WORK_ORDER_TYPE_LABELS[ot.type]}</dd>
<dt>Équipement</dt>
<dd>
<Link to={`/ascenseurs/${ot.assetId}`} style={{ color: 'var(--primaire)', fontWeight: 600 }}>
Ascenseur {ot.assetReference}
</Link>{' '}
— {ot.siteName}
</dd>
<dt>Priorité</dt>
<dd><PrioriteOT priorite={ot.priority} /></dd>
<dt>Échéance</dt>
<dd className="num">{formatDateFr(ot.dueDate)}</dd>
<dt>Assignés</dt>
<dd className="flex items-center gap-2">
{ot.assignees.length ? (
<>
<span className="avatars">
{ot.assignees.map((a) => (
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
))}
</span>
{ot.assignees.map((a) => a.displayName).join(', ')}
</>
) : (
''
)}
{peutEditer ? (
<Button
variant="ghost"
className="px-2 py-1 text-[12px]"
onClick={() => setEnAssignation((v) => !v)}
>
+ Assigner
</Button>
) : null}
</dd>
{ot.request ? (
<>
<dt>Demande liée</dt>
<dd>
<span className="num">{ot.request.reference}</span> — {ot.request.requesterLabel}
</dd>
</>
) : null}
</dl>
{enAssignation ? (
<div className="mt-3 flex items-center gap-2">
<select
aria-label="Choisir un technicien"
className="flex-1 rounded-lg border border-bordure-forte bg-surface p-2 text-encre"
defaultValue=""
onChange={(e) => {
if (!e.target.value) return;
assignation.mutate(
{ assigneeIds: [...new Set([...ot.assignees.map((a) => a.id), e.target.value])] },
{ onSuccess: () => setEnAssignation(false) },
);
}}
>
<option value="" disabled>Choisir un technicien…</option>
{techniciens.map((t) => (
<option key={t.id} value={t.id}>{t.displayName}</option>
))}
</select>
{ot.assignees.length ? (
<Button
variant="ghost"
className="text-[12px]"
onClick={() =>
assignation.mutate({ assigneeIds: [] }, { onSuccess: () => setEnAssignation(false) })
}
>
Tout retirer
</Button>
) : null}
</div>
) : null}
</div>
);
}
const KIND_LABELS: Record<string, string> = {
CREATED: 'OT créé',
GENERATED: 'Généré',
FROM_REQUEST: 'Créé depuis une demande',
STATUS_CHANGED: 'Changement détat',
ASSIGNED: 'Assignation',
COMMENT: 'Commentaire',
};
function CarteActivite({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
const commentaire = useCommentWorkOrder(ot.id);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const message = String(new FormData(form).get('message') ?? '').trim();
if (!message) return;
commentaire.mutate(message, { onSuccess: () => form.reset() });
};
return (
<div className="carte">
<h2>Activité</h2>
{peutEditer ? (
<form onSubmit={surEnvoi} className="mb-3 flex gap-2">
<input
name="message"
aria-label="Commenter"
placeholder="Commenter…"
className="flex-1 rounded-lg border border-bordure-forte bg-surface px-3 py-2 text-encre"
/>
<Button type="submit" disabled={commentaire.isPending}>Publier</Button>
</form>
) : null}
<ul className="chrono">
{ot.events.map((e) => (
<li key={e.id} className="fait">
{e.kind === 'COMMENT' ? (
<>Commentaire {e.by ? <b>{e.by.displayName}</b> : null} : « {e.message} »</>
) : (
<>
<b>{KIND_LABELS[e.kind] ?? e.kind}</b>
{e.message ? <> — {e.message}</> : null}
</>
)}
<br />
<span className="quand">
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(e.createdAt))}
{e.by && e.kind !== 'COMMENT' ? ` · ${e.by.displayName}` : ''}
</span>
</li>
))}
</ul>
</div>
);
}
function CarteChecklist({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
const patch = usePatchChecklist(ot.id);
const traitees = ot.checklist.filter((c) => c.state !== 'PENDING').length;
return (
<div className="carte">
<h2>
Checklist du mois{' '}
<span style={{ color: 'var(--encre-3)', textTransform: 'none', letterSpacing: 0 }}>
— {traitees} / {ot.checklist.length} traitées
</span>
</h2>
{ot.checklist.map((item) => (
<div className="check" key={item.id}>
<button
type="button"
className={`coche ${item.state === 'DONE' ? 'ok' : item.state === 'NA' ? 'na' : ''}`}
aria-label={`${item.label} — ${item.state === 'PENDING' ? 'marquer Fait (clic) ou N-A (double clic)' : 'remettre à faire'}`}
disabled={!peutEditer || patch.isPending}
onClick={() =>
patch.mutate({
itemId: item.id,
state: item.state === 'PENDING' ? 'DONE' : item.state === 'DONE' ? 'NA' : 'PENDING',
})
}
>
{item.state === 'DONE' ? '' : item.state === 'NA' ? 'N-A' : ''}
</button>
{item.label}
<span className="qui-fait">
{item.doneBy
? `${item.doneBy.displayName.split(' ')[0]} · ${new Intl.DateTimeFormat('fr-FR', { timeStyle: 'short' }).format(new Date(item.doneAt!))}`
: ''}
</span>
</div>
))}
{ot.checklist.some((c) => c.state === 'PENDING') ? (
<p className="avertissement">
⚠ La clôture reste bloquée : chaque tâche doit être <b>Fait</b> ou <b>N-A</b>.
</p>
) : null}
</div>
);
}
const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail['report']> & string; dto: string }[] = [
{ champ: 'DOOR_STATE', cle: 'doorState', dto: 'doorStateId' },
{ champ: 'CABIN_POSITION', cle: 'cabinPosition', dto: 'cabinPositionId' },
{ champ: 'ANOMALY', cle: 'anomaly', dto: 'anomalyId' },
{ champ: 'EXTERNAL_CAUSE', cle: 'externalCause', dto: 'externalCauseId' },
{ champ: 'ACTION_TAKEN', cle: 'actionTaken', dto: 'actionTakenId' },
{ champ: 'COMPONENT_CONCERNED', cle: 'componentConcerned', dto: 'componentConcernedId' },
];
function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
const { data: valeurs } = useReferenceValues();
const maj = useUpsertReport(ot.id);
const bloqueurBilan = ot.closureBlockers.find((b) => b.includes('bilan'));
return (
<div className="carte">
<h2>
Bilan d'intervention{' '}
<span style={{ color: 'var(--encre-3)', textTransform: 'none', letterSpacing: 0 }}>
requis pour clôturer
</span>
</h2>
<div className="bilan">
{CHAMPS_BILAN.map(({ champ, cle, dto }) => {
const options = (valeurs ?? []).filter((v) => v.field === champ && v.isActive);
const valeur = ot.report?.[cle as 'doorState'] ?? null;
const requis = REQUIRED_BILAN_FIELDS.includes(champ);
return (
<div className="champ-b" key={champ}>
<label htmlFor={`bilan-${champ}`}>
{BILAN_FIELD_LABELS[champ]} {requis ? <em>*</em> : null}
</label>
<select
id={`bilan-${champ}`}
disabled={!peutEditer || maj.isPending || ot.status === 'DONE' || ot.status === 'CANCELLED'}
value={valeur?.id ?? ''}
onChange={(e) => maj.mutate({ [dto]: e.target.value || null })}
>
<option value="">Sélectionner</option>
{options.map((o) => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
</div>
);
})}
</div>
{bloqueurBilan && ot.status !== 'DONE' && ot.status !== 'CANCELLED' ? (
<p className="avertissement"> {bloqueurBilan} la clôture restera bloquée.</p>
) : null}
{maj.isError ? <p className="erreur-form" role="alert">{maj.error.message}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useAssets, useCreateLocation, useLocations } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { CarteGeo } from '@/components/carte-geo';
import { StatutEquipement } from '@/components/statut-equipement';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
/** Écran 2 des maquettes R1 : identité du site, hiérarchie d'emplacements,
* appareils rattachés. Un organe n'apparaît jamais ici : il suit son appareil. */
export default function PageFicheSite() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: locations } = useLocations();
const { data: assets } = useAssets();
const { can } = usePermissions();
const [modaleZone, setModaleZone] = useState(false);
const site = locations?.find((l) => l.id === id);
if (!site) return null;
const zones = (locations ?? []).filter((l) => l.parentId === site.id);
const appareils = (assets ?? []).filter((a) => a.siteName === site.name);
return (
<>
<div className="filajout">
<Link to="/sites">Sites</Link> / {site.name}
</div>
<div className="entete-page">
<h1>{site.name}</h1>
<div className="actions">
{can('ASSETS', 'create') ? (
<Button variant="prim" onClick={() => navigate(`/ascenseurs/nouveau?site=${site.id}`)}>
+ Nouvel ascenseur
</Button>
) : null}
</div>
</div>
<div className="grille-2">
<div className="carte">
<h2>Identité</h2>
<dl className="infos">
<dt>Adresse</dt>
<dd>{[site.address, site.city].filter(Boolean).join(' — ') || '—'}</dd>
<dt>Position</dt>
<dd className="num">
{site.latitude != null
? `${site.latitude} N · ${Math.abs(site.longitude!)} O`
: 'non posée'}
</dd>
<dt>Gardien</dt>
<dd>
{site.guardianName ?? '—'}
{site.guardianPhone ? (
<span className="num"> · {site.guardianPhone}</span>
) : null}
</dd>
<dt>Appareils</dt>
<dd className="num">{site.assetCount}</dd>
</dl>
</div>
{site.latitude != null && site.longitude != null ? (
<CarteGeo
className="min-h-[200px]"
zoom={15}
centre={[site.latitude, site.longitude]}
pins={[{ id: site.id, latitude: site.latitude, longitude: site.longitude, libelle: site.name }]}
/>
) : (
<div className="carte text-encre-3">Position non posée modifiez le site pour la placer.</div>
)}
</div>
<div className="grille-2">
<div className="carte">
<h2>Emplacements & appareils</h2>
<ul className="arbre">
<li className="racine">
<b>{site.name}</b>
<span className="meta">site</span>
<span className="fin">{site.assetCount} appareils</span>
</li>
{zones.map((zone) => (
<li key={zone.id}>
<b>{zone.name}</b>
<span className="meta">zone</span>
<span className="fin">
{appareils
.filter((a) => a.locationId === zone.id)
.map((a) => a.reference)
.join(' · ') || '—'}
</span>
</li>
))}
</ul>
{can('LOCATIONS', 'create') ? (
<Button variant="ghost" className="mt-2" onClick={() => setModaleZone(true)}>
+ Ajouter un emplacement
</Button>
) : null}
</div>
<div className="carte">
<h2>Ascenseurs du site</h2>
{appareils.map((a) => (
<div className="item-liste" key={a.id}>
<Link to={`/ascenseurs/${a.id}`} style={{ color: 'var(--primaire)', fontWeight: 600 }}>
{a.reference} {a.brand} {a.model ?? ''}
</Link>
<span className="fin">
<StatutEquipement statut={a.status} />
</span>
</div>
))}
{appareils.length === 0 ? (
<p className="text-encre-3">Aucun appareil rattaché pour linstant.</p>
) : null}
</div>
</div>
<NouvelleZone siteId={site.id} ouverte={modaleZone} surFermeture={setModaleZone} />
</>
);
}
function NouvelleZone({
siteId,
ouverte,
surFermeture,
}: {
siteId: string;
ouverte: boolean;
surFermeture: (o: boolean) => void;
}) {
const creation = useCreateLocation();
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const nom = String(new FormData(e.currentTarget).get('name'));
creation.mutate(
{ name: nom, parentId: siteId },
{ onSuccess: () => surFermeture(false) },
);
};
return (
<Modale titre="Ajouter un emplacement" ouverte={ouverte} surFermeture={surFermeture}>
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
<div className="champ">
<label htmlFor="nz-nom">Nom de la zone *</label>
<input id="nz-nom" name="name" required placeholder="Ex. : Hall principal" />
</div>
<p className="note-douce">
Hiérarchie limitée à deux niveaux : site zone. Les organes, eux,
suivent leur appareil jamais demplacement propre.
</p>
{creation.isError ? (
<p className="erreur-form" role="alert">{creation.error.message}</p>
) : null}
<div className="pied">
<Button onClick={() => surFermeture(false)}>Annuler</Button>
<Button type="submit" variant="prim" disabled={creation.isPending}>
Ajouter
</Button>
</div>
</form>
</Modale>
);
}

View File

@@ -0,0 +1,206 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useCategories, useCreateAsset, useLocations } from '@/api/referentiel';
import { Button } from '@/components/ui/button';
import { construireArbre } from '@/lib/lieux';
interface LigneOrgane {
cle: number;
typeId: string;
designation: string;
}
/** Écran 4 des maquettes R1 — le parcours de recette : identité →
* rattachement (site → zone) → organes ligne à ligne. QR généré à la création. */
export default function PageNouvelAscenseur() {
const navigate = useNavigate();
const [params] = useSearchParams();
const { data: locations } = useLocations();
const { data: categories } = useCategories();
const creation = useCreateAsset();
const sites = construireArbre(locations ?? []);
const [siteId, setSiteId] = useState(params.get('site') ?? '');
const [organes, setOrganes] = useState<LigneOrgane[]>([
{ cle: 1, typeId: '', designation: '' },
]);
const siteChoisi = sites.find((s) => s.id === siteId);
const equipements = (categories ?? []).filter((c) => c.kind === 'EQUIPMENT' && c.isActive);
const typesOrganes = (categories ?? []).filter(
(c) => c.kind === 'COMPONENT_TYPE' && c.isActive,
);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const donnees = new FormData(e.currentTarget);
const champ = (nom: string) => String(donnees.get(nom) ?? '').trim();
const dateMes = champ('commissionedAt');
creation.mutate(
{
reference: champ('reference'),
brand: champ('brand'),
model: champ('model') || undefined,
serialNumber: champ('serialNumber') || undefined,
commissionedAt: dateMes ? new Date(dateMes).toISOString() : undefined,
loadKg: champ('loadKg') ? Number(champ('loadKg')) : undefined,
floors: champ('floors') ? Number(champ('floors')) : undefined,
categoryId: champ('categoryId'),
locationId: champ('locationId'),
components: organes
.filter((o) => o.typeId)
.map((o) => ({ typeId: o.typeId, designation: o.designation || undefined })),
},
{ onSuccess: (asset) => navigate(`/ascenseurs/${asset.id}`) },
);
};
return (
<div style={{ maxWidth: 860 }} className="flex flex-col gap-4">
<div className="filajout">
<Link to="/ascenseurs">Ascenseurs</Link> / Nouvel ascenseur
</div>
<div className="entete-page"><h1>Nouvel ascenseur</h1></div>
<form onSubmit={surEnvoi} className="flex flex-col gap-4">
<div className="carte">
<h2>1 · Identité</h2>
<div className="form-grille">
<div className="champ">
<label htmlFor="na-ref">Référence *</label>
<input id="na-ref" name="reference" required placeholder="Ex. : B3" />
</div>
<div className="champ">
<label htmlFor="na-serie">N° de série</label>
<input id="na-serie" name="serialNumber" placeholder="Plaque constructeur" />
</div>
<div className="champ">
<label htmlFor="na-marque">Marque *</label>
<input id="na-marque" name="brand" required placeholder="Ex. : Schindler" />
</div>
<div className="champ">
<label htmlFor="na-modele">Modèle</label>
<input id="na-modele" name="model" placeholder="Ex. : 3300" />
</div>
<div className="champ">
<label htmlFor="na-mes">Mise en service</label>
<input id="na-mes" name="commissionedAt" type="date" />
</div>
<div className="champ">
<label htmlFor="na-categorie">Catégorie *</label>
<select id="na-categorie" name="categoryId" required defaultValue="">
<option value="" disabled>Choisir</option>
{equipements.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="na-charge">Charge (kg)</label>
<input id="na-charge" name="loadKg" type="number" min="1" placeholder="630" />
</div>
<div className="champ">
<label htmlFor="na-niveaux">Niveaux desservis</label>
<input id="na-niveaux" name="floors" type="number" min="1" placeholder="8" />
</div>
</div>
</div>
<div className="carte">
<h2>2 · Rattachement</h2>
<div className="form-grille">
<div className="champ">
<label htmlFor="na-site">Site *</label>
<select
id="na-site"
required
value={siteId}
onChange={(e) => setSiteId(e.target.value)}
>
<option value="" disabled>Choisir</option>
{sites.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="na-zone">Emplacement dans le site *</label>
<select id="na-zone" name="locationId" required defaultValue="" disabled={!siteChoisi}>
<option value="" disabled>{siteChoisi ? 'Choisir…' : 'Choisissez un site'}</option>
{siteChoisi ? (
<option value={siteChoisi.id}> le site lui-même </option>
) : null}
{siteChoisi?.zones.map((z) => (
<option key={z.id} value={z.id}>{z.name}</option>
))}
</select>
</div>
</div>
</div>
<div className="carte">
<h2>
3 · Organes{' '}
<span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>
suivis individuellement (pannes par organe, essais réglementaires)
</span>
</h2>
{organes.map((organe, index) => (
<div className="ligne-organe" key={organe.cle}>
<select
aria-label={`Type de l'organe ${index + 1}`}
value={organe.typeId}
onChange={(e) =>
setOrganes((o) =>
o.map((x) => (x.cle === organe.cle ? { ...x, typeId: e.target.value } : x)),
)
}
>
<option value="">Type</option>
{typesOrganes.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<input
aria-label={`Désignation de l'organe ${index + 1}`}
style={{ flex: 2 }}
placeholder="Désignation…"
value={organe.designation}
onChange={(e) =>
setOrganes((o) =>
o.map((x) =>
x.cle === organe.cle ? { ...x, designation: e.target.value } : x,
),
)
}
/>
<button
type="button"
className="btn-suppr"
aria-label={`Retirer l'organe ${index + 1}`}
onClick={() => setOrganes((o) => o.filter((x) => x.cle !== organe.cle))}
>
</button>
</div>
))}
<Button
variant="ghost"
className="mt-2"
onClick={() =>
setOrganes((o) => [...o, { cle: Date.now(), typeId: '', designation: '' }])
}
>
+ Ajouter un organe
</Button>
</div>
{creation.isError ? (
<p className="erreur-form" role="alert">{creation.error.message}</p>
) : null}
<div className="flex justify-end gap-2">
<Button onClick={() => navigate('/ascenseurs')}>Annuler</Button>
<Button type="submit" variant="prim" disabled={creation.isPending}>
Créer l'ascenseur (QR généré)
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import {
WORK_ORDER_PRIORITY_LABELS,
WORK_ORDER_TYPE_LABELS,
WORK_ORDER_TYPES,
type WorkOrderPriority,
type WorkOrderType,
} from '@siop/shared';
import { useCreateWorkOrder } from '@/api/exploitation';
import { useAssets } from '@/api/referentiel';
import { useUsers } from '@/api/referentiel';
import { Button } from '@/components/ui/button';
/** Écran 2 des maquettes R2 : l'OT manuel — « personne bloquée » est un
* choix EXPLICITE (interrupteur) qui déclenche l'urgence partout. */
export default function PageNouvelOT() {
const navigate = useNavigate();
const [params] = useSearchParams();
const { data: assets } = useAssets();
const { data: users } = useUsers();
const creation = useCreateWorkOrder();
const [personneBloquee, setPersonneBloquee] = useState(false);
const techniciens = (users ?? []).filter(
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const donnees = new FormData(e.currentTarget);
const champ = (n: string) => String(donnees.get(n) ?? '').trim();
const assigne = champ('assigneeId');
creation.mutate(
{
title: champ('title'),
description: champ('description') || undefined,
type: champ('type') as WorkOrderType,
priority: personneBloquee
? 'PERSON_TRAPPED'
: (champ('priority') as WorkOrderPriority),
assetId: champ('assetId'),
dueDate: champ('dueDate') ? new Date(champ('dueDate')).toISOString() : undefined,
assigneeIds: assigne ? [assigne] : undefined,
},
{ onSuccess: (ot) => navigate(`/ot/${ot.id}`) },
);
};
return (
<div style={{ maxWidth: 760 }} className="flex flex-col gap-4">
<div className="filajout">
<Link to="/ot">Ordres de travail</Link> / Nouvel OT
</div>
<div className="entete-page"><h1>Nouvel ordre de travail</h1></div>
<form onSubmit={surEnvoi} className="flex flex-col gap-4">
<div className="carte">
<div className="form-grille">
<div className="champ pleine-l">
<label htmlFor="not-titre">Objet *</label>
<input id="not-titre" name="title" required placeholder="Ex. : Remplacement contact de porte" />
</div>
<div className="champ">
<label htmlFor="not-type">Type *</label>
<select id="not-type" name="type" required defaultValue="CORRECTIVE">
{WORK_ORDER_TYPES.map((t) => (
<option key={t} value={t}>{WORK_ORDER_TYPE_LABELS[t]}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="not-asset">Équipement *</label>
<select id="not-asset" name="assetId" required defaultValue={params.get('asset') ?? ''}>
<option value="" disabled>Choisir</option>
{(assets ?? []).map((a) => (
<option key={a.id} value={a.id}>
{a.reference} {a.siteName}
</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="not-prio">Priorité *</label>
<select id="not-prio" name="priority" defaultValue="MEDIUM" disabled={personneBloquee}>
{(['NONE', 'LOW', 'MEDIUM', 'HIGH'] as const).map((p) => (
<option key={p} value={p}>{WORK_ORDER_PRIORITY_LABELS[p]}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="not-echeance">Échéance</label>
<input id="not-echeance" name="dueDate" type="date" />
</div>
<div className="champ pleine-l">
<label htmlFor="not-desc">Description</label>
<input id="not-desc" name="description" placeholder="Constat, contexte, consignes…" />
</div>
<div className="champ">
<label htmlFor="not-assigne">Assigner à</label>
<select id="not-assigne" name="assigneeId" defaultValue="">
<option value=""></option>
{techniciens.map((t) => (
<option key={t.id} value={t.id}>{t.displayName}</option>
))}
</select>
</div>
</div>
<div className="gros-toggle" style={{ marginTop: 14 }}>
<b>Personne bloquée dans la cabine ?</b>
<span style={{ marginLeft: 6, fontSize: 12, color: 'var(--encre-2)' }}>
passe l'OT en urgence absolue (bandeau + tête de liste)
</span>
<button
type="button"
className="interrupteur"
role="switch"
aria-checked={personneBloquee}
aria-label="Personne bloquée"
onClick={() => setPersonneBloquee((v) => !v)}
/>
</div>
</div>
{creation.isError ? (
<p className="erreur-form" role="alert">{creation.error.message}</p>
) : null}
<div className="flex justify-end gap-2">
<Button onClick={() => navigate('/ot')}>Annuler</Button>
<Button type="submit" variant="prim" disabled={creation.isPending}>
Créer l'OT (statut Ouvert)
</Button>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_STATUSES,
WORK_ORDER_TYPE_LABELS,
WORK_ORDER_TYPES,
type WorkOrderStatus,
type WorkOrderType,
} from '@siop/shared';
import { useWorkOrders } from '@/api/exploitation';
import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar';
import { PrioriteOT, StatutOT } from '@/components/chips-ot';
import { Button } from '@/components/ui/button';
import { formatDateFr } from '@/lib/lieux';
/** Écran 3 validé R0 : table dense « bureau », strie rouge sur l'urgence. */
export default function PageOrdresTravail() {
const navigate = useNavigate();
const { data: workOrders } = useWorkOrders();
const { can } = usePermissions();
const [statut, setStatut] = useState<'actifs' | 'tous' | WorkOrderStatus>('actifs');
const [type, setType] = useState<'' | WorkOrderType>('');
const tous = workOrders ?? [];
const visibles = tous.filter((w) => {
if (statut === 'actifs' && (w.status === 'DONE' || w.status === 'CANCELLED')) return false;
if (statut !== 'actifs' && statut !== 'tous' && w.status !== statut) return false;
if (type && w.type !== type) return false;
return true;
});
return (
<>
<div className="entete-page">
<h1>Ordres de travail</h1>
<div className="actions">
{can('WORK_ORDERS', 'create') ? (
<Button variant="prim" onClick={() => navigate('/ot/nouveau')}>
+ Nouvel OT
</Button>
) : null}
</div>
</div>
<div className="filtres">
<select
aria-label="Filtrer par statut"
value={statut}
onChange={(e) => setStatut(e.target.value as typeof statut)}
>
<option value="actifs">Statut : actifs</option>
<option value="tous">Statut : tous</option>
{WORK_ORDER_STATUSES.map((s) => (
<option key={s} value={s}>{WORK_ORDER_STATUS_LABELS[s]}</option>
))}
</select>
<select
aria-label="Filtrer par type"
value={type}
onChange={(e) => setType(e.target.value as '' | WorkOrderType)}
>
<option value="">Type : tous</option>
{WORK_ORDER_TYPES.map((t) => (
<option key={t} value={t}>{WORK_ORDER_TYPE_LABELS[t]}</option>
))}
</select>
<span className="num" style={{ color: 'var(--encre-3)', fontSize: 12.5, marginLeft: 'auto' }}>
{visibles.length} résultats
</span>
</div>
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>N°</th>
<th>Objet</th>
<th>Équipement · Site</th>
<th>Type</th>
<th>Priorité</th>
<th>Statut</th>
<th>Assignés</th>
<th>Échéance</th>
</tr>
</thead>
<tbody>
{visibles.map((w) => (
<tr
key={w.id}
className={w.priority === 'PERSON_TRAPPED' ? 'ligne-bloque' : undefined}
onClick={() => navigate(`/ot/${w.id}`)}
style={{ cursor: 'pointer' }}
>
<td className="ref num">{w.reference}</td>
<td>{w.priority === 'PERSON_TRAPPED' ? <b>{w.title}</b> : w.title}</td>
<td>
Asc. {w.assetReference}
<span className="sous">{w.siteName}</span>
</td>
<td>{WORK_ORDER_TYPE_LABELS[w.type]}</td>
<td><PrioriteOT priorite={w.priority} /></td>
<td><StatutOT statut={w.status} /></td>
<td>
{w.assignees.length ? (
<div className="avatars">
{w.assignees.map((a) => (
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
))}
</div>
) : (
'—'
)}
</td>
<td className="num">
{w.priority === 'PERSON_TRAPPED' ? <b>immédiat</b> : formatDateFr(w.dueDate)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,297 @@
import { useState, type FormEvent } from 'react';
import type { InvitationResponse } from '@siop/shared';
import {
useCreateTeam,
useInviteUser,
useResendInvitation,
useRoles,
useTeams,
useUsers,
} from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { Avatar, initialsOf } from '@/components/avatar';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
/** Écran 6 des maquettes R1 : personnes (invitation par lien 7 j, jamais de
* mot de passe créé pour autrui) et équipes par zone. */
export default function PagePersonnes() {
const { data: users } = useUsers();
const { data: teams } = useTeams();
const { can } = usePermissions();
const [modaleInvitation, setModaleInvitation] = useState(false);
const [modaleEquipe, setModaleEquipe] = useState(false);
const [lien, setLien] = useState<InvitationResponse | null>(null);
const renvoi = useResendInvitation();
return (
<>
<div className="entete-page">
<h1>Personnes & équipes</h1>
<div className="actions">
{can('PEOPLE_TEAMS', 'create') ? (
<Button variant="prim" onClick={() => setModaleInvitation(true)}>
+ Inviter
</Button>
) : null}
</div>
</div>
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>Personne</th>
<th>Rôle</th>
<th>Équipe</th>
<th>Statut</th>
<th></th>
</tr>
</thead>
<tbody>
{(users ?? []).map((u) => (
<tr key={u.id}>
<td>
<div className="flex items-center gap-[9px]">
<Avatar
initials={initialsOf(u.displayName)}
roleName={u.role.name}
className="h-[26px] w-[26px] text-[10.5px]"
/>
<div>
<b>{u.displayName}</b>
<span className="sous">{u.email}</span>
</div>
</div>
</td>
<td>{u.role.name}</td>
<td>{u.teams.map((t) => t.name).join(', ') || '—'}</td>
<td>
{u.status === 'active' ? (
<span className="st termine">Actif</span>
) : u.status === 'invited' ? (
<span className="st attente">Invitation envoyée</span>
) : (
<span className="st annule">Désactivé</span>
)}
</td>
<td>
{u.status === 'invited' && can('PEOPLE_TEAMS', 'edit') ? (
<Button
variant="ghost"
className="px-2 py-1 text-[12px]"
disabled={renvoi.isPending}
onClick={() => renvoi.mutate(u.id, { onSuccess: setLien })}
>
Renvoyer le lien
</Button>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="carte">
<h2>Équipes</h2>
<div className="equipes">
{(teams ?? []).map((t) => (
<div className="equipe" key={t.id}>
<b>{t.name}</b>
<span className="zone">{t.description ?? '—'}</span>
<div className="avatars">
{t.members.map((m) => (
<Avatar
key={m.id}
initials={m.initials}
roleName={m.roleName}
className="h-6 w-6 text-[10px]"
/>
))}
{t.members.length === 0 ? (
<span className="text-[12px] text-encre-3">aucun membre</span>
) : null}
</div>
</div>
))}
{can('PEOPLE_TEAMS', 'create') ? (
<button
type="button"
className="equipe items-center justify-center text-encre-3"
style={{ borderStyle: 'dashed', cursor: 'pointer' }}
onClick={() => setModaleEquipe(true)}
>
<b style={{ fontWeight: 600 }}>+ Nouvelle équipe</b>
</button>
) : null}
</div>
</div>
<ModaleInvitation
ouverte={modaleInvitation}
surFermeture={setModaleInvitation}
surLien={setLien}
/>
<ModaleEquipe ouverte={modaleEquipe} surFermeture={setModaleEquipe} />
<ModaleLien lien={lien} surFermeture={() => setLien(null)} />
</>
);
}
function ModaleInvitation({
ouverte,
surFermeture,
surLien,
}: {
ouverte: boolean;
surFermeture: (o: boolean) => void;
surLien: (l: InvitationResponse) => void;
}) {
const { data: roles } = useRoles();
const { data: teams } = useTeams();
const invitation = useInviteUser();
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const donnees = new FormData(e.currentTarget);
const teamId = String(donnees.get('teamId'));
invitation.mutate(
{
email: String(donnees.get('email')),
displayName: String(donnees.get('displayName')),
roleId: String(donnees.get('roleId')),
teamIds: teamId ? [teamId] : undefined,
},
{
onSuccess: (reponse) => {
surFermeture(false);
surLien(reponse);
},
},
);
};
return (
<Modale titre="Inviter une personne" ouverte={ouverte} surFermeture={surFermeture}>
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
<div className="champ">
<label htmlFor="inv-nom">Nom complet *</label>
<input id="inv-nom" name="displayName" required placeholder="Prénom Nom" />
</div>
<div className="champ">
<label htmlFor="inv-email">Email professionnel *</label>
<input id="inv-email" name="email" type="email" required placeholder="prenom.nom@spelev.ma" />
</div>
<div className="form-grille">
<div className="champ">
<label htmlFor="inv-role">Rôle *</label>
<select id="inv-role" name="roleId" required defaultValue="">
<option value="" disabled>Choisir</option>
{(roles ?? []).map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="inv-equipe">Équipe (optionnel)</label>
<select id="inv-equipe" name="teamId" defaultValue="">
<option value=""></option>
{(teams ?? []).map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
</div>
<p className="note-douce">
La personne recevra un <b>lien d'activation valable 7 jours</b> pour choisir
son mot de passe. Aucun compte n'est actif avant cela.
</p>
{invitation.isError ? (
<p className="erreur-form" role="alert">{invitation.error.message}</p>
) : null}
<div className="pied">
<Button onClick={() => surFermeture(false)}>Annuler</Button>
<Button type="submit" variant="prim" disabled={invitation.isPending}>
Envoyer l'invitation
</Button>
</div>
</form>
</Modale>
);
}
/** L'envoi d'email n'existe pas encore : le lien se copie ici (décision R1). */
function ModaleLien({
lien,
surFermeture,
}: {
lien: InvitationResponse | null;
surFermeture: () => void;
}) {
const [copie, setCopie] = useState(false);
if (!lien) return null;
const url = `${window.location.origin}/activation?token=${lien.activationToken}`;
return (
<Modale titre="Lien d'activation émis" ouverte surFermeture={surFermeture}>
<p className="text-[13px] text-encre-2">
Transmettez ce lien à la personne (valable jusqu'au{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(lien.expiresAt))}) :
</p>
<div className="lien-copiable" data-testid="lien-activation">{url}</div>
<div className="pied">
<Button
onClick={() => {
void navigator.clipboard.writeText(url);
setCopie(true);
}}
>
{copie ? 'Copié ' : 'Copier le lien'}
</Button>
<Button variant="prim" onClick={surFermeture}>Fermer</Button>
</div>
</Modale>
);
}
function ModaleEquipe({
ouverte,
surFermeture,
}: {
ouverte: boolean;
surFermeture: (o: boolean) => void;
}) {
const creation = useCreateTeam();
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const donnees = new FormData(e.currentTarget);
creation.mutate(
{
name: String(donnees.get('name')),
description: String(donnees.get('description')) || undefined,
},
{ onSuccess: () => surFermeture(false) },
);
};
return (
<Modale titre="Nouvelle équipe" ouverte={ouverte} surFermeture={surFermeture}>
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
<div className="champ">
<label htmlFor="eq-nom">Nom *</label>
<input id="eq-nom" name="name" required placeholder="Ex. : Rabat Agdal" />
</div>
<div className="champ">
<label htmlFor="eq-desc">Zone couverte</label>
<input id="eq-desc" name="description" placeholder="Sites couverts…" />
</div>
{creation.isError ? (
<p className="erreur-form" role="alert">{creation.error.message}</p>
) : null}
<div className="pied">
<Button onClick={() => surFermeture(false)}>Annuler</Button>
<Button type="submit" variant="prim" disabled={creation.isPending}>Créer</Button>
</div>
</form>
</Modale>
);
}

View File

@@ -0,0 +1,203 @@
import { useQuery } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { useParams } from 'react-router-dom';
import type { PortalRequestStatus } from '@siop/shared';
import { api } from '@/api/client';
import { Button } from '@/components/ui/button';
import { IcoAscenseurs } from '@/components/nav-icons';
/**
* Écran 6 validé R0 : Karim (gardien, aucune formation, aucun compte) arrive
* ici en scannant le QR. Équipement prérempli, UN choix critique, suivi sans
* jargon. Ses signalements vivent sur SON téléphone (référence + jeton).
*/
const CLE_SUIVIS = 'siop.portail.suivis';
interface Suivi { reference: string; token: string; }
const lireSuivis = (): Suivi[] => {
try {
return JSON.parse(localStorage.getItem(CLE_SUIVIS) ?? '[]') as Suivi[];
} catch {
return [];
}
};
const ajouterSuivi = (s: Suivi) =>
localStorage.setItem(CLE_SUIVIS, JSON.stringify([s, ...lireSuivis()].slice(0, 10)));
export default function PagePortail() {
const { reference } = useParams<{ reference: string }>();
const [suivis, setSuivis] = useState<Suivi[]>(lireSuivis);
const [personneBloquee, setPersonneBloquee] = useState(false);
const [envoye, setEnvoye] = useState(false);
const [erreur, setErreur] = useState<string | null>(null);
const { data: asset, isError: refInconnue } = useQuery({
queryKey: ['portal-asset', reference],
retry: false,
queryFn: async () => {
const { data, response } = await api.GET('/portal/assets/{reference}', {
params: { path: { reference: reference! } },
});
if (!data) throw new Error(String(response.status));
return data;
},
});
const surEnvoi = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setErreur(null);
const form = e.currentTarget;
const description = String(new FormData(form).get('description') ?? '').trim();
const { data, response } = await api.POST('/portal/requests', {
body: {
assetReference: reference!,
description,
isPersonTrapped: personneBloquee,
},
});
if (!data) {
setErreur(
response.status === 429
? 'Trop de signalements envoyés — réessayez dans une minute.'
: 'Envoi impossible — réessayez ou appelez le numéro affiché en cabine.',
);
return;
}
ajouterSuivi({ reference: data.reference, token: data.publicToken });
setSuivis(lireSuivis());
setPersonneBloquee(false);
setEnvoye(true);
form.reset();
};
return (
<div style={{ background: 'var(--fond)', minHeight: '100vh' }}>
<div className="portail">
<div className="logo" style={{ color: 'var(--encre)', padding: 0 }}>
<span className="marque" aria-hidden="true"><br /></span>
<div>
<b style={{ fontSize: 19 }}>SIOP</b>
<small style={{ color: 'var(--encre-2)' }}>
Signalement{asset ? `${asset.siteName}` : ''}
</small>
</div>
</div>
{refInconnue ? (
<div className="carte">
<p className="erreur-form">
Ce QR ne correspond à aucun appareil connu. Vérifiez l'étiquette ou
appelez le numéro affiché en cabine.
</p>
</div>
) : (
<form className="carte flex flex-col gap-3" onSubmit={surEnvoi}>
<div className="bandeau-detecte">
<span style={{ color: 'var(--primaire)', width: 18, height: 18, flex: 'none' }}>
<IcoAscenseurs />
</span>
<span>
<b>Ascenseur {reference}{asset ? ` — ${asset.locationName}` : ''}</b>{' '}
<span style={{ color: 'var(--encre-2)' }}>(détecté par le QR)</span>
</span>
</div>
<div className="gros-toggle">
<b>Une personne est-elle bloquée dans la cabine ?</b>
<button
type="button"
className="interrupteur"
role="switch"
aria-checked={personneBloquee}
aria-label="Personne bloquée"
onClick={() => setPersonneBloquee((v) => !v)}
/>
</div>
<div className="champ">
<label htmlFor="po-desc">Décrivez le problème</label>
<input
id="po-desc"
name="description"
required
minLength={3}
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
/>
</div>
<div className="zone-photo">📷 Ajouter une photo (bientôt disponible)</div>
{erreur ? <p className="erreur-form" role="alert">{erreur}</p> : null}
{envoye ? (
<p style={{ color: 'var(--succes)', fontWeight: 600, fontSize: 13 }} role="status">
✓ Signalement envoyé — l'équipe SPELEV est prévenue.
</p>
) : null}
<Button type="submit" variant="prim" className="justify-center" style={{ padding: 11 }}>
Envoyer le signalement
</Button>
</form>
)}
{suivis.length > 0 ? (
<div className="carte">
<h2>Mes signalements</h2>
{suivis.map((s) => (
<LigneSuivi key={s.reference} suivi={s} />
))}
</div>
) : null}
</div>
</div>
);
}
function LigneSuivi({ suivi }: { suivi: Suivi }) {
const { data } = useQuery({
queryKey: ['portal-status', suivi.reference],
refetchInterval: 30_000,
retry: false,
queryFn: async () => {
const { data } = await api.GET('/portal/requests/{reference}/{token}', {
params: { path: { reference: suivi.reference, token: suivi.token } },
});
return data ?? null;
},
});
if (!data) return null;
return (
<div className="item-liste" style={{ alignItems: 'flex-start' }}>
<div>
<b>{data.description}</b>
<span className="sous num">
{data.reference} ·{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(data.createdAt))}
</span>
{data.status === 'REJECTED' ? (
<div style={{ fontSize: 12, color: 'var(--encre-2)', marginTop: 4 }}>
Sans suite : {data.rejectionReason ?? 'voir avec le gestionnaire'}
</div>
) : (
<Etapes status={data} />
)}
</div>
</div>
);
}
function Etapes({ status }: { status: PortalRequestStatus }) {
const franchi = (etape: 'RECEIVED' | 'IN_PROGRESS' | 'RESOLVED') => {
const ordre = ['RECEIVED', 'IN_PROGRESS', 'RESOLVED'];
return ordre.indexOf(status.step) >= ordre.indexOf(etape);
};
return (
<div className="suivi-simple">
<span className={franchi('RECEIVED') ? 'etape fait' : 'etape'}> Reçu</span>
<span className="lien-e" />
<span className={franchi('IN_PROGRESS') ? 'etape fait' : 'etape'}>
{franchi('IN_PROGRESS') ? '✓ ' : ''}Intervention
</span>
<span className="lien-e" />
<span className={franchi('RESOLVED') ? 'etape fait' : 'etape'}>
{franchi('RESOLVED') ? '✓ ' : ''}Résolu
</span>
</div>
);
}

View File

@@ -0,0 +1,166 @@
import { useState, type FormEvent } from 'react';
import type { PreventiveGenerationResult } from '@siop/shared';
import {
useCreateTaskTemplate,
useGeneratePreventive,
usePreventiveStatus,
useTaskTemplates,
useUpdateTaskTemplate,
} from '@/api/exploitation';
import { usePermissions } from '@/auth/use-permissions';
import { Button } from '@/components/ui/button';
const MOIS_FR = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
];
const libelleMois = (period: string) => {
const [annee, mois] = period.split('-').map(Number);
return `${MOIS_FR[mois! - 1]} ${annee}`;
};
/** Écran 3 des maquettes R2 : gabarits à périodicité + génération idempotente. */
export default function PagePreventif() {
const { data: status } = usePreventiveStatus();
const { data: templates } = useTaskTemplates();
const { can } = usePermissions();
const generation = useGeneratePreventive();
const creation = useCreateTaskTemplate();
const maj = useUpdateTaskTemplate();
const [resultat, setResultat] = useState<PreventiveGenerationResult | null>(null);
const [nouveau, setNouveau] = useState({ label: '', periodMonths: 1 });
const peutGenerer = can('WORK_ORDERS', 'create');
const peutAdministrer = can('SETTINGS', 'edit');
const ajouterGabarit = (e: FormEvent) => {
e.preventDefault();
if (!nouveau.label.trim()) return;
creation.mutate(
{ label: nouveau.label.trim(), periodMonths: nouveau.periodMonths },
{ onSuccess: () => setNouveau({ label: '', periodMonths: 1 }) },
);
};
return (
<>
<div className="entete-page">
<h1>Préventif</h1>
<div className="actions">
{peutGenerer && status ? (
<Button
variant="prim"
disabled={generation.isPending}
onClick={() => generation.mutate({}, { onSuccess: setResultat })}
>
Générer la grille de {libelleMois(status.month)}
</Button>
) : null}
</div>
</div>
{status ? (
<div className="kpis">
<div className="kpi">
<div className="lib">Grilles générées</div>
<div className="val num">{status.generated} / {status.assetsUnderContract}</div>
</div>
<div className="kpi">
<div className="lib">Terminées</div>
<div className="val num">{status.done}</div>
</div>
<div className={status.late ? 'kpi alarme' : 'kpi'}>
<div className="lib">En retard</div>
<div className="val num">{status.late}</div>
</div>
<div className="kpi">
<div className="lib">Premiers contrôles</div>
<div className="val num">{status.firstControls}</div>
</div>
</div>
) : null}
{resultat ? (
<div className="carte gen-info">
<span style={{ fontSize: 13 }}>
Génération {libelleMois(resultat.month)} : <b className="num">{resultat.created}</b> OT créés
{resultat.firstControls ? <> (dont <b className="num">{resultat.firstControls}</b> premiers contrôles)</> : null},{' '}
<b className="num">{resultat.skipped}</b> déjà en place <b>regénérer ne double rien</b>.
</span>
</div>
) : null}
{generation.isError ? (
<p className="erreur-form" role="alert">{generation.error.message}</p>
) : null}
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>Tâche du gabarit</th>
<th>Organe visé</th>
<th>Périodicité</th>
{peutAdministrer ? <th></th> : null}
</tr>
</thead>
<tbody>
{(templates ?? []).map((t) => (
<tr key={t.id} style={!t.isActive ? { color: 'var(--encre-3)' } : undefined}>
<td>{t.isRegulatory ? <b>{t.label}</b> : t.label}{!t.isActive ? ' (désactivé)' : ''}</td>
<td>{t.componentTypeName ?? '—'}</td>
<td>
<span className={t.isRegulatory ? 'periode reglementaire' : 'periode'}>
{t.periodMonths === 1
? 'Tous les mois'
: `${t.periodMonths} mois${t.isRegulatory ? ' · réglementaire' : ''}`}
</span>
</td>
{peutAdministrer ? (
<td>
<Button
variant="ghost"
className="px-2 py-1 text-[12px]"
onClick={() => maj.mutate({ id: t.id, isActive: !t.isActive })}
>
{t.isActive ? 'Désactiver' : 'Réactiver'}
</Button>
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
</div>
{peutAdministrer ? (
<form className="carte flex items-end gap-3" onSubmit={ajouterGabarit}>
<div className="champ" style={{ flex: 2 }}>
<label htmlFor="gab-label">Nouvelle tâche</label>
<input
id="gab-label"
placeholder="Ex. : Contrôle éclairage de gaine"
value={nouveau.label}
onChange={(e) => setNouveau((n) => ({ ...n, label: e.target.value }))}
/>
</div>
<div className="champ">
<label htmlFor="gab-periode">Périodicité (mois)</label>
<input
id="gab-periode"
type="number"
min={1}
max={60}
value={nouveau.periodMonths}
onChange={(e) => setNouveau((n) => ({ ...n, periodMonths: Number(e.target.value) }))}
/>
</div>
<Button type="submit" variant="prim" disabled={creation.isPending}>Ajouter</Button>
</form>
) : null}
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
Le gabarit s'applique à tout appareil <b>sous contrat préventif</b>. Un appareil sans
historique reçoit un OT « <b>premier contrôle</b> » : toutes les tâches, quelle que soit
la périodicité. Les autres tâches tombent aux mois anniversaires de la mise en service.
</div>
</>
);
}

View File

@@ -0,0 +1,172 @@
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCreateLocation, useLocations } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { CarteGeo } from '@/components/carte-geo';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
import { construireArbre } from '@/lib/lieux';
/** Écran 1 des maquettes R1 : liste dense + carte OSM (positions PostGIS). */
export default function PageSites() {
const navigate = useNavigate();
const { data: locations } = useLocations();
const { can } = usePermissions();
const [modale, setModale] = useState(false);
const sites = construireArbre(locations ?? []);
const totalAppareils = sites.reduce((n, s) => n + s.assetCount, 0);
return (
<>
<div className="entete-page">
<h1>Sites</h1>
<span className="filajout">
{sites.length} sites · {totalAppareils} ascenseurs
</span>
<div className="actions">
{can('LOCATIONS', 'create') ? (
<Button variant="prim" onClick={() => setModale(true)}>
+ Nouveau site
</Button>
) : null}
</div>
</div>
<div className="grille-2">
<div className="carte" style={{ padding: 0 }}>
<div className="table">
<table>
<thead>
<tr>
<th>Site</th>
<th>Ascenseurs</th>
<th>Gardien</th>
<th>Zones</th>
</tr>
</thead>
<tbody>
{sites.map((site) => (
<tr
key={site.id}
onClick={() => navigate(`/sites/${site.id}`)}
style={{ cursor: 'pointer' }}
>
<td>
<b>{site.name}</b>
<span className="sous">
{[site.address, site.city].filter(Boolean).join(', ') || '—'}
</span>
</td>
<td className="num">{site.assetCount}</td>
<td>{site.guardianName ?? '—'}</td>
<td className="num">{site.zones.length}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<CarteGeo
pins={sites
.filter((s) => s.latitude != null && s.longitude != null)
.map((s) => ({
id: s.id,
latitude: s.latitude!,
longitude: s.longitude!,
libelle: `${s.name} · ${s.assetCount}`,
onClick: () => navigate(`/sites/${s.id}`),
}))}
/>
</div>
<NouveauSite ouverte={modale} surFermeture={setModale} />
</>
);
}
function NouveauSite({
ouverte,
surFermeture,
}: {
ouverte: boolean;
surFermeture: (o: boolean) => void;
}) {
const creation = useCreateLocation();
const navigate = useNavigate();
const [position, setPosition] = useState<{ lat: number; lng: number } | null>(null);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const donnees = new FormData(e.currentTarget);
creation.mutate(
{
name: String(donnees.get('name')),
address: String(donnees.get('address')) || undefined,
city: String(donnees.get('city')) || undefined,
guardianName: String(donnees.get('guardianName')) || undefined,
guardianPhone: String(donnees.get('guardianPhone')) || undefined,
latitude: position?.lat,
longitude: position?.lng,
},
{
onSuccess: (site) => {
surFermeture(false);
navigate(`/sites/${site.id}`);
},
},
);
};
return (
<Modale titre="Nouveau site" ouverte={ouverte} surFermeture={surFermeture}>
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
<div className="champ">
<label htmlFor="ns-nom">Nom du site *</label>
<input id="ns-nom" name="name" required placeholder="Ex. : Résidence Les Palmiers" />
</div>
<div className="form-grille">
<div className="champ">
<label htmlFor="ns-adresse">Adresse</label>
<input id="ns-adresse" name="address" placeholder="Bd, rue…" />
</div>
<div className="champ">
<label htmlFor="ns-ville">Ville</label>
<input id="ns-ville" name="city" placeholder="Casablanca" />
</div>
<div className="champ">
<label htmlFor="ns-gardien">Gardien</label>
<input id="ns-gardien" name="guardianName" />
</div>
<div className="champ">
<label htmlFor="ns-tel">Téléphone gardien</label>
<input id="ns-tel" name="guardianPhone" placeholder="06 …" />
</div>
</div>
<div className="champ">
<label>Position cliquez sur la carte</label>
<CarteGeo
className="min-h-[180px]"
zoom={11}
onClick={(lat, lng) => setPosition({ lat, lng })}
pins={
position
? [{ id: 'nouveau', latitude: position.lat, longitude: position.lng, libelle: 'Nouveau site' }]
: []
}
/>
<p className="mt-1 text-[12px] text-encre-3 num">
{position ? `${position.lat} N · ${position.lng} E` : 'Aucune position posée'}
</p>
</div>
{creation.isError ? (
<p className="erreur-form" role="alert">{creation.error.message}</p>
) : null}
<div className="pied">
<Button onClick={() => surFermeture(false)}>Annuler</Button>
<Button type="submit" variant="prim" disabled={creation.isPending}>
Créer le site
</Button>
</div>
</form>
</Modale>
);
}

View File

@@ -1,49 +1,206 @@
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TYPE_LABELS,
type WorkOrderStatus,
} from '@siop/shared';
import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation';
import { useMe } from '@/auth/use-auth'; import { useMe } from '@/auth/use-auth';
import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar';
import { PrioriteOT, StatutOT } from '@/components/chips-ot';
import { Button } from '@/components/ui/button';
/** R0 : coquille seulement — le vrai tableau de bord (KPI, urgences, const ST_COULEURS: Record<string, string> = {
* graphiques) arrive avec l'exploitation (R2), conformément au plan. */ OPEN: 'var(--st-ouvert)',
IN_PROGRESS: 'var(--st-encours)',
ON_HOLD: 'var(--st-attente)',
DONE: 'var(--st-termine)',
};
/** Écran 2 validé R0 — alimenté par les données réelles de R2.
* (Coûts → R3 ; pannes par organe → analytics R3.) */
export default function PageTableauDeBord() { export default function PageTableauDeBord() {
const { data: me } = useMe(); const navigate = useNavigate();
const { can, chargé } = usePermissions();
const peutVoirOT = can('WORK_ORDERS', 'view');
const { data: workOrders } = useWorkOrders(peutVoirOT);
const { data: requests } = useRequests();
const { data: preventif } = usePreventiveStatus(peutVoirOT);
if (!chargé) return null;
if (!peutVoirOT) return <AccueilSansExploitation />;
const tous = workOrders ?? [];
const urgences = tous.filter(
(w) => w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
);
const ouverts = tous.filter((w) => w.status !== 'DONE' && w.status !== 'CANCELLED');
const enRetard = ouverts.filter((w) => w.dueDate && new Date(w.dueDate) < new Date());
const aTraiter = (requests ?? []).filter((r) => r.status === 'RECEIVED');
const parStatut = (['OPEN', 'IN_PROGRESS', 'ON_HOLD', 'DONE'] as WorkOrderStatus[]).map(
(statut) => ({
statut,
n: tous.filter((w) => w.status === statut).length,
}),
);
const max = Math.max(1, ...parStatut.map((s) => s.n));
const recents = tous.slice(0, 5);
return ( return (
<> <>
<div className="entete-page"> <div className="entete-page"><h1>Tableau de bord</h1></div>
<h1>Tableau de bord</h1> {urgences.map((u) => (
</div> <div className="bandeau-urgence" key={u.id}>
<div className="carte"> <span className="point-pulse" />
<h2>Fondations (R0)</h2> <div>
<p> <b>{u.title} Ascenseur {u.assetReference}</b> ({u.siteName})
Bienvenue{me ? ` ${me.displayName}` : ''} vous êtes connecté avec le <br />
rôle <b>{me?.role.name}</b>. Cette coquille valide l'authentification, <span className="depuis">
la bascule de rôle en un clic et le bi-thème ; les indicateurs {u.reference} · signalé{' '}
d'exploitation arriveront avec la release R2. {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(u.createdAt))}
</p> {u.assignees.length === 0 ? ' · aucune intervention en cours' : ''}
<p className="mt-2 text-encre-2"> </span>
La référence vivante du design est consultable sur la page{' '} </div>
<Link to="/design" className="font-semibold text-primaire"> <div style={{ marginLeft: 'auto' }}>
/design <Button variant="prim" onClick={() => navigate(`/ot/${u.id}`)}>
</Link> Ouvrir l'OT d'urgence
. </Button>
</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>
</div> </div>
) : null} ))}
<div className="kpis">
<div className="kpi">
<div className="lib">OT ouverts</div>
<div className="val num">{ouverts.length}</div>
<div className="delta neutre">
dont {tous.filter((w) => w.status === 'IN_PROGRESS').length} en cours
</div>
</div>
<div className={enRetard.length ? 'kpi alarme' : 'kpi'}>
<div className="lib">En retard</div>
<div className="val num">{enRetard.length}</div>
</div>
<div className="kpi">
<div className="lib">Préventif · {preventif ? mois(preventif.month) : '…'}</div>
<div className="val num">
{preventif && preventif.generated > 0
? `${Math.round((preventif.done / preventif.generated) * 100)} %`
: '—'}
</div>
<div className="delta bon">
{preventif ? `${preventif.done} / ${preventif.generated} grilles` : ''}
</div>
</div>
<div className="kpi">
<div className="lib">Demandes à traiter</div>
<div className="val num">{aTraiter.length}</div>
<div className="delta neutre">
{aTraiter[0]
? `la + ancienne : ${new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(aTraiter[aTraiter.length - 1]!.createdAt))}`
: 'file vide'}
</div>
</div>
</div>
<div className="grille-2">
<div className="carte">
<h2>OT par statut</h2>
<div className="barres-h flex flex-col gap-2">
{parStatut.map(({ statut, n }) => (
<div className="ligne-b" key={statut}>
<span>{WORK_ORDER_STATUS_LABELS[statut]}</span>
<div className="fond-b">
<div
className="rempli"
style={{ width: `${(n / max) * 100}%`, background: ST_COULEURS[statut] }}
/>
</div>
<span className="valeur num">{n}</span>
</div>
))}
</div>
</div>
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Pannes par organe · 12 mois</h2>
<p className="text-encre-3">
Ce graphique s'alimentera des bilans codés — il arrive avec les analytics (R3).
</p>
</div>
</div>
<div className="carte">
<h2>Interventions récentes</h2>
<div className="table">
<table>
<thead>
<tr>
<th>N°</th><th>Objet</th><th>Équipement</th><th>Type</th><th>Priorité</th><th>Statut</th><th>Assignés</th>
</tr>
</thead>
<tbody>
{recents.map((w) => (
<tr
key={w.id}
className={w.priority === 'PERSON_TRAPPED' ? 'ligne-bloque' : undefined}
onClick={() => navigate(`/ot/${w.id}`)}
style={{ cursor: 'pointer' }}
>
<td className="ref num">{w.reference}</td>
<td>{w.title}</td>
<td>
Asc. {w.assetReference}
<span className="sous">{w.siteName}</span>
</td>
<td>{WORK_ORDER_TYPE_LABELS[w.type]}</td>
<td><PrioriteOT priorite={w.priority} /></td>
<td><StatutOT statut={w.status} /></td>
<td>
{w.assignees.length ? (
<div className="avatars">
{w.assignees.map((a) => (
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
))}
</div>
) : (
''
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}
const MOIS_FR = [
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
];
const mois = (period: string) => MOIS_FR[Number(period.split('-')[1]) - 1];
/** Accueil des rôles sans exploitation (Demandeur) : ses demandes. */
function AccueilSansExploitation() {
const { data: me } = useMe();
const { data: requests } = useRequests();
const { can } = usePermissions();
return (
<>
<div className="entete-page"><h1>Mes signalements</h1></div>
<div className="carte">
<p>
Bienvenue{me ? ` ${me.displayName}` : ''} — retrouvez ici vos signalements
et leur avancement.
</p>
{can('REQUESTS', 'view') ? (
<p className="mt-2">
<Link to="/demandes" className="font-semibold text-primaire">
Voir mes demandes ({(requests ?? []).length})
</Link>
</p>
) : null}
</div>
</> </>
); );
} }

View File

@@ -536,6 +536,382 @@ table {
margin: 6px 4px; margin: 6px 4px;
} }
/* ═══ R1 — Référentiel (classes extraites de maquette-r1.html validée) ═══ */
/* Statuts d'ÉQUIPEMENT (distincts des statuts OT) */
.st.arret { color: var(--danger); background: var(--prio-bloque-fond); }
.st.maintenance { color: var(--st-encours); background: var(--st-encours-fond); }
/* Carte géographique — le conteneur ; Leaflet rend à l'intérieur */
.geo {
position: relative;
border: 1px solid var(--bordure);
border-radius: var(--rayon);
overflow: hidden;
min-height: 320px;
background: var(--surface-2);
}
.geo .leaflet-container {
position: absolute;
inset: 0;
font: inherit;
}
/* Pin maquette (divIcon Leaflet) */
.pin-geo {
width: 26px;
height: 26px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg) translate(4px, -4px);
background: var(--primaire);
border: 2px solid var(--surface);
box-shadow: var(--ombre-menu);
}
.pin-geo::after {
content: '';
position: absolute;
inset: 7px;
border-radius: 50%;
background: var(--surface);
}
.pin-geo.alerte { background: var(--prio-bloque); }
/* Arborescence des emplacements */
.arbre { list-style: none; padding: 0; font-size: 13.5px; }
.arbre li { position: relative; padding: 7px 0 7px 26px; border-bottom: 1px solid var(--bordure); }
.arbre li:last-child { border-bottom: 0; }
.arbre li::before { content: ''; position: absolute; left: 8px; top: 0; bottom: 0; width: 1px; background: var(--bordure); }
.arbre li::after { content: ''; position: absolute; left: 8px; top: 50%; width: 12px; height: 1px; background: var(--bordure); }
.arbre li.racine { padding-left: 0; }
.arbre li.racine::before, .arbre li.racine::after { display: none; }
.arbre .meta { color: var(--encre-2); font-size: 12px; margin-left: 8px; }
.arbre .fin { float: right; color: var(--encre-2); font-size: 12px; }
/* Formulaires R1 */
.form-grille { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 14px; }
@media (max-width: 900px) { .form-grille { grid-template-columns: 1fr; } }
.form-grille .pleine-l { grid-column: 1/-1; }
.champ select {
width: 100%;
padding: 9px 12px;
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon-controle);
font: inherit;
background: var(--surface);
color: var(--encre);
}
.ligne-organe { display: flex; gap: 8px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--bordure); }
.ligne-organe:last-of-type { border-bottom: 0; }
.ligne-organe select, .ligne-organe input {
padding: 8px 11px;
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon-controle);
font: inherit;
background: var(--surface);
color: var(--encre);
}
.btn-suppr { color: var(--encre-3); padding: 6px; border-radius: 6px; border: 0; background: none; }
.btn-suppr:hover { color: var(--danger); background: var(--prio-bloque-fond); }
/* Étiquette QR imprimable (A6 — objet papier, blanc même en thème sombre) */
.zone-impression { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
.etiquette {
width: 340px;
background: #fff;
color: #1b2534;
border: 1px solid var(--bordure-forte);
border-radius: 12px;
padding: 22px;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: var(--ombre-menu);
}
.etiquette .tete { display: flex; align-items: center; gap: 10px; border-bottom: 2px solid #1b2534; padding-bottom: 10px; }
.etiquette .tete .marque { display: flex; flex-direction: column; line-height: 0.55; font-size: 12px; color: #dd8a0b; }
.etiquette .tete b { font-size: 20px; letter-spacing: 0.04em; }
.etiquette .corps { display: flex; gap: 16px; align-items: center; }
.etiquette .qr-grand { flex: none; border: 1px solid #dce3ec; border-radius: 8px; padding: 6px; background: #fff; }
.etiquette h3 { font-size: 15px; font-weight: 700; margin: 0; }
.etiquette .ou { font-size: 12px; color: #55647a; }
.etiquette .consigne {
font-size: 12.5px;
background: #fdf3e3;
border: 1px dashed #dd8a0b;
color: #8a5a06;
border-radius: 8px;
padding: 8px 10px;
text-align: center;
font-weight: 600;
}
.etiquette .ref { font-size: 11px; color: #8494ab; text-align: center; font-variant-numeric: tabular-nums; }
@media print {
body * { visibility: hidden; }
.etiquette, .etiquette * { visibility: visible; }
.etiquette { position: fixed; inset: 0 auto auto 0; box-shadow: none; margin: 10mm; }
}
/* Modale (Radix Dialog) — style maquette */
.voile-radix {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--encre) 32%, transparent);
z-index: 70;
}
.modale {
position: fixed;
top: 10vh;
left: 50%;
transform: translateX(-50%);
width: 460px;
max-width: 94vw;
max-height: 82vh;
overflow-y: auto;
background: var(--surface);
border: 1px solid var(--bordure);
border-radius: 12px;
box-shadow: var(--ombre-menu);
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
z-index: 71;
}
.modale h3 { font-size: 15px; font-weight: 700; }
.modale .pied { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.note-douce {
font-size: 12px;
color: var(--encre-2);
background: var(--surface-2);
border-radius: 8px;
padding: 8px 10px;
}
.lien-copiable {
font-family: var(--police-mono);
font-size: 11.5px;
word-break: break-all;
background: var(--surface-2);
border: 1px dashed var(--bordure-forte);
border-radius: 8px;
padding: 8px 10px;
}
/* Équipes */
.equipes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
@media (max-width: 1000px) { .equipes { grid-template-columns: 1fr; } }
.equipe {
border: 1px solid var(--bordure);
border-radius: var(--rayon);
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 8px;
background: var(--surface);
}
.equipe b { font-size: 13.5px; }
.equipe .zone { font-size: 12px; color: var(--encre-2); }
/* Tables denses (liste ascenseurs / sites / personnes) */
.table { width: 100%; overflow-x: auto; }
.table table { width: 100%; font-size: 13.5px; }
.table th {
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--encre-2);
text-align: left;
padding: 9px 12px;
border-bottom: 1px solid var(--bordure);
background: var(--surface-2);
white-space: nowrap;
}
.table td { padding: 9px 12px; border-bottom: 1px solid var(--bordure); vertical-align: middle; }
.table tbody tr:hover { background: var(--primaire-doux); }
.table .ref { font-weight: 700; color: var(--primaire); }
.table .sous { color: var(--encre-2); font-size: 12px; display: block; }
.ligne-bloque td:first-child { box-shadow: inset 3px 0 0 var(--prio-bloque); }
.filajout { color: var(--encre-3); font-size: 12.5px; }
.filajout a { color: var(--encre-2); text-decoration: none; }
.filtres { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.filtres select {
border: 1px solid var(--bordure-forte);
border-radius: var(--rayon-controle);
padding: 7px 11px;
font-size: 13px;
background: var(--surface);
color: var(--encre);
}
.infos { display: grid; grid-template-columns: 130px 1fr; row-gap: 9px; font-size: 13.5px; }
.infos dt { color: var(--encre-2); }
.infos dd { font-weight: 500; }
.infos a { color: var(--primaire); text-decoration: none; font-weight: 600; }
.item-liste { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--bordure); font-size: 13.5px; }
.item-liste:last-child { border-bottom: 0; }
.item-liste .fin { margin-left: auto; display: flex; align-items: center; gap: 10px; color: var(--encre-2); font-size: 12.5px; }
.avatars { display: flex; }
.avatars .avatar { width: 24px; height: 24px; font-size: 10px; border: 2px solid var(--surface); margin-left: -6px; }
.avatars .avatar:first-child { margin-left: 0; }
/* ═══ R2 — Exploitation (classes extraites des maquettes validées) ═══ */
/* Statuts de DEMANDE (validés R2 : extension cohérente des couleurs) */
.st.recue { color: var(--st-ouvert); background: var(--st-ouvert-fond); }
.st.approuvee { color: var(--st-encours); background: var(--st-encours-fond); }
.st.rejetee { color: var(--st-annule); background: var(--st-annule-fond); }
.st.resolue { color: var(--st-termine); background: var(--st-termine-fond); }
/* Urgence « personne bloquée » — seule animation continue de l'app */
.chip-urgence {
display: flex;
align-items: center;
gap: 7px;
background: var(--prio-bloque-fond);
color: var(--prio-bloque);
border: 1px solid color-mix(in srgb, var(--prio-bloque) 30%, transparent);
padding: 6px 12px;
border-radius: var(--rayon-pastille);
font-weight: 700;
font-size: 12.5px;
}
.bandeau-urgence {
display: flex;
align-items: center;
gap: 14px;
background: var(--prio-bloque-fond);
border: 1px solid color-mix(in srgb, var(--prio-bloque) 35%, transparent);
border-left: 4px solid var(--prio-bloque);
border-radius: var(--rayon);
padding: 12px 16px;
}
.bandeau-urgence b { color: var(--prio-bloque); }
.bandeau-urgence .depuis { color: var(--encre-2); font-size: 12.5px; }
/* Tuiles KPI (tableau de bord + préventif) */
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
@media (max-width: 1100px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
.kpi { background: var(--surface); border: 1px solid var(--bordure); border-radius: var(--rayon); padding: 14px 16px; }
.kpi .lib { font-size: 11.5px; letter-spacing: 0.05em; text-transform: uppercase; color: var(--encre-2); font-weight: 600; }
.kpi .val { font-size: 27px; font-weight: 700; letter-spacing: -0.02em; margin-top: 4px; }
.kpi .delta { font-size: 12px; font-weight: 600; margin-top: 2px; }
.delta.bon { color: var(--succes); }
.delta.mauvais { color: var(--danger); }
.delta.neutre { color: var(--encre-3); }
.kpi.alarme { border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
.kpi.alarme .val { color: var(--danger); }
/* Barres horizontales (OT par statut) */
.barres-h .ligne-b { display: grid; grid-template-columns: 110px 1fr 40px; align-items: center; gap: 10px; font-size: 13px; }
.barres-h .fond-b { background: var(--graf-grille); border-radius: 4px; height: 14px; overflow: hidden; }
.barres-h .rempli { height: 100%; border-radius: 4px 0 0 4px; }
.barres-h .valeur { text-align: right; font-weight: 600; }
/* Activité chronologique */
.chrono { list-style: none; display: flex; flex-direction: column; gap: 0; font-size: 13px; padding: 0; }
.chrono li { position: relative; padding: 0 0 16px 22px; }
.chrono li::before { content: ''; position: absolute; left: 5px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--bordure-forte); }
.chrono li.fait::before { background: var(--primaire); }
.chrono li::after { content: ''; position: absolute; left: 8.5px; top: 15px; bottom: -2px; width: 1px; background: var(--bordure); }
.chrono li:last-child::after { display: none; }
.chrono .quand { color: var(--encre-3); font-size: 11.5px; }
/* Checklist Fait / N-A */
.check { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--bordure); font-size: 13.5px; }
.check:last-of-type { border-bottom: 0; }
.coche {
width: 19px; height: 19px; border-radius: 5px; border: 1.5px solid var(--bordure-forte);
flex: none; display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 11px; background: none; padding: 0; cursor: pointer;
}
.coche.ok { background: var(--succes); border-color: var(--succes); }
.coche.na { background: var(--encre-3); border-color: var(--encre-3); font-size: 8.5px; }
.check .qui-fait { margin-left: auto; color: var(--encre-3); font-size: 12px; white-space: nowrap; }
/* Bilan codé (6 champs) */
.bilan { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; }
@media (max-width: 900px) { .bilan { grid-template-columns: 1fr; } }
.bilan .champ-b { font-size: 12.5px; }
.bilan .champ-b label { color: var(--encre-2); font-weight: 600; display: block; margin-bottom: 4px; }
.bilan .champ-b label em { color: var(--danger); font-style: normal; }
.bilan select {
width: 100%; padding: 8px 11px; border: 1px solid var(--bordure-forte);
border-radius: var(--rayon-controle); font: inherit; background: var(--surface); color: var(--encre);
}
.avertissement { margin-top: 12px; font-size: 12.5px; color: var(--alerte); }
/* Préventif */
.periode {
display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 700;
color: var(--primaire); background: var(--primaire-doux); padding: 2px 9px; border-radius: var(--rayon-pastille);
}
.periode.reglementaire { color: var(--safran); background: var(--safran-doux); }
.gen-info { border-left: 3px solid var(--succes); }
/* Panneau d'approbation (demandes) */
.panneau { border: 1px solid var(--bordure); border-radius: var(--rayon); background: var(--surface); padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; }
/* Interrupteur (personne bloquée) */
.gros-toggle {
display: flex; align-items: center; gap: 12px; background: var(--prio-bloque-fond);
border: 1px solid color-mix(in srgb, var(--prio-bloque) 30%, transparent);
border-radius: 10px; padding: 12px 14px;
}
.gros-toggle b { color: var(--prio-bloque); }
.interrupteur {
margin-left: auto; width: 44px; height: 24px; border-radius: var(--rayon-pastille);
background: var(--bordure-forte); position: relative; flex: none; border: 0; cursor: pointer;
transition: background var(--transition);
}
.interrupteur::after {
content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px;
border-radius: 50%; background: #fff; transition: left var(--transition);
}
.interrupteur[aria-checked='true'] { background: var(--prio-bloque); }
.interrupteur[aria-checked='true']::after { left: 22px; }
/* Relevés de compteurs */
.releve-saisie { display: flex; gap: 8px; align-items: center; }
.releve-saisie input {
width: 140px; padding: 8px 11px; border: 1px solid var(--bordure-forte);
border-radius: var(--rayon-controle); font: inherit; background: var(--surface);
color: var(--encre); text-align: right;
}
/* ═══ Portail demandeur (écran 6 validé R0 — aucun compte, aucun jargon) ═══ */
.portail {
max-width: 430px;
margin: 28px auto;
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 100vh;
}
.zone-photo {
border: 1.5px dashed var(--bordure-forte);
border-radius: 10px;
padding: 18px;
text-align: center;
color: var(--encre-2);
font-size: 13px;
}
.bandeau-detecte {
display: flex;
align-items: center;
gap: 10px;
background: var(--primaire-doux);
border-radius: var(--rayon-controle);
padding: 10px 12px;
font-size: 13px;
}
.suivi-simple {
display: flex;
align-items: center;
gap: 0;
font-size: 11.5px;
color: var(--encre-3);
margin-top: 6px;
}
.suivi-simple .etape { display: flex; align-items: center; gap: 6px; }
.suivi-simple .etape.fait { color: var(--succes); font-weight: 700; }
.suivi-simple .lien-e { width: 26px; height: 1px; background: var(--bordure-forte); margin: 0 6px; }
/* ═══ Page /design : nuanciers ═══ */ /* ═══ Page /design : nuanciers ═══ */
.nuancier { .nuancier {
display: flex; display: flex;

View File

@@ -11,7 +11,12 @@ export default defineConfig({
include: ['src/**/*.test.{ts,tsx}'], include: ['src/**/*.test.{ts,tsx}'],
}, },
resolve: { resolve: {
alias: { '@': path.resolve(__dirname, 'src') }, alias: {
'@': path.resolve(__dirname, 'src'),
// Le paquet partagé est compilé en CJS pour l'API ; côté Vite on
// consomme sa SOURCE TypeScript (exports nommés, HMR, pas de rebuild).
'@siop/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'),
},
}, },
server: { server: {
// Même topologie qu'en prod (nginx : /api → apps/api sans préfixe) // Même topologie qu'en prod (nginx : /api → apps/api sans préfixe)

View File

@@ -0,0 +1,863 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SIOP V2 — Maquettes HD · R1 Référentiel</title>
</head>
<body>
<!--
SIOP V2 — Maquettes HD de la release R1 « Référentiel » (design-first).
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE APPLICATIF (principe n°1).
La CSS est RÉPLIQUÉE de maquette-web.html (validée le 15/07/2026) ;
les seuls ajouts R1 sont dans le second bloc <style> (commenté).
L'écran « fiche ascenseur » (validé en R0) n'est pas redessiné ici.
-->
<style>
/* ═══ Tokens (répliques de ../tokens.css) ═══ */
:root{
--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;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--police-ui:"Manrope","Avenir Next","Segoe UI Variable",system-ui,sans-serif;
--rayon:10px;--rayon-controle:8px;--ombre-menu:0 8px 24px rgb(16 30 54/.14);
--transition:140ms ease-out;
}
@media (prefers-color-scheme:dark){:root{
--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;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}}
:root[data-theme="light"]{
--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;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--ombre-menu:0 8px 24px rgb(16 30 54/.14);
}
:root[data-theme="dark"]{
--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;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}
/* ═══ Base ═══ */
*{box-sizing:border-box;margin:0}
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;border:0;background:none;color:inherit}
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}
/* ═══ Barre de revue (outil maquette, pas l'app) ═══ */
.revue{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:12px;flex-wrap:wrap;
padding:10px 16px;background:var(--nav-fond);color:var(--nav-encre);font-size:13px}
.revue b{color:var(--nav-encre-vif);letter-spacing:.02em}
.revue .onglets{display:flex;gap:4px;flex-wrap:wrap}
.revue .onglets button{padding:6px 10px;border-radius:6px;color:var(--nav-encre)}
.revue .onglets button:hover{background:rgb(255 255 255/.08);color:var(--nav-encre-vif)}
.revue .onglets button[aria-selected="true"]{background:rgb(255 255 255/.12);color:var(--nav-encre-vif);
box-shadow:inset 3px 0 0 var(--safran)}
.revue .droite{margin-left:auto;display:flex;align-items:center;gap:8px}
.revue .theme{padding:6px 10px;border:1px solid var(--nav-bordure);border-radius:6px}
.note-ecran{max-width:1280px;margin:14px auto 0;padding:0 20px;color:var(--encre-2);font-size:13px}
.note-ecran b{color:var(--encre)}
/* ═══ Coquille applicative ═══ */
.ecran{display:none;max-width:1280px;margin:14px auto 40px;padding:0 20px}
.ecran.actif{display:block}
.app{display:grid;grid-template-columns:232px 1fr;min-height:720px;border:1px solid var(--bordure);
border-radius:14px;overflow:hidden;background:var(--fond)}
.app.pleine{grid-template-columns:1fr}
/* 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:.55;font-size:11px;color:var(--safran)}
.logo b{font-size:19px;letter-spacing:.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:.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:3px solid transparent;transition:background var(--transition)}
.nav a:hover{background:rgb(255 255 255/.06);color:var(--nav-encre-vif)}
.nav a.actif{background:rgb(255 255 255/.09);color:var(--nav-encre-vif);border-left-color:var(--safran)}
.nav a .badge-nav{margin-left:auto;background:var(--prio-bloque);color:#fff;font-size:11px;
padding:1px 7px;border-radius:999px;font-weight:700}
.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:8px;padding:7px 12px;color:var(--encre-3);font-size:13px}
.recherche svg{width:15px;height:15px}
.chip-urgence{display:flex;align-items:center;gap:7px;background:var(--prio-bloque-fond);
color:var(--prio-bloque);border:1px solid color-mix(in srgb,var(--prio-bloque) 30%,transparent);
padding:6px 12px;border-radius:999px;font-weight:700;font-size:12.5px}
.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}}
.compte{margin-left:auto;display:flex;align-items:center;gap:10px}
.chip-demo{font-size:11px;font-weight:700;letter-spacing:.05em;color:var(--safran);
background:var(--safran-doux);padding:3px 9px;border-radius:999px;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}
.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:-.01em;text-wrap:balance}
.entete-page .actions{margin-left:auto;display:flex;gap:8px}
.filajout{color:var(--encre-3);font-size:12.5px}
.filajout a{color:var(--encre-2);text-decoration:none}
/* Boutons */
.btn{display:inline-flex;align-items:center;gap:7px;padding:8px 14px;border-radius:8px;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(.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:.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:.04em;text-transform:uppercase;color:var(--encre-2);margin-bottom:12px}
.grille-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.grille-3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
@media(max-width:900px){.grille-2,.grille-3{grid-template-columns:1fr}}
/* Tuiles KPI */
.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}
@media(max-width:1100px){.kpis{grid-template-columns:repeat(2,1fr)}}
.kpi{background:var(--surface);border:1px solid var(--bordure);border-radius:var(--rayon);padding:14px 16px}
.kpi .lib{font-size:11.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--encre-2);font-weight:600}
.kpi .val{font-size:27px;font-weight:700;letter-spacing:-.02em;margin-top:4px}
.kpi .delta{font-size:12px;font-weight:600;margin-top:2px}
.delta.bon{color:var(--succes)}.delta.mauvais{color:var(--danger)}.delta.neutre{color:var(--encre-3)}
.kpi.alarme{border-color:color-mix(in srgb,var(--danger) 45%,transparent)}
.kpi.alarme .val{color:var(--danger)}
.anneau{display:flex;align-items:center;gap:12px}
.anneau svg{flex:none}
/* Pastilles statut / priorité */
.st{display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;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}
.prio.bloque{color:var(--prio-bloque)}.prio.bloque i{background:var(--prio-bloque);animation:pulse 1.4s ease-out infinite}
.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)}
/* Bandeau urgence */
.bandeau-urgence{display:flex;align-items:center;gap:14px;background:var(--prio-bloque-fond);
border:1px solid color-mix(in srgb,var(--prio-bloque) 35%,transparent);border-left:4px solid var(--prio-bloque);
border-radius:var(--rayon);padding:12px 16px}
.bandeau-urgence b{color:var(--prio-bloque)}
.bandeau-urgence .depuis{color:var(--encre-2);font-size:12.5px}
/* Tables */
.table{width:100%;overflow-x:auto}
.table table{width:100%;font-size:13.5px}
.table th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);text-align:left;
padding:9px 12px;border-bottom:1px solid var(--bordure);background:var(--surface-2);white-space:nowrap}
.table td{padding:9px 12px;border-bottom:1px solid var(--bordure);vertical-align:middle}
.table tbody tr:hover{background:var(--primaire-doux)}
.table .ref{font-weight:700;color:var(--primaire)}
.table .sous{color:var(--encre-2);font-size:12px;display:block}
.ligne-bloque td:first-child{box-shadow:inset 3px 0 0 var(--prio-bloque)}
.avatars{display:flex}
.avatars .avatar{width:24px;height:24px;font-size:10px;border:2px solid var(--surface);margin-left:-6px}
.avatars .avatar:first-child{margin-left:0}
/* Filtres */
.filtres{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.select{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--bordure-forte);
border-radius:8px;padding:7px 11px;font-size:13px;background:var(--surface);color:var(--encre-2)}
.select b{color:var(--encre);font-weight:600}
/* Graphiques */
.graf{display:flex;flex-direction:column;gap:10px}
.barres-h .ligne-b{display:grid;grid-template-columns:110px 1fr 40px;align-items:center;gap:10px;font-size:13px}
.barres-h .fond-b{background:var(--graf-grille);border-radius:4px;height:14px;overflow:hidden}
.barres-h .rempli{height:100%;border-radius:4px 0 0 4px}
.barres-h .valeur{text-align:right;font-weight:600}
.legende{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--encre-2)}
.legende span{display:inline-flex;align-items:center;gap:6px}
.legende i{width:9px;height:9px;border-radius:3px}
/* Connexion */
.conn{min-height:640px;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:8px;
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:.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:8px;background:var(--surface);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)}
/* Fiche : listes de définitions */
.infos{display:grid;grid-template-columns:130px 1fr;row-gap:9px;font-size:13.5px}
.infos dt{color:var(--encre-2)}
.infos dd{font-weight:500}
.infos a{color:var(--primaire);text-decoration:none;font-weight:600}
.chrono{list-style:none;display:flex;flex-direction:column;gap:0;font-size:13px}
.chrono li{position:relative;padding:0 0 16px 22px}
.chrono li::before{content:"";position:absolute;left:5px;top:5px;width:8px;height:8px;border-radius:50%;
background:var(--bordure-forte)}
.chrono li.fait::before{background:var(--primaire)}
.chrono li::after{content:"";position:absolute;left:8.5px;top:15px;bottom:-2px;width:1px;background:var(--bordure)}
.chrono li:last-child::after{display:none}
.chrono .quand{color:var(--encre-3);font-size:11.5px}
.check{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--bordure);font-size:13.5px}
.check:last-of-type{border-bottom:0}
.coche{width:19px;height:19px;border-radius:5px;border:1.5px solid var(--bordure-forte);flex:none;
display:flex;align-items:center;justify-content:center;color:#fff}
.coche.ok{background:var(--succes);border-color:var(--succes)}
.coche.na{background:var(--encre-3);border-color:var(--encre-3)}
.check .qui-fait{margin-left:auto;color:var(--encre-3);font-size:12px}
.total{display:flex;justify-content:space-between;font-weight:700;padding-top:10px;font-size:14px}
/* Bilan codé */
.bilan{display:grid;grid-template-columns:1fr 1fr;gap:10px 14px}
@media(max-width:900px){.bilan{grid-template-columns:1fr}}
.bilan .champ-b{font-size:12.5px}
.bilan .champ-b label{color:var(--encre-2);font-weight:600;display:block;margin-bottom:4px}
.bilan .champ-b label em{color:var(--danger);font-style:normal}
.faux-select{display:flex;justify-content:space-between;align-items:center;border:1px solid var(--bordure-forte);
border-radius:8px;padding:8px 11px;background:var(--surface);color:var(--encre)}
.faux-select.vide{color:var(--encre-3)}
/* Organes / documents */
.item-liste{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--bordure);font-size:13.5px}
.item-liste:last-child{border-bottom:0}
.item-liste .ico{width:34px;height:34px;border-radius:8px;background:var(--surface-2);display:flex;
align-items:center;justify-content:center;color:var(--encre-2);flex:none}
.item-liste .ico svg{width:16px;height:16px;stroke-width:1.75}
.item-liste .fin{margin-left:auto;display:flex;align-items:center;gap:10px;color:var(--encre-2);font-size:12.5px}
.qr{display:flex;gap:16px;align-items:center}
.qr svg{border:1px solid var(--bordure);border-radius:8px;background:#fff;padding:8px;flex:none}
/* Portail demandeur */
.portail{max-width:430px;margin:28px auto;display:flex;flex-direction:column;gap:14px}
.gros-toggle{display:flex;align-items:center;gap:12px;background:var(--prio-bloque-fond);
border:1px solid color-mix(in srgb,var(--prio-bloque) 30%,transparent);border-radius:10px;padding:12px 14px}
.gros-toggle b{color:var(--prio-bloque)}
.interrupteur{margin-left:auto;width:44px;height:24px;border-radius:999px;background:var(--bordure-forte);position:relative;flex:none}
.interrupteur::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff}
.zone-photo{border:1.5px dashed var(--bordure-forte);border-radius:10px;padding:18px;text-align:center;color:var(--encre-2);font-size:13px}
.suivi-simple{display:flex;align-items:center;gap:0;font-size:11.5px;color:var(--encre-3);margin-top:6px}
.suivi-simple .etape{display:flex;align-items:center;gap:6px}
.suivi-simple .etape.fait{color:var(--succes);font-weight:700}
.suivi-simple .lien-e{width:26px;height:1px;background:var(--bordure-forte);margin:0 6px}
/* Mobile */
.tels{display:flex;gap:24px;justify-content:center;flex-wrap:wrap}
.tel{width:300px;border:1px solid var(--bordure-forte);border-radius:28px;overflow:hidden;background:var(--fond);
box-shadow:var(--ombre-menu)}
.tel .encoche{height:26px;background:var(--nav-fond);display:flex;justify-content:center;align-items:center}
.tel .encoche i{width:70px;height:7px;border-radius:999px;background:rgb(255 255 255/.18)}
.tel .ecran-tel{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:520px}
.tel h3{font-size:16px;font-weight:700;display:flex;align-items:center;gap:8px}
.tel h3 .sync{margin-left:auto;font-size:10.5px;font-weight:700;color:var(--st-termine);
background:var(--st-termine-fond);padding:2px 8px;border-radius:999px}
.tel h3 .sync.off{color:var(--st-attente);background:var(--st-attente-fond)}
.carte-ot-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:12px;padding:12px}
.carte-ot-tel .type-t{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);
font-weight:700;display:flex;justify-content:space-between;align-items:center}
.carte-ot-tel b{display:block;margin:3px 0 2px;font-size:14.5px}
.carte-ot-tel .ou{color:var(--encre-2);font-size:12.5px}
.btn-tel{display:flex;justify-content:center;align-items:center;gap:8px;padding:12px;border-radius:10px;
font-weight:700;font-size:14.5px;background:var(--primaire);color:#fff;width:100%}
.btn-tel.vert{background:var(--succes)}
.btn-tel.gris{background:var(--bordure-forte);color:var(--surface)}
.paire-boutons{display:flex;gap:8px}
.btn-choix{flex:1;text-align:center;padding:9px;border:1.5px solid var(--bordure-forte);border-radius:9px;font-weight:600;font-size:13px}
.btn-choix.actif{border-color:var(--primaire);background:var(--primaire-doux);color:var(--primaire)}
.viseur{flex:1;min-height:330px;border-radius:12px;background:
radial-gradient(circle at 50% 45%, #2c3a52 0%, #131c2c 70%);position:relative;display:flex;
align-items:center;justify-content:center}
.viseur .cadre-qr{width:170px;height:170px;border-radius:14px;border:2.5px solid var(--safran);
box-shadow:0 0 0 2000px rgb(9 14 22/.45)}
.viseur p{position:absolute;bottom:14px;color:#dfe7f2;font-size:12.5px}
/* Design system : 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:8px;border:1px solid var(--bordure);margin-bottom:4px}
</style>
<style>
/* ═══ Ajouts R1 (à reverser dans l'app après validation) ═══ */
/* Statuts d'ÉQUIPEMENT (distincts des statuts OT) */
.st.arret{color:var(--danger);background:var(--prio-bloque-fond)}
.st.maintenance{color:var(--st-encours);background:var(--st-encours-fond)}
/* Carte géographique (placeholder — Leaflet/OSM + PostGIS dans l'app) */
.geo{position:relative;border:1px solid var(--bordure);border-radius:var(--rayon);overflow:hidden;
background:
linear-gradient(var(--graf-grille) 1px,transparent 1px),
linear-gradient(90deg,var(--graf-grille) 1px,transparent 1px),
linear-gradient(160deg,var(--surface-2),var(--surface));
background-size:34px 34px,34px 34px,100% 100%;min-height:320px}
.geo .voie{position:absolute;background:var(--bordure);border-radius:2px}
.geo .pin{position:absolute;display:flex;flex-direction:column;align-items:center;transform:translate(-50%,-100%)}
.geo .pin i{width:26px;height:26px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);
background:var(--primaire);border:2px solid var(--surface);box-shadow:var(--ombre-menu)}
.geo .pin i::after{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--surface)}
.geo .pin.alerte i{background:var(--prio-bloque)}
.geo .pin b{margin-top:5px;font-size:11px;background:var(--surface);border:1px solid var(--bordure);
padding:2px 7px;border-radius:999px;white-space:nowrap}
.geo .attribution{position:absolute;right:8px;bottom:6px;font-size:10px;color:var(--encre-3)}
/* Arborescence des emplacements */
.arbre{list-style:none;padding:0;font-size:13.5px}
.arbre li{position:relative;padding:7px 0 7px 26px;border-bottom:1px solid var(--bordure)}
.arbre li:last-child{border-bottom:0}
.arbre li::before{content:"";position:absolute;left:8px;top:0;bottom:0;width:1px;background:var(--bordure)}
.arbre li::after{content:"";position:absolute;left:8px;top:50%;width:12px;height:1px;background:var(--bordure)}
.arbre li.racine{padding-left:0}
.arbre li.racine::before,.arbre li.racine::after{display:none}
.arbre .n2{margin-left:22px}
.arbre .meta{color:var(--encre-2);font-size:12px;margin-left:8px}
.arbre .fin{float:right;color:var(--encre-2);font-size:12px}
/* Formulaires R1 */
.form-grille{display:grid;grid-template-columns:1fr 1fr;gap:12px 14px}
@media(max-width:900px){.form-grille{grid-template-columns:1fr}}
.form-grille .pleine-l{grid-column:1/-1}
.ligne-organe{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid var(--bordure)}
.ligne-organe:last-of-type{border-bottom:0}
.ligne-organe .faux-select{flex:1}
.btn-suppr{color:var(--encre-3);padding:6px;border-radius:6px}
.btn-suppr:hover{color:var(--danger);background:var(--prio-bloque-fond)}
/* Étiquette QR imprimable (A6) */
.zone-impression{display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap}
.etiquette{width:340px;background:#fff;color:#1b2534;border:1px solid var(--bordure-forte);
border-radius:12px;padding:22px;display:flex;flex-direction:column;gap:12px;
box-shadow:var(--ombre-menu)}
.etiquette .tete{display:flex;align-items:center;gap:10px;border-bottom:2px solid #1b2534;padding-bottom:10px}
.etiquette .tete .marque{display:flex;flex-direction:column;line-height:.55;font-size:12px;color:#dd8a0b}
.etiquette .tete b{font-size:20px;letter-spacing:.04em}
.etiquette .corps{display:flex;gap:16px;align-items:center}
.etiquette .qr-grand{flex:none;border:1px solid #dce3ec;border-radius:8px;padding:6px;background:#fff}
.etiquette h3{font-size:15px;font-weight:700;margin:0}
.etiquette .ou{font-size:12px;color:#55647a}
.etiquette .consigne{font-size:12.5px;background:#fdf3e3;border:1px dashed #dd8a0b;color:#8a5a06;
border-radius:8px;padding:8px 10px;text-align:center;font-weight:600}
.etiquette .ref{font-size:11px;color:#8494ab;text-align:center;font-variant-numeric:tabular-nums}
/* Modale (invitation) — présentée ouverte dans la maquette */
.voile{background:color-mix(in srgb,var(--encre) 32%,transparent);border-radius:var(--rayon);
padding:34px;display:flex;align-items:flex-start;justify-content:center}
.modale{width:430px;max-width:100%;background:var(--surface);border:1px solid var(--bordure);
border-radius:12px;box-shadow:var(--ombre-menu);padding:20px;display:flex;flex-direction:column;gap:12px}
.modale h3{font-size:15px;font-weight:700}
.modale .pied{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}
/* Équipes */
.equipes{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
@media(max-width:1000px){.equipes{grid-template-columns:1fr}}
.equipe{border:1px solid var(--bordure);border-radius:var(--rayon);padding:12px 14px;display:flex;
flex-direction:column;gap:8px;background:var(--surface)}
.equipe b{font-size:13.5px}
.equipe .zone{font-size:12px;color:var(--encre-2)}
</style>
<!-- ═══════════ BARRE DE REVUE ═══════════ -->
<header class="revue">
<b>SIOP V2 · Maquettes R1 — Référentiel</b>
<nav class="onglets" role="tablist" aria-label="Écrans">
<button role="tab" aria-selected="true" data-cible="sites">1 · Sites</button>
<button role="tab" aria-selected="false" data-cible="fiche-site">2 · Fiche site</button>
<button role="tab" aria-selected="false" data-cible="ascenseurs">3 · Ascenseurs</button>
<button role="tab" aria-selected="false" data-cible="nouvel-ascenseur">4 · Nouvel ascenseur</button>
<button role="tab" aria-selected="false" data-cible="etiquette">5 · Étiquette QR</button>
<button role="tab" aria-selected="false" data-cible="personnes">6 · Personnes & équipes</button>
<button role="tab" aria-selected="false" data-cible="categories">7 · Catégories</button>
</nav>
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
</header>
<!-- ═══════════ 1 · SITES ═══════════ -->
<p class="note-ecran" data-note="sites"><b>Intention :</b> le parc vu par la géographie — liste dense à gauche, <b>carte PostGIS</b> à droite (Leaflet/OSM dans l'app). Un site en alerte (OT « personne bloquée ») porte un pin rouge. La hiérarchie fine (halls, sous-sols) vit dans la fiche site, pas ici.</p>
<section class="ecran actif" id="sites">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Parc</div>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Sites</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher un site, une ville…</div>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
</div>
<div class="page">
<div class="entete-page">
<h1>Sites</h1><span class="filajout">12 sites · 34 ascenseurs</span>
<div class="actions"><button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouveau site</button></div>
</div>
<div class="filtres">
<span class="select">Ville : <b>Toutes</b></span>
<span class="select">Trier : <b>Ascenseurs ↓</b></span>
</div>
<div class="grille-2">
<div class="carte" style="padding:0">
<div class="table"><table>
<thead><tr><th>Site</th><th>Ascenseurs</th><th>Gardien</th><th>OT ouverts</th></tr></thead>
<tbody>
<tr><td><b>Tour Atlas</b><span class="sous">Bd de la Corniche, Casablanca</span></td><td class="num">6</td><td>Karim Doukkali</td><td><span class="prio bloque"><i></i>1 urgence</span></td></tr>
<tr><td><b>Résidence Al Manar</b><span class="sous">Bd Hassan II, Mohammedia</span></td><td class="num">4</td><td>Hassan Alami</td><td class="num">2</td></tr>
<tr><td><b>Anfa Place</b><span class="sous">Bd de l'Océan Atlantique, Casablanca</span></td><td class="num">5</td><td></td><td class="num">1</td></tr>
<tr><td><b>Clinique Yasmine</b><span class="sous">Rue Ibn Rochd, Casablanca</span></td><td class="num">3</td><td>Rachid Mansouri</td><td class="num">0</td></tr>
<tr><td><b>Marina Center</b><span class="sous">Av. des FAR, Mohammedia</span></td><td class="num">2</td><td></td><td class="num">0</td></tr>
</tbody>
</table></div>
</div>
<div class="geo">
<div class="voie" style="left:0;right:0;top:38%;height:8px;transform:rotate(-4deg)"></div>
<div class="voie" style="left:22%;top:0;bottom:0;width:6px;transform:rotate(8deg)"></div>
<div class="voie" style="left:58%;top:0;bottom:0;width:5px;transform:rotate(-12deg)"></div>
<div class="pin alerte" style="left:30%;top:34%"><i></i><b>Tour Atlas · 6</b></div>
<div class="pin" style="left:66%;top:26%"><i></i><b>Al Manar · 4</b></div>
<div class="pin" style="left:47%;top:62%"><i></i><b>Anfa Place · 5</b></div>
<div class="pin" style="left:76%;top:70%"><i></i><b>Clinique Yasmine · 3</b></div>
<div class="attribution">Carte : OpenStreetMap (app) — positions PostGIS</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════ 2 · FICHE SITE ═══════════ -->
<p class="note-ecran" data-note="fiche-site" hidden><b>Intention :</b> l'adresse, le contact terrain et la <b>hiérarchie d'emplacements</b> (site → hall/zone) avec les appareils rattachés. Règle métier : un ascenseur est rattaché à un emplacement ; un <b>organe</b>, lui, n'a jamais d'emplacement propre (il suit son ascenseur).</p>
<section class="ecran" id="fiche-site">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Parc</div>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Sites</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
</div>
<div class="page">
<div class="filajout"><a href="#">Sites</a> / Tour Atlas</div>
<div class="entete-page">
<h1>Tour Atlas</h1><span class="prio bloque"><i></i>1 urgence en cours</span>
<div class="actions">
<button class="btn">Modifier</button>
<button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouvel ascenseur</button>
</div>
</div>
<div class="grille-3">
<div class="carte" style="grid-column:span 2">
<h2>Identité</h2>
<dl class="infos">
<dt>Adresse</dt><dd>Bd de la Corniche, Aïn Diab — Casablanca</dd>
<dt>Position</dt><dd class="num">33.6062 N · 7.6706 O <span style="color:var(--encre-2)">(posée sur la carte)</span></dd>
<dt>Gardien</dt><dd>Karim Doukkali · <span class="num">06 61 23 45 67</span></dd>
<dt>Syndic</dt><dd>Atlas Property Management</dd>
<dt>Contrat</dt><dd><span class="st termine">Actif</span> · 6 appareils au préventif</dd>
</dl>
</div>
<div class="geo" style="min-height:200px">
<div class="voie" style="left:0;right:0;top:52%;height:7px;transform:rotate(-3deg)"></div>
<div class="pin" style="left:52%;top:48%"><i></i><b>Tour Atlas</b></div>
<div class="attribution">OSM (app)</div>
</div>
</div>
<div class="grille-2">
<div class="carte">
<h2>Emplacements & appareils</h2>
<ul class="arbre">
<li class="racine"><b>Tour Atlas</b><span class="meta">site</span><span class="fin">6 appareils</span></li>
<li><b>Hall principal</b><span class="meta">zone</span><span class="fin">Asc. A1 · A2</span></li>
<li><b>Tour bureaux (étages 1-24)</b><span class="meta">zone</span><span class="fin">Asc. B1 · <a href="#" style="color:var(--prio-bloque);font-weight:700">B2 ⚠</a></span></li>
<li><b>Parking sous-sol</b><span class="meta">zone</span><span class="fin">Monte-charge M1</span></li>
<li><b>Résidence (aile est)</b><span class="meta">zone</span><span class="fin">Asc. C1</span></li>
</ul>
<button class="btn ghost" style="margin-top:8px"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Ajouter un emplacement</button>
</div>
<div class="carte">
<h2>Ascenseurs du site</h2>
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg></span><a href="#" style="color:var(--primaire);font-weight:600">B2 — Schindler 3300</a><span class="fin"><span class="st arret">À l'arrêt</span></span></div>
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg></span><a href="#" style="color:var(--primaire);font-weight:600">A1 — Otis Gen2</a><span class="fin"><span class="st termine">En service</span></span></div>
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg></span><a href="#" style="color:var(--primaire);font-weight:600">A2 — Otis Gen2</a><span class="fin"><span class="st termine">En service</span></span></div>
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg></span><a href="#" style="color:var(--primaire);font-weight:600">M1 — Monte-charge Kone</a><span class="fin"><span class="st maintenance">En maintenance</span></span></div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════ 3 · ASCENSEURS ═══════════ -->
<p class="note-ecran" data-note="ascenseurs" hidden><b>Intention :</b> l'inventaire du parc, table dense « bureau ». Le <b>statut d'équipement</b> (En service / À l'arrêt / En maintenance) est distinct des statuts d'OT — un appareil à l'arrêt saute aux yeux. Référence cliquable → fiche validée en R0.</p>
<section class="ecran" id="ascenseurs">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Parc</div>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Sites</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher un appareil, un n° de série…</div>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
</div>
<div class="page">
<div class="entete-page">
<h1>Ascenseurs</h1><span class="filajout">34 appareils · 2 à l'arrêt</span>
<div class="actions">
<button class="btn">Exporter</button>
<button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouvel ascenseur</button>
</div>
</div>
<div class="filtres">
<span class="select">Site : <b>Tous</b></span>
<span class="select">Statut : <b>Tous</b></span>
<span class="select">Catégorie : <b>Toutes</b></span>
<span class="select">Marque : <b>Toutes</b></span>
</div>
<div class="carte" style="padding:0">
<div class="table"><table>
<thead><tr><th>Appareil</th><th>Marque · modèle</th><th>Site / emplacement</th><th>Catégorie</th><th>Statut</th><th>Prochaine grille</th></tr></thead>
<tbody>
<tr class="ligne-bloque"><td class="ref num">B2</td><td>Schindler 3300</td><td>Tour Atlas<span class="sous">Tour bureaux</span></td><td>Électrique</td><td><span class="st arret">À l'arrêt</span></td><td><span class="prio bloque"><i></i>Personne bloquée</span></td></tr>
<tr><td class="ref num">A1</td><td>Otis Gen2 Premier</td><td>Résidence Al Manar<span class="sous">Hall principal</span></td><td>Électrique</td><td><span class="st termine">En service</span></td><td class="num">1 août</td></tr>
<tr><td class="ref num">C1</td><td>Kone MonoSpace 500</td><td>Anfa Place<span class="sous">Galerie commerciale</span></td><td>Électrique</td><td><span class="st termine">En service</span></td><td class="num">1 août</td></tr>
<tr><td class="ref num">M1</td><td>Kone TranSys</td><td>Tour Atlas<span class="sous">Parking sous-sol</span></td><td>Monte-charge</td><td><span class="st maintenance">En maintenance</span></td><td class="num">12 août</td></tr>
<tr><td class="ref num">D1</td><td>ThyssenKrupp Evolution</td><td>Clinique Yasmine<span class="sous">Bloc A</span></td><td>Électrique</td><td><span class="st termine">En service</span></td><td class="num">3 août</td></tr>
<tr><td class="ref num">E2</td><td>Otis HydroFit</td><td>Marina Center<span class="sous">Hall B</span></td><td>Hydraulique</td><td><span class="st termine">En service</span></td><td class="num">9 août</td></tr>
</tbody>
</table></div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════ 4 · NOUVEL ASCENSEUR ═══════════ -->
<p class="note-ecran" data-note="nouvel-ascenseur" hidden><b>Intention :</b> le parcours de recette R1 — identité, rattachement (site → emplacement), puis <b>organes</b> ajoutés ligne à ligne. Un organe n'a pas d'emplacement propre : il suit son appareil (contrainte en base). Le QR est généré à la création.</p>
<section class="ecran" id="nouvel-ascenseur">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Parc</div>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Sites</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
</div>
<div class="page" style="max-width:860px">
<div class="filajout"><a href="#">Ascenseurs</a> / Nouvel ascenseur</div>
<div class="entete-page"><h1>Nouvel ascenseur</h1></div>
<div class="carte">
<h2>1 · Identité</h2>
<div class="form-grille">
<div class="champ"><label for="na-ref">Référence <em style="color:var(--danger)">*</em></label><input id="na-ref" placeholder="Ex. : B3"></div>
<div class="champ"><label for="na-serie">N° de série</label><input id="na-serie" placeholder="Plaque constructeur"></div>
<div class="champ"><label>Marque <em style="color:var(--danger)">*</em></label><div class="faux-select">Schindler <span></span></div></div>
<div class="champ"><label for="na-modele">Modèle</label><input id="na-modele" placeholder="Ex. : 3300"></div>
<div class="champ"><label for="na-mes">Mise en service</label><input id="na-mes" placeholder="JJ/MM/AAAA"></div>
<div class="champ"><label>Catégorie <em style="color:var(--danger)">*</em></label><div class="faux-select">Ascenseur électrique <span></span></div></div>
<div class="champ"><label for="na-charge">Charge (kg)</label><input id="na-charge" placeholder="630"></div>
<div class="champ"><label for="na-niv">Niveaux desservis</label><input id="na-niv" placeholder="8"></div>
</div>
</div>
<div class="carte">
<h2>2 · Rattachement</h2>
<div class="form-grille">
<div class="champ"><label>Site <em style="color:var(--danger)">*</em></label><div class="faux-select">Tour Atlas <span></span></div></div>
<div class="champ"><label>Emplacement dans le site</label><div class="faux-select">Tour bureaux (étages 1-24) <span></span></div></div>
</div>
</div>
<div class="carte">
<h2>3 · Organes <span style="font-weight:400;text-transform:none;letter-spacing:0">— suivis individuellement (pannes par organe, essais réglementaires)</span></h2>
<div class="ligne-organe"><div class="faux-select">Portes cabine <span></span></div><div class="faux-select" style="flex:2">Fermator 40/10</div><button class="btn-suppr" aria-label="Retirer"></button></div>
<div class="ligne-organe"><div class="faux-select">Treuil <span></span></div><div class="faux-select" style="flex:2">Schindler FMB130</div><button class="btn-suppr" aria-label="Retirer"></button></div>
<div class="ligne-organe"><div class="faux-select">Parachute <span></span></div><div class="faux-select vide" style="flex:2">Désignation…</div><button class="btn-suppr" aria-label="Retirer"></button></div>
<button class="btn ghost" style="margin-top:8px"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Ajouter un organe</button>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="btn">Annuler</button>
<button class="btn prim">Créer l'ascenseur (QR généré)</button>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════ 5 · ÉTIQUETTE QR ═══════════ -->
<p class="note-ecran" data-note="etiquette" hidden><b>Intention :</b> l'étiquette <b>imprimable</b> (A6, encre noire sur blanc — lisible et économique) collée en cabine : la marque, le QR, la consigne pour le gardien. Fond toujours blanc, même en thème sombre : c'est un objet papier.</p>
<section class="ecran" id="etiquette">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Parc</div>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Sites</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
</div>
<div class="page">
<div class="filajout"><a href="#">Ascenseurs</a> / <a href="#">Ascenseur A1</a> / Étiquette QR</div>
<div class="entete-page">
<h1>Étiquette cabine — Ascenseur A1</h1>
<div class="actions">
<button class="btn">Télécharger PDF</button>
<button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M6 9V3h12v6M6 18h12v3H6zM4 9h16a2 2 0 0 1 2 2v5h-4"/><path d="M4 16H2v-5a2 2 0 0 1 2-2"/></svg>Imprimer (A6)</button>
</div>
</div>
<div class="zone-impression">
<div class="etiquette">
<div class="tete"><span class="marque"><br></span><b>SIOP</b><span style="margin-left:auto;font-size:11px;color:#55647a">Maintenance d'ascenseurs<br>SPELEV · 05 22 00 00 00</span></div>
<div class="corps">
<svg class="qr-grand" width="120" height="120" viewBox="0 0 86 86" role="img" aria-label="QR code de l'ascenseur A1">
<rect width="86" height="86" fill="#fff"/>
<g fill="#1b2534">
<rect x="8" y="8" width="20" height="20"/><rect x="58" y="8" width="20" height="20"/><rect x="8" y="58" width="20" height="20"/>
<rect x="12" y="12" width="12" height="12" fill="#fff"/><rect x="62" y="12" width="12" height="12" fill="#fff"/><rect x="12" y="62" width="12" height="12" fill="#fff"/>
<rect x="15" y="15" width="6" height="6"/><rect x="65" y="15" width="6" height="6"/><rect x="15" y="65" width="6" height="6"/>
<rect x="34" y="10" width="4" height="4"/><rect x="42" y="10" width="8" height="4"/><rect x="34" y="18" width="4" height="8"/><rect x="46" y="18" width="4" height="4"/>
<rect x="10" y="34" width="4" height="4"/><rect x="18" y="34" width="8" height="4"/><rect x="30" y="34" width="4" height="8"/><rect x="42" y="34" width="4" height="4"/>
<rect x="50" y="34" width="4" height="8"/><rect x="58" y="38" width="8" height="4"/><rect x="70" y="34" width="4" height="4"/>
<rect x="34" y="46" width="8" height="4"/><rect x="46" y="46" width="4" height="4"/><rect x="58" y="50" width="4" height="4"/><rect x="66" y="46" width="8" height="4"/>
<rect x="34" y="58" width="4" height="8"/><rect x="42" y="62" width="8" height="4"/><rect x="58" y="58" width="8" height="8"/><rect x="70" y="70" width="6" height="6"/>
<rect x="42" y="70" width="4" height="6"/><rect x="50" y="70" width="4" height="4"/>
</g>
</svg>
<div>
<h3>Ascenseur A1</h3>
<div class="ou">Résidence Al Manar<br>Hall principal — Mohammedia</div>
</div>
</div>
<div class="consigne">Un problème ? Une personne bloquée ?<br>📱 Scannez ce code pour signaler</div>
<div class="ref">SIOP · A1 · OT-2020-4521 · siop2.apps.enset.top/q/a1</div>
</div>
<div class="carte" style="flex:1;min-width:260px">
<h2>Ce que fait le scan</h2>
<dl class="infos" style="grid-template-columns:96px 1fr">
<dt>Gardien</dt><dd>ouvre le <b>portail de signalement</b>, équipement prérempli (écran validé R0)</dd>
<dt>Technicien</dt><dd>connecté → ouvre la <b>fiche de l'appareil</b></dd>
<dt>Impression</dt><dd>A6 autocollant, noir sur blanc — testée à 300 dpi</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════ 6 · PERSONNES & ÉQUIPES ═══════════ -->
<p class="note-ecran" data-note="personnes" hidden><b>Intention :</b> l'admin <b>invite</b> (jamais de mot de passe créé pour autrui) : lien d'activation 7 jours, statut « Invitation envoyée » jusqu'à activation. Les équipes regroupent les techniciens par zone — l'assignation d'OT (R2) s'appuiera dessus. La modale est montrée ouverte.</p>
<section class="ecran" id="personnes">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Pilotage</div>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Statistiques</a>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Personnes</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher une personne…</div>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-1)">YA</span>
<div class="qui"><b>Yasmine Alaoui</b><br><small>Administratrice ▾</small></div></div>
</div>
<div class="page">
<div class="entete-page">
<h1>Personnes & équipes</h1>
<div class="actions">
<button class="btn">Gérer les rôles</button>
<button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Inviter</button>
</div>
</div>
<div class="carte" style="padding:0">
<div class="table"><table>
<thead><tr><th>Personne</th><th>Rôle</th><th>Équipe</th><th>Statut</th><th></th></tr></thead>
<tbody>
<tr><td><div style="display:flex;align-items:center;gap:9px"><span class="avatar" style="width:26px;height:26px;font-size:10.5px">SI</span><div><b>Salma Idrissi</b><span class="sous">salma@spelev.ma</span></div></div></td><td>Dispatcher</td><td></td><td><span class="st termine">Actif</span></td><td></td></tr>
<tr><td><div style="display:flex;align-items:center;gap:9px"><span class="avatar" style="width:26px;height:26px;font-size:10.5px;background:var(--graf-3)">AB</span><div><b>Ahmed Benali</b><span class="sous">ahmed@spelev.ma</span></div></div></td><td>Technicien</td><td>Casablanca Centre</td><td><span class="st termine">Actif</span></td><td></td></tr>
<tr><td><div style="display:flex;align-items:center;gap:9px"><span class="avatar" style="width:26px;height:26px;font-size:10.5px;background:var(--graf-2)">FZ</span><div><b>Fatima-Zahra Idrissi</b><span class="sous">fz.idrissi@spelev.ma</span></div></div></td><td>Technicienne</td><td>Mohammedia</td><td><span class="st attente">Invitation envoyée</span></td><td><button class="btn ghost" style="padding:4px 8px;font-size:12px">Renvoyer le lien</button></td></tr>
<tr><td><div style="display:flex;align-items:center;gap:9px"><span class="avatar" style="width:26px;height:26px;font-size:10.5px;background:var(--st-encours)">YT</span><div><b>Youssef Tazi</b><span class="sous">y.tazi@spelev.ma</span></div></div></td><td>Technicien limité</td><td>Casablanca Centre</td><td><span class="st termine">Actif</span></td><td></td></tr>
<tr><td><div style="display:flex;align-items:center;gap:9px"><span class="avatar" style="width:26px;height:26px;font-size:10.5px;background:var(--st-annule)">KD</span><div><b>Karim Doukkali</b><span class="sous">gardien · Tour Atlas</span></div></div></td><td>Demandeur</td><td></td><td><span class="st termine">Actif</span></td><td></td></tr>
</tbody>
</table></div>
</div>
<div class="carte">
<h2>Équipes</h2>
<div class="equipes">
<div class="equipe"><b>Casablanca Centre</b><span class="zone">Tour Atlas · Anfa Place · Clinique Yasmine</span><div class="avatars"><span class="avatar" style="background:var(--graf-3)">AB</span><span class="avatar" style="background:var(--st-encours)">YT</span></div></div>
<div class="equipe"><b>Mohammedia</b><span class="zone">Résidence Al Manar · Marina Center</span><div class="avatars"><span class="avatar" style="background:var(--graf-2)">FZ</span></div></div>
<div class="equipe" style="border-style:dashed;align-items:center;justify-content:center;color:var(--encre-3)"><b style="font-weight:600">+ Nouvelle équipe</b></div>
</div>
</div>
<div class="voile">
<div class="modale">
<h3>Inviter une personne</h3>
<div class="champ"><label for="inv-email">Email professionnel</label><input id="inv-email" placeholder="prenom.nom@spelev.ma"></div>
<div class="form-grille">
<div class="champ"><label>Rôle</label><div class="faux-select">Technicien <span></span></div></div>
<div class="champ"><label>Équipe (optionnel)</label><div class="faux-select">Casablanca Centre <span></span></div></div>
</div>
<div style="font-size:12px;color:var(--encre-2);background:var(--surface-2);border-radius:8px;padding:8px 10px">
La personne recevra un <b>lien d'activation valable 7 jours</b> pour choisir son mot de passe. Aucun compte n'est actif avant cela.
</div>
<div class="pied"><button class="btn">Annuler</button><button class="btn prim">Envoyer l'invitation</button></div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════ 7 · CATÉGORIES ═══════════ -->
<p class="note-ecran" data-note="categories" hidden><b>Intention :</b> les référentiels administrables du parc — catégories d'équipement et types d'organes. Simples listes : renommer, ajouter, désactiver (jamais supprimer si utilisé). Les référentiels du bilan codé arriveront en R2 sur le même modèle.</p>
<section class="ecran" id="categories">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Parc</div>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Sites</a>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 7h16M4 12h16M4 17h10"/></svg>Catégories</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-1)">YA</span>
<div class="qui"><b>Yasmine Alaoui</b><br><small>Administratrice ▾</small></div></div>
</div>
<div class="page">
<div class="entete-page"><h1>Catégories</h1></div>
<div class="grille-2">
<div class="carte">
<h2>Catégories d'équipement</h2>
<div class="item-liste">Ascenseur électrique<span class="fin"><span class="num">26 appareils</span> · <a href="#" style="color:var(--primaire)">Renommer</a></span></div>
<div class="item-liste">Ascenseur hydraulique<span class="fin"><span class="num">4</span> · <a href="#" style="color:var(--primaire)">Renommer</a></span></div>
<div class="item-liste">Monte-charge<span class="fin"><span class="num">3</span> · <a href="#" style="color:var(--primaire)">Renommer</a></span></div>
<div class="item-liste">EPMR (plateforme PMR)<span class="fin"><span class="num">1</span> · <a href="#" style="color:var(--primaire)">Renommer</a></span></div>
<div class="ligne-organe" style="border:0;margin-top:6px"><input placeholder="Nouvelle catégorie…" style="flex:1;padding:8px 11px;border:1px solid var(--bordure-forte);border-radius:8px;font:inherit;background:var(--surface);color:var(--encre)"><button class="btn prim">Ajouter</button></div>
</div>
<div class="carte">
<h2>Types d'organes</h2>
<div class="item-liste">Portes cabine / palières<span class="fin"><span class="num">34</span></span></div>
<div class="item-liste">Treuil / machinerie<span class="fin"><span class="num">34</span></span></div>
<div class="item-liste">Parachute<span class="fin"><span class="num">31</span></span></div>
<div class="item-liste">Armoire de commande<span class="fin"><span class="num">34</span></span></div>
<div class="item-liste">Boutons & signalisation<span class="fin"><span class="num">29</span></span></div>
<div class="item-liste" style="color:var(--encre-3)">Limiteur de vitesse <span style="font-size:11px">(désactivé)</span><span class="fin"><a href="#" style="color:var(--primaire)">Réactiver</a></span></div>
<div class="ligne-organe" style="border:0;margin-top:6px"><input placeholder="Nouveau type…" style="flex:1;padding:8px 11px;border:1px solid var(--bordure-forte);border-radius:8px;font:inherit;background:var(--surface);color:var(--encre)"><button class="btn prim">Ajouter</button></div>
</div>
</div>
<div class="carte" style="border-style:dashed;color:var(--encre-2);font-size:13px">
Une catégorie <b>utilisée</b> ne peut pas être supprimée — seulement renommée ou désactivée (elle disparaît des sélecteurs, l'historique reste lisible).
</div>
</div>
</div>
</div>
</section>
<script>
(function(){
var onglets=document.querySelectorAll('.revue .onglets button');
var ecrans=document.querySelectorAll('.ecran');
var notes=document.querySelectorAll('.note-ecran');
onglets.forEach(function(b){b.addEventListener('click',function(){
onglets.forEach(function(x){x.setAttribute('aria-selected','false')});
b.setAttribute('aria-selected','true');
var cible=b.dataset.cible;
ecrans.forEach(function(e){e.classList.toggle('actif',e.id===cible)});
notes.forEach(function(n){n.hidden=(n.dataset.note!==cible)});
window.scrollTo({top:0});
})});
var ordre=['auto','light','dark'];var i=0;
var bt=document.getElementById('btn-theme');
bt.addEventListener('click',function(){
i=(i+1)%3;var m=ordre[i];
if(m==='auto'){document.documentElement.removeAttribute('data-theme')}
else{document.documentElement.setAttribute('data-theme',m)}
bt.textContent='Thème : '+(m==='auto'?'auto':(m==='light'?'clair':'sombre'));
});
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,755 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SIOP V2 — Maquettes HD · R2 Exploitation</title>
</head>
<body>
<!--
SIOP V2 — Maquettes HD de la release R2 « Exploitation » (design-first).
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE APPLICATIF R2 (principe n°1).
Complète les écrans DÉJÀ VALIDÉS en R0 (maquette-web.html : tableau de bord,
liste OT, fiche OT avec bilan codé, portail demandeur) — non redessinés ici.
CSS répliquée des maquettes validées ; ajouts R2 dans le dernier bloc <style>.
-->
<style>
/* ═══ Tokens (répliques de ../tokens.css) ═══ */
:root{
--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;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--police-ui:"Manrope","Avenir Next","Segoe UI Variable",system-ui,sans-serif;
--rayon:10px;--rayon-controle:8px;--ombre-menu:0 8px 24px rgb(16 30 54/.14);
--transition:140ms ease-out;
}
@media (prefers-color-scheme:dark){:root{
--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;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}}
:root[data-theme="light"]{
--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;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--ombre-menu:0 8px 24px rgb(16 30 54/.14);
}
:root[data-theme="dark"]{
--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;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}
/* ═══ Base ═══ */
*{box-sizing:border-box;margin:0}
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;border:0;background:none;color:inherit}
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}
/* ═══ Barre de revue (outil maquette, pas l'app) ═══ */
.revue{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:12px;flex-wrap:wrap;
padding:10px 16px;background:var(--nav-fond);color:var(--nav-encre);font-size:13px}
.revue b{color:var(--nav-encre-vif);letter-spacing:.02em}
.revue .onglets{display:flex;gap:4px;flex-wrap:wrap}
.revue .onglets button{padding:6px 10px;border-radius:6px;color:var(--nav-encre)}
.revue .onglets button:hover{background:rgb(255 255 255/.08);color:var(--nav-encre-vif)}
.revue .onglets button[aria-selected="true"]{background:rgb(255 255 255/.12);color:var(--nav-encre-vif);
box-shadow:inset 3px 0 0 var(--safran)}
.revue .droite{margin-left:auto;display:flex;align-items:center;gap:8px}
.revue .theme{padding:6px 10px;border:1px solid var(--nav-bordure);border-radius:6px}
.note-ecran{max-width:1280px;margin:14px auto 0;padding:0 20px;color:var(--encre-2);font-size:13px}
.note-ecran b{color:var(--encre)}
/* ═══ Coquille applicative ═══ */
.ecran{display:none;max-width:1280px;margin:14px auto 40px;padding:0 20px}
.ecran.actif{display:block}
.app{display:grid;grid-template-columns:232px 1fr;min-height:720px;border:1px solid var(--bordure);
border-radius:14px;overflow:hidden;background:var(--fond)}
.app.pleine{grid-template-columns:1fr}
/* 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:.55;font-size:11px;color:var(--safran)}
.logo b{font-size:19px;letter-spacing:.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:.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:3px solid transparent;transition:background var(--transition)}
.nav a:hover{background:rgb(255 255 255/.06);color:var(--nav-encre-vif)}
.nav a.actif{background:rgb(255 255 255/.09);color:var(--nav-encre-vif);border-left-color:var(--safran)}
.nav a .badge-nav{margin-left:auto;background:var(--prio-bloque);color:#fff;font-size:11px;
padding:1px 7px;border-radius:999px;font-weight:700}
.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:8px;padding:7px 12px;color:var(--encre-3);font-size:13px}
.recherche svg{width:15px;height:15px}
.chip-urgence{display:flex;align-items:center;gap:7px;background:var(--prio-bloque-fond);
color:var(--prio-bloque);border:1px solid color-mix(in srgb,var(--prio-bloque) 30%,transparent);
padding:6px 12px;border-radius:999px;font-weight:700;font-size:12.5px}
.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}}
.compte{margin-left:auto;display:flex;align-items:center;gap:10px}
.chip-demo{font-size:11px;font-weight:700;letter-spacing:.05em;color:var(--safran);
background:var(--safran-doux);padding:3px 9px;border-radius:999px;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}
.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:-.01em;text-wrap:balance}
.entete-page .actions{margin-left:auto;display:flex;gap:8px}
.filajout{color:var(--encre-3);font-size:12.5px}
.filajout a{color:var(--encre-2);text-decoration:none}
/* Boutons */
.btn{display:inline-flex;align-items:center;gap:7px;padding:8px 14px;border-radius:8px;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(.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:.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:.04em;text-transform:uppercase;color:var(--encre-2);margin-bottom:12px}
.grille-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.grille-3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
@media(max-width:900px){.grille-2,.grille-3{grid-template-columns:1fr}}
/* Tuiles KPI */
.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}
@media(max-width:1100px){.kpis{grid-template-columns:repeat(2,1fr)}}
.kpi{background:var(--surface);border:1px solid var(--bordure);border-radius:var(--rayon);padding:14px 16px}
.kpi .lib{font-size:11.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--encre-2);font-weight:600}
.kpi .val{font-size:27px;font-weight:700;letter-spacing:-.02em;margin-top:4px}
.kpi .delta{font-size:12px;font-weight:600;margin-top:2px}
.delta.bon{color:var(--succes)}.delta.mauvais{color:var(--danger)}.delta.neutre{color:var(--encre-3)}
.kpi.alarme{border-color:color-mix(in srgb,var(--danger) 45%,transparent)}
.kpi.alarme .val{color:var(--danger)}
.anneau{display:flex;align-items:center;gap:12px}
.anneau svg{flex:none}
/* Pastilles statut / priorité */
.st{display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;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}
.prio.bloque{color:var(--prio-bloque)}.prio.bloque i{background:var(--prio-bloque);animation:pulse 1.4s ease-out infinite}
.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)}
/* Bandeau urgence */
.bandeau-urgence{display:flex;align-items:center;gap:14px;background:var(--prio-bloque-fond);
border:1px solid color-mix(in srgb,var(--prio-bloque) 35%,transparent);border-left:4px solid var(--prio-bloque);
border-radius:var(--rayon);padding:12px 16px}
.bandeau-urgence b{color:var(--prio-bloque)}
.bandeau-urgence .depuis{color:var(--encre-2);font-size:12.5px}
/* Tables */
.table{width:100%;overflow-x:auto}
.table table{width:100%;font-size:13.5px}
.table th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);text-align:left;
padding:9px 12px;border-bottom:1px solid var(--bordure);background:var(--surface-2);white-space:nowrap}
.table td{padding:9px 12px;border-bottom:1px solid var(--bordure);vertical-align:middle}
.table tbody tr:hover{background:var(--primaire-doux)}
.table .ref{font-weight:700;color:var(--primaire)}
.table .sous{color:var(--encre-2);font-size:12px;display:block}
.ligne-bloque td:first-child{box-shadow:inset 3px 0 0 var(--prio-bloque)}
.avatars{display:flex}
.avatars .avatar{width:24px;height:24px;font-size:10px;border:2px solid var(--surface);margin-left:-6px}
.avatars .avatar:first-child{margin-left:0}
/* Filtres */
.filtres{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.select{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--bordure-forte);
border-radius:8px;padding:7px 11px;font-size:13px;background:var(--surface);color:var(--encre-2)}
.select b{color:var(--encre);font-weight:600}
/* Graphiques */
.graf{display:flex;flex-direction:column;gap:10px}
.barres-h .ligne-b{display:grid;grid-template-columns:110px 1fr 40px;align-items:center;gap:10px;font-size:13px}
.barres-h .fond-b{background:var(--graf-grille);border-radius:4px;height:14px;overflow:hidden}
.barres-h .rempli{height:100%;border-radius:4px 0 0 4px}
.barres-h .valeur{text-align:right;font-weight:600}
.legende{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--encre-2)}
.legende span{display:inline-flex;align-items:center;gap:6px}
.legende i{width:9px;height:9px;border-radius:3px}
/* Connexion */
.conn{min-height:640px;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:8px;
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:.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:8px;background:var(--surface);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)}
/* Fiche : listes de définitions */
.infos{display:grid;grid-template-columns:130px 1fr;row-gap:9px;font-size:13.5px}
.infos dt{color:var(--encre-2)}
.infos dd{font-weight:500}
.infos a{color:var(--primaire);text-decoration:none;font-weight:600}
.chrono{list-style:none;display:flex;flex-direction:column;gap:0;font-size:13px}
.chrono li{position:relative;padding:0 0 16px 22px}
.chrono li::before{content:"";position:absolute;left:5px;top:5px;width:8px;height:8px;border-radius:50%;
background:var(--bordure-forte)}
.chrono li.fait::before{background:var(--primaire)}
.chrono li::after{content:"";position:absolute;left:8.5px;top:15px;bottom:-2px;width:1px;background:var(--bordure)}
.chrono li:last-child::after{display:none}
.chrono .quand{color:var(--encre-3);font-size:11.5px}
.check{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--bordure);font-size:13.5px}
.check:last-of-type{border-bottom:0}
.coche{width:19px;height:19px;border-radius:5px;border:1.5px solid var(--bordure-forte);flex:none;
display:flex;align-items:center;justify-content:center;color:#fff}
.coche.ok{background:var(--succes);border-color:var(--succes)}
.coche.na{background:var(--encre-3);border-color:var(--encre-3)}
.check .qui-fait{margin-left:auto;color:var(--encre-3);font-size:12px}
.total{display:flex;justify-content:space-between;font-weight:700;padding-top:10px;font-size:14px}
/* Bilan codé */
.bilan{display:grid;grid-template-columns:1fr 1fr;gap:10px 14px}
@media(max-width:900px){.bilan{grid-template-columns:1fr}}
.bilan .champ-b{font-size:12.5px}
.bilan .champ-b label{color:var(--encre-2);font-weight:600;display:block;margin-bottom:4px}
.bilan .champ-b label em{color:var(--danger);font-style:normal}
.faux-select{display:flex;justify-content:space-between;align-items:center;border:1px solid var(--bordure-forte);
border-radius:8px;padding:8px 11px;background:var(--surface);color:var(--encre)}
.faux-select.vide{color:var(--encre-3)}
/* Organes / documents */
.item-liste{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--bordure);font-size:13.5px}
.item-liste:last-child{border-bottom:0}
.item-liste .ico{width:34px;height:34px;border-radius:8px;background:var(--surface-2);display:flex;
align-items:center;justify-content:center;color:var(--encre-2);flex:none}
.item-liste .ico svg{width:16px;height:16px;stroke-width:1.75}
.item-liste .fin{margin-left:auto;display:flex;align-items:center;gap:10px;color:var(--encre-2);font-size:12.5px}
.qr{display:flex;gap:16px;align-items:center}
.qr svg{border:1px solid var(--bordure);border-radius:8px;background:#fff;padding:8px;flex:none}
/* Portail demandeur */
.portail{max-width:430px;margin:28px auto;display:flex;flex-direction:column;gap:14px}
.gros-toggle{display:flex;align-items:center;gap:12px;background:var(--prio-bloque-fond);
border:1px solid color-mix(in srgb,var(--prio-bloque) 30%,transparent);border-radius:10px;padding:12px 14px}
.gros-toggle b{color:var(--prio-bloque)}
.interrupteur{margin-left:auto;width:44px;height:24px;border-radius:999px;background:var(--bordure-forte);position:relative;flex:none}
.interrupteur::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff}
.zone-photo{border:1.5px dashed var(--bordure-forte);border-radius:10px;padding:18px;text-align:center;color:var(--encre-2);font-size:13px}
.suivi-simple{display:flex;align-items:center;gap:0;font-size:11.5px;color:var(--encre-3);margin-top:6px}
.suivi-simple .etape{display:flex;align-items:center;gap:6px}
.suivi-simple .etape.fait{color:var(--succes);font-weight:700}
.suivi-simple .lien-e{width:26px;height:1px;background:var(--bordure-forte);margin:0 6px}
/* Mobile */
.tels{display:flex;gap:24px;justify-content:center;flex-wrap:wrap}
.tel{width:300px;border:1px solid var(--bordure-forte);border-radius:28px;overflow:hidden;background:var(--fond);
box-shadow:var(--ombre-menu)}
.tel .encoche{height:26px;background:var(--nav-fond);display:flex;justify-content:center;align-items:center}
.tel .encoche i{width:70px;height:7px;border-radius:999px;background:rgb(255 255 255/.18)}
.tel .ecran-tel{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:520px}
.tel h3{font-size:16px;font-weight:700;display:flex;align-items:center;gap:8px}
.tel h3 .sync{margin-left:auto;font-size:10.5px;font-weight:700;color:var(--st-termine);
background:var(--st-termine-fond);padding:2px 8px;border-radius:999px}
.tel h3 .sync.off{color:var(--st-attente);background:var(--st-attente-fond)}
.carte-ot-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:12px;padding:12px}
.carte-ot-tel .type-t{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);
font-weight:700;display:flex;justify-content:space-between;align-items:center}
.carte-ot-tel b{display:block;margin:3px 0 2px;font-size:14.5px}
.carte-ot-tel .ou{color:var(--encre-2);font-size:12.5px}
.btn-tel{display:flex;justify-content:center;align-items:center;gap:8px;padding:12px;border-radius:10px;
font-weight:700;font-size:14.5px;background:var(--primaire);color:#fff;width:100%}
.btn-tel.vert{background:var(--succes)}
.btn-tel.gris{background:var(--bordure-forte);color:var(--surface)}
.paire-boutons{display:flex;gap:8px}
.btn-choix{flex:1;text-align:center;padding:9px;border:1.5px solid var(--bordure-forte);border-radius:9px;font-weight:600;font-size:13px}
.btn-choix.actif{border-color:var(--primaire);background:var(--primaire-doux);color:var(--primaire)}
.viseur{flex:1;min-height:330px;border-radius:12px;background:
radial-gradient(circle at 50% 45%, #2c3a52 0%, #131c2c 70%);position:relative;display:flex;
align-items:center;justify-content:center}
.viseur .cadre-qr{width:170px;height:170px;border-radius:14px;border:2.5px solid var(--safran);
box-shadow:0 0 0 2000px rgb(9 14 22/.45)}
.viseur p{position:absolute;bottom:14px;color:#dfe7f2;font-size:12.5px}
/* Design system : 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:8px;border:1px solid var(--bordure);margin-bottom:4px}
</style>
<style>
/* ═══ Ajouts R1 (à reverser dans l'app après validation) ═══ */
/* Statuts d'ÉQUIPEMENT (distincts des statuts OT) */
.st.arret{color:var(--danger);background:var(--prio-bloque-fond)}
.st.maintenance{color:var(--st-encours);background:var(--st-encours-fond)}
/* Carte géographique (placeholder — Leaflet/OSM + PostGIS dans l'app) */
.geo{position:relative;border:1px solid var(--bordure);border-radius:var(--rayon);overflow:hidden;
background:
linear-gradient(var(--graf-grille) 1px,transparent 1px),
linear-gradient(90deg,var(--graf-grille) 1px,transparent 1px),
linear-gradient(160deg,var(--surface-2),var(--surface));
background-size:34px 34px,34px 34px,100% 100%;min-height:320px}
.geo .voie{position:absolute;background:var(--bordure);border-radius:2px}
.geo .pin{position:absolute;display:flex;flex-direction:column;align-items:center;transform:translate(-50%,-100%)}
.geo .pin i{width:26px;height:26px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);
background:var(--primaire);border:2px solid var(--surface);box-shadow:var(--ombre-menu)}
.geo .pin i::after{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--surface)}
.geo .pin.alerte i{background:var(--prio-bloque)}
.geo .pin b{margin-top:5px;font-size:11px;background:var(--surface);border:1px solid var(--bordure);
padding:2px 7px;border-radius:999px;white-space:nowrap}
.geo .attribution{position:absolute;right:8px;bottom:6px;font-size:10px;color:var(--encre-3)}
/* Arborescence des emplacements */
.arbre{list-style:none;padding:0;font-size:13.5px}
.arbre li{position:relative;padding:7px 0 7px 26px;border-bottom:1px solid var(--bordure)}
.arbre li:last-child{border-bottom:0}
.arbre li::before{content:"";position:absolute;left:8px;top:0;bottom:0;width:1px;background:var(--bordure)}
.arbre li::after{content:"";position:absolute;left:8px;top:50%;width:12px;height:1px;background:var(--bordure)}
.arbre li.racine{padding-left:0}
.arbre li.racine::before,.arbre li.racine::after{display:none}
.arbre .n2{margin-left:22px}
.arbre .meta{color:var(--encre-2);font-size:12px;margin-left:8px}
.arbre .fin{float:right;color:var(--encre-2);font-size:12px}
/* Formulaires R1 */
.form-grille{display:grid;grid-template-columns:1fr 1fr;gap:12px 14px}
@media(max-width:900px){.form-grille{grid-template-columns:1fr}}
.form-grille .pleine-l{grid-column:1/-1}
.ligne-organe{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid var(--bordure)}
.ligne-organe:last-of-type{border-bottom:0}
.ligne-organe .faux-select{flex:1}
.btn-suppr{color:var(--encre-3);padding:6px;border-radius:6px}
.btn-suppr:hover{color:var(--danger);background:var(--prio-bloque-fond)}
/* Étiquette QR imprimable (A6) */
.zone-impression{display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap}
.etiquette{width:340px;background:#fff;color:#1b2534;border:1px solid var(--bordure-forte);
border-radius:12px;padding:22px;display:flex;flex-direction:column;gap:12px;
box-shadow:var(--ombre-menu)}
.etiquette .tete{display:flex;align-items:center;gap:10px;border-bottom:2px solid #1b2534;padding-bottom:10px}
.etiquette .tete .marque{display:flex;flex-direction:column;line-height:.55;font-size:12px;color:#dd8a0b}
.etiquette .tete b{font-size:20px;letter-spacing:.04em}
.etiquette .corps{display:flex;gap:16px;align-items:center}
.etiquette .qr-grand{flex:none;border:1px solid #dce3ec;border-radius:8px;padding:6px;background:#fff}
.etiquette h3{font-size:15px;font-weight:700;margin:0}
.etiquette .ou{font-size:12px;color:#55647a}
.etiquette .consigne{font-size:12.5px;background:#fdf3e3;border:1px dashed #dd8a0b;color:#8a5a06;
border-radius:8px;padding:8px 10px;text-align:center;font-weight:600}
.etiquette .ref{font-size:11px;color:#8494ab;text-align:center;font-variant-numeric:tabular-nums}
/* Modale (invitation) — présentée ouverte dans la maquette */
.voile{background:color-mix(in srgb,var(--encre) 32%,transparent);border-radius:var(--rayon);
padding:34px;display:flex;align-items:flex-start;justify-content:center}
.modale{width:430px;max-width:100%;background:var(--surface);border:1px solid var(--bordure);
border-radius:12px;box-shadow:var(--ombre-menu);padding:20px;display:flex;flex-direction:column;gap:12px}
.modale h3{font-size:15px;font-weight:700}
.modale .pied{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}
/* Équipes */
.equipes{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
@media(max-width:1000px){.equipes{grid-template-columns:1fr}}
.equipe{border:1px solid var(--bordure);border-radius:var(--rayon);padding:12px 14px;display:flex;
flex-direction:column;gap:8px;background:var(--surface)}
.equipe b{font-size:13.5px}
.equipe .zone{font-size:12px;color:var(--encre-2)}
</style>
<style>
/* ═══ Ajouts R2 (à reverser dans l'app après validation) ═══ */
/* Statuts de DEMANDE (parcours sans jargon : Reçue → Approuvée → Résolue) */
.st.recue{color:var(--st-ouvert);background:var(--st-ouvert-fond)}
.st.approuvee{color:var(--st-encours);background:var(--st-encours-fond)}
.st.rejetee{color:var(--st-annule);background:var(--st-annule-fond)}
.st.resolue{color:var(--st-termine);background:var(--st-termine-fond)}
/* Pastille de périodicité (gabarits préventif) */
.periode{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:700;
color:var(--primaire);background:var(--primaire-doux);padding:2px 9px;border-radius:999px}
.periode.reglementaire{color:var(--safran);background:var(--safran-doux)}
/* Panneau d'approbation (drawer statique dans la maquette) */
.panneau{border:1px solid var(--bordure);border-radius:var(--rayon);background:var(--surface);
padding:16px 18px;display:flex;flex-direction:column;gap:12px}
.panneau .photo{height:120px;border-radius:8px;background:
linear-gradient(160deg,var(--surface-2),var(--bordure));display:flex;align-items:center;
justify-content:center;color:var(--encre-3);font-size:12.5px}
/* Résumé de génération mensuelle */
.gen-resume{display:flex;gap:14px;flex-wrap:wrap}
.gen-resume .bloc{flex:1;min-width:130px;border:1px solid var(--bordure);border-radius:var(--rayon);
padding:12px 14px;text-align:center}
.gen-resume .bloc b{display:block;font-size:24px;letter-spacing:-.02em}
.gen-resume .bloc span{font-size:11.5px;color:var(--encre-2);text-transform:uppercase;letter-spacing:.05em}
/* Relevés de compteurs */
.releve-saisie{display:flex;gap:8px;align-items:center}
.releve-saisie input{width:130px;padding:8px 11px;border:1px solid var(--bordure-forte);
border-radius:8px;font:inherit;background:var(--surface);color:var(--encre);text-align:right}
</style>
<!-- ═══════════ BARRE DE REVUE ═══════════ -->
<header class="revue">
<b>SIOP V2 · Maquettes R2 — Exploitation</b>
<nav class="onglets" role="tablist" aria-label="Écrans">
<button role="tab" aria-selected="true" data-cible="demandes">1 · Demandes</button>
<button role="tab" aria-selected="false" data-cible="nouvel-ot">2 · Nouvel OT</button>
<button role="tab" aria-selected="false" data-cible="preventif">3 · Préventif</button>
<button role="tab" aria-selected="false" data-cible="ot-preventif">4 · OT préventif (checklist)</button>
<button role="tab" aria-selected="false" data-cible="compteurs">5 · Compteurs</button>
</nav>
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
</header>
<!-- ═══════════ 1 · DEMANDES ═══════════ -->
<p class="note-ecran" data-note="demandes"><b>Intention :</b> la file d'attente de Salma. Une demande <b>approuvée devient un OT</b> (lien 1-1, jamais de doublon) ; un rejet exige un motif — le demandeur le lit sur son portail, sans jargon. « Personne bloquée » saute en tête, partout.</p>
<section class="ecran actif" id="demandes">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Exploitation</div>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Tableau de bord</a>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Ordres de travail</a>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M8 10h8M8 14h5M21 12a9 9 0 1 1-4-7.5"/></svg>Demandes<span class="badge-nav">3</span></a>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Préventif</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
<span class="chip-urgence"><span class="point-pulse"></span>1 personne bloquée</span>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
</div>
<div class="page">
<div class="entete-page">
<h1>Demandes</h1><span class="filajout">3 à traiter · 12 ce mois</span>
</div>
<div class="grille-2" style="grid-template-columns:1.4fr 1fr">
<div class="carte" style="padding:0"><div class="table"><table>
<thead><tr><th></th><th>Signalement</th><th>Équipement · Site</th><th>Par</th><th>Statut</th></tr></thead>
<tbody>
<tr class="ligne-bloque"><td class="ref num">DEM-2026-0112</td><td><b>Personne bloquée en cabine !</b></td><td>Asc. B2<span class="sous">Tour Atlas</span></td><td>Karim (QR)<span class="sous">il y a 4 min</span></td><td><span class="prio bloque"><i></i>Urgence</span></td></tr>
<tr style="background:var(--primaire-doux)"><td class="ref num">DEM-2026-0111</td><td>Porte du 3ᵉ ne se ferme plus</td><td>Asc. A1<span class="sous">Résidence Al Manar</span></td><td>Karim (QR)<span class="sous">il y a 2 h</span></td><td><span class="st recue">Reçue</span></td></tr>
<tr><td class="ref num">DEM-2026-0110</td><td>Voyant étage éteint</td><td>Asc. C1<span class="sous">Anfa Place</span></td><td>H. Alami (QR)<span class="sous">hier</span></td><td><span class="st recue">Reçue</span></td></tr>
<tr><td class="ref num">DEM-2026-0107</td><td>Bruit anormal en gaine</td><td>Asc. A1<span class="sous">Résidence Al Manar</span></td><td>Karim (QR)<span class="sous">hier</span></td><td><span class="st approuvee">OT-2026-0341</span></td></tr>
<tr><td class="ref num">DEM-2026-0104</td><td>Odeur de brûlé (fausse alerte)</td><td>Asc. M1<span class="sous">Tour Atlas</span></td><td>Karim (QR)<span class="sous">11 juil.</span></td><td><span class="st rejetee">Rejetée</span></td></tr>
<tr><td class="ref num">DEM-2026-0093</td><td>Voyant cabine éteint</td><td>Asc. A1<span class="sous">Résidence Al Manar</span></td><td>Karim (QR)<span class="sous">2 juil.</span></td><td><span class="st resolue">Résolue</span></td></tr>
</tbody>
</table></div></div>
<div class="panneau">
<h2 style="font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--encre-2)">DEM-2026-0111 — approbation</h2>
<dl class="infos" style="grid-template-columns:110px 1fr">
<dt>Équipement</dt><dd><a href="#">Ascenseur A1</a> — Résidence Al Manar</dd>
<dt>Signalé par</dt><dd>Karim Doukkali (gardien) · via QR cabine</dd>
<dt>Description</dt><dd>« La porte ne se ferme plus au 3ᵉ étage, il faut la retenir à la main. »</dd>
</dl>
<div class="photo">📷 photo-porte-3e.jpg (fournie par le gardien)</div>
<div class="champ"><label>Priorité de l'OT</label><div class="faux-select">Haute <span></span></div></div>
<div class="champ"><label>Assigner à</label><div class="faux-select">Ahmed Benali — équipe Mohammedia <span></span></div></div>
<div style="display:flex;gap:8px;justify-content:flex-end">
<button class="btn danger-l">Rejeter (motif requis)</button>
<button class="btn prim">Approuver → créer l'OT</button>
</div>
<p style="font-size:12px;color:var(--encre-3)">L'OT reprend titre, équipement, photo et description — la demande suit ensuite l'OT (Reçu → Intervention → Résolu côté portail).</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════ 2 · NOUVEL OT ═══════════ -->
<p class="note-ecran" data-note="nouvel-ot" hidden><b>Intention :</b> l'OT manuel (sans demande) en une carte : type, équipement, priorité — « personne bloquée » est un choix explicite qui déclenche l'urgence partout. Échéance par défaut selon la priorité.</p>
<section class="ecran" id="nouvel-ot">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Exploitation</div>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Tableau de bord</a>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Ordres de travail</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
</div>
<div class="page" style="max-width:760px">
<div class="filajout"><a href="#">Ordres de travail</a> / Nouvel OT</div>
<div class="entete-page"><h1>Nouvel ordre de travail</h1></div>
<div class="carte">
<div class="form-grille">
<div class="champ pleine-l"><label for="not-titre">Objet <em style="color:var(--danger)">*</em></label><input id="not-titre" placeholder="Ex. : Remplacement contact de porte"></div>
<div class="champ"><label>Type <em style="color:var(--danger)">*</em></label><div class="faux-select">Dépannage <span></span></div></div>
<div class="champ"><label>Équipement <em style="color:var(--danger)">*</em></label><div class="faux-select">B2 — Tour Atlas <span></span></div></div>
<div class="champ"><label>Priorité <em style="color:var(--danger)">*</em></label><div class="faux-select"><span class="prio haute" style="font-size:12.5px"><i></i>Haute</span> <span></span></div></div>
<div class="champ"><label for="not-echeance">Échéance</label><input id="not-echeance" value="18/07/2026" class="num"></div>
<div class="champ pleine-l"><label for="not-desc">Description</label><input id="not-desc" placeholder="Constat, contexte, consignes…"></div>
<div class="champ"><label>Assigner à</label><div class="faux-select">Ahmed Benali <span></span></div></div>
<div class="champ"><label>Équipe</label><div class="faux-select vide"><span></span></div></div>
</div>
<div class="gros-toggle" style="margin-top:14px"><b>Personne bloquée dans la cabine ?</b><span style="margin-left:6px;font-size:12px;color:var(--encre-2)">passe l'OT en urgence absolue (bandeau + tête de liste)</span><span class="interrupteur" role="switch" aria-checked="false" aria-label="Personne bloquée"></span></div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px">
<button class="btn">Annuler</button>
<button class="btn prim">Créer l'OT (statut Ouvert)</button>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════ 3 · PRÉVENTIF ═══════════ -->
<p class="note-ecran" data-note="preventif" hidden><b>Intention :</b> le réglementaire sans tableur — des <b>gabarits de tâches à périodicité</b> (l'essai parachute est marqué réglementaire), et la <b>génération du mois</b> : un OT « grille du mois » par appareil sous contrat, avec les tâches dues. <b>Regénérer ne double rien</b> ; un appareil jamais contrôlé reçoit le premier contrôle (tout).</p>
<section class="ecran" id="preventif">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Exploitation</div>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Tableau de bord</a>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Ordres de travail</a>
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M8 10h8M8 14h5M21 12a9 9 0 1 1-4-7.5"/></svg>Demandes</a>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Préventif</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-1)">YA</span>
<div class="qui"><b>Yasmine Alaoui</b><br><small>Administratrice ▾</small></div></div>
</div>
<div class="page">
<div class="entete-page">
<h1>Préventif</h1>
<div class="actions"><button class="btn prim">Générer la grille de juillet</button></div>
</div>
<div class="gen-resume">
<div class="bloc"><b class="num">32 / 41</b><span>grilles générées</span></div>
<div class="bloc"><b class="num">25</b><span>terminées</span></div>
<div class="bloc"><b class="num" style="color:var(--danger)">3</b><span>en retard</span></div>
<div class="bloc"><b class="num">2</b><span>premiers contrôles</span></div>
</div>
<div class="carte" style="border-left:3px solid var(--succes)">
<span style="font-size:13px">✓ Dernière génération : <b>1ᵉʳ juillet, 06 h 00</b> (automatique) — 32 OT créés, 0 doublon. Regénérer maintenant ne créerait que les grilles des <b>9 appareils ajoutés depuis</b>.</span>
</div>
<div class="carte" style="padding:0">
<div class="table"><table>
<thead><tr><th>Tâche du gabarit</th><th>Organe visé</th><th>Périodicité</th><th>Juillet</th></tr></thead>
<tbody>
<tr><td>Contrôle fermeture / verrouillage des portes</td><td>Portes cabine / palières</td><td><span class="periode">Tous les mois</span></td><td><span class="st termine">Due</span></td></tr>
<tr><td>Nettoyage cuvette et toit de cabine</td><td></td><td><span class="periode">Tous les mois</span></td><td><span class="st termine">Due</span></td></tr>
<tr><td>Contrôle niveau d'huile réducteur</td><td>Treuil / machinerie</td><td><span class="periode">Tous les 3 mois</span></td><td><span class="st termine">Due</span></td></tr>
<tr><td>Vérification jeu des coulisseaux</td><td></td><td><span class="periode">Tous les 6 mois</span></td><td><span class="st annule">Non due</span></td></tr>
<tr><td><b>Essai du parachute</b></td><td>Parachute</td><td><span class="periode reglementaire">12 mois · réglementaire</span></td><td><span class="st termine">Due</span></td></tr>
<tr><td>Contrôle câbles de traction (usure, tension)</td><td>Treuil / machinerie</td><td><span class="periode">Tous les 6 mois</span></td><td><span class="st annule">Non due</span></td></tr>
</tbody>
</table></div>
</div>
<div class="carte" style="border-style:dashed;color:var(--encre-2);font-size:13px">
Le gabarit s'applique à tout appareil <b>sous contrat préventif</b>. Un appareil sans historique reçoit
un OT « <b>premier contrôle</b> » : toutes les tâches, quelle que soit la périodicité.
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════ 4 · OT PRÉVENTIF (CHECKLIST) ═══════════ -->
<p class="note-ecran" data-note="ot-preventif" hidden><b>Intention :</b> la grille du mois d'Ahmed — chaque tâche se règle en <b>Fait / N-A</b> (avec qui et quand) ; la <b>clôture est bloquée</b> tant qu'une tâche reste sans réponse. Le bilan codé reste requis (écran fiche OT validé en R0).</p>
<section class="ecran" id="ot-preventif">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Exploitation</div>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><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>Ordres de travail</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-3)">AB</span>
<div class="qui"><b>Ahmed Benali</b><br><small>Technicien ▾</small></div></div>
</div>
<div class="page">
<div class="filajout"><a href="#">Ordres de travail</a> / OT-2026-0338</div>
<div class="entete-page">
<h1>Grille du mois — juillet · Ascenseur A1</h1>
<span class="st encours">En cours</span>
<div class="actions"><button class="btn succes" disabled>Clôturer (2 tâches sans réponse)</button></div>
</div>
<div class="grille-2">
<div class="carte">
<h2>Checklist du mois <span style="color:var(--encre-3);text-transform:none;letter-spacing:0">— 4 / 6 traitées</span></h2>
<div class="check"><span class="coche ok"></span>Contrôle fermeture / verrouillage des portes<span class="qui-fait">Ahmed · 09 h 12</span></div>
<div class="check"><span class="coche ok"></span>Nettoyage cuvette et toit de cabine<span class="qui-fait">Ahmed · 09 h 40</span></div>
<div class="check"><span class="coche na">N-A</span>Contrôle niveau d'huile réducteur <span style="color:var(--encre-3);font-size:12px">(gearless — sans objet)</span><span class="qui-fait">Ahmed · 09 h 41</span></div>
<div class="check"><span class="coche ok"></span>Essai du parachute<span class="qui-fait">Ahmed · 10 h 25</span></div>
<div class="check"><span class="coche"></span>Contrôle boutons cabine &amp; paliers<span class="qui-fait"></span></div>
<div class="check"><span class="coche"></span>Vérification éclairage de secours<span class="qui-fait"></span></div>
<p style="margin-top:10px;font-size:12.5px;color:var(--alerte)">⚠ La clôture reste bloquée : chaque tâche doit être <b>Fait</b> ou <b>N-A</b>.</p>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div class="carte">
<h2>Intervention</h2>
<dl class="infos">
<dt></dt><dd class="num">OT-2026-0338 · Maintenance</dd>
<dt>Équipement</dt><dd><a href="#">Ascenseur A1 — Otis Gen2</a></dd>
<dt>Généré le</dt><dd class="num">1ᵉʳ juillet (grille mensuelle)</dd>
<dt>Échéance</dt><dd class="num">31 juillet 2026</dd>
<dt>Assigné</dt><dd>Ahmed Benali</dd>
</dl>
</div>
<div class="carte">
<h2>Bilan d'intervention</h2>
<p style="font-size:12.5px;color:var(--encre-2)">Requis aussi pour la grille (écran « fiche OT » validé en R0) — prérempli : Action réalisée = <b>Visite d'entretien</b>.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══════════ 5 · COMPTEURS ═══════════ -->
<p class="note-ecran" data-note="compteurs" hidden><b>Intention :</b> les relevés simples du carnet (heures de marche, démarrages) — saisis en fiche appareil ou en tournée. R2 les stocke et trace ; les seuils d'alerte viendront nourrir le préventif conditionnel (backlog).</p>
<section class="ecran" id="compteurs">
<div class="app"><aside class="nav">
<div class="logo"><span class="marque"><br></span><div><b>SIOP</b></div></div>
<div class="groupe">Parc</div>
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
</aside>
<div class="contenu">
<div class="topbar">
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-3)">AB</span>
<div class="qui"><b>Ahmed Benali</b><br><small>Technicien ▾</small></div></div>
</div>
<div class="page" style="max-width:860px">
<div class="filajout"><a href="#">Ascenseurs</a> / <a href="#">Ascenseur A1</a> / Compteurs</div>
<div class="entete-page"><h1>Compteurs — Ascenseur A1</h1></div>
<div class="grille-2">
<div class="carte">
<h2>Heures de marche</h2>
<div class="releve-saisie">
<input value="12 480" class="num" aria-label="Nouveau relevé heures de marche"><span style="color:var(--encre-2);font-size:13px">h</span>
<button class="btn prim">Enregistrer le relevé</button>
</div>
<ul class="chrono" style="margin-top:14px">
<li class="fait"><span class="num">12 411 h</span> — relevé mensuel<br><span class="quand">1ᵉʳ juillet · Ahmed (grille)</span></li>
<li class="fait"><span class="num">12 322 h</span> — relevé mensuel<br><span class="quand">2 juin · Fatima-Zahra</span></li>
<li class="fait"><span class="num">12 246 h</span> — relevé mensuel<br><span class="quand">3 mai · Ahmed</span></li>
</ul>
</div>
<div class="carte">
<h2>Démarrages</h2>
<div class="releve-saisie">
<input value="1 844 210" class="num" aria-label="Nouveau relevé démarrages"><span style="color:var(--encre-2);font-size:13px">cycles</span>
<button class="btn prim">Enregistrer le relevé</button>
</div>
<ul class="chrono" style="margin-top:14px">
<li class="fait"><span class="num">1 831 970</span><br><span class="quand">1ᵉʳ juillet · Ahmed (grille)</span></li>
<li class="fait"><span class="num">1 815 400</span><br><span class="quand">2 juin · Fatima-Zahra</span></li>
</ul>
</div>
</div>
<div class="carte" style="border-style:dashed;color:var(--encre-2);font-size:13px">
Un relevé <b>inférieur au précédent</b> est refusé (compteur croissant). Les seuils d'alerte
(préventif conditionnel) sont au backlog v2 — R2 stocke et trace.
</div>
</div>
</div>
</div>
</section>
<script>
(function(){
var onglets=document.querySelectorAll('.revue .onglets button');
var ecrans=document.querySelectorAll('.ecran');
var notes=document.querySelectorAll('.note-ecran');
onglets.forEach(function(b){b.addEventListener('click',function(){
onglets.forEach(function(x){x.setAttribute('aria-selected','false')});
b.setAttribute('aria-selected','true');
var cible=b.dataset.cible;
ecrans.forEach(function(e){e.classList.toggle('actif',e.id===cible)});
notes.forEach(function(n){n.hidden=(n.dataset.note!==cible)});
window.scrollTo({top:0});
})});
var ordre=['auto','light','dark'];var i=0;
var bt=document.getElementById('btn-theme');
bt.addEventListener('click',function(){
i=(i+1)%3;var m=ordre[i];
if(m==='auto'){document.documentElement.removeAttribute('data-theme')}
else{document.documentElement.setAttribute('data-theme',m)}
bt.textContent='Thème : '+(m==='auto'?'auto':(m==='light'?'clair':'sombre'));
});
})();
</script>
</body>
</html>

View File

@@ -50,13 +50,144 @@ model User {
| `demo-login` refuse tout compte `isDemo=false` | service auth (+ test e2e) | | `demo-login` refuse tout compte `isDemo=false` | service auth (+ test e2e) |
| Matrice complète : chaque rôle a une ligne par catégorie d'objet | seed idempotent (+ test) | | Matrice complète : chaque rôle a une ligne par catégorie d'objet | seed idempotent (+ test) |
## R1 — Référentiel
```prisma
enum CategoryKind { EQUIPMENT COMPONENT_TYPE } // référentiels administrables
enum AssetStatus { IN_SERVICE OUT_OF_SERVICE UNDER_MAINTENANCE }
model Category { // renommable/désactivable, JAMAIS supprimée si utilisée
id String @id @default(uuid()) @db.Uuid
kind CategoryKind
name String
isActive Boolean @default(true)
@@unique([kind, name])
}
model Location { // site (parentId null) → zone (1 niveau max, vérifié service)
id String @id @default(uuid()) @db.Uuid
name String
parentId String? @db.Uuid // auto-relation « LocationTree »
address String? city String?
guardianName String? guardianPhone String?
latitude Float? longitude Float? // saisies par la carte
// + colonne PostGIS générée (migration SQL) :
// position geography(Point,4326) GENERATED ALWAYS AS (ST_Point(longitude,latitude)::geography) STORED
}
model Asset { // l'appareil (ascenseur, monte-charge…)
id String @id @default(uuid()) @db.Uuid
reference String @unique // « A1 », « B2 » — imprimée sur le QR
brand String model String? serialNumber String?
commissionedAt DateTime? loadKg Int? floors Int?
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
categoryId String @db.Uuid // Category(kind=EQUIPMENT)
locationId String @db.Uuid // rattachement obligatoire
components AssetComponent[]
}
model AssetComponent { // organe — PAS de colonne emplacement : la règle
id String @id @default(uuid()) @db.Uuid // « un organe n'a pas d'emplacement
assetId String @db.Uuid // propre » est garantie PAR
typeId String @db.Uuid // CONSTRUCTION (table dédiée),
designation String? // plus besoin du CHECK v1
}
model Team { // équipes par zone — l'assignation d'OT (R2) s'appuiera dessus
id String @id @default(uuid()) @db.Uuid
name String @unique
description String?
members User[] // m2m implicite
}
// User (R0) reçoit : phone?, teams Team[], et l'invitation (ADR maquettes R1) :
// activationToken String? @unique + activationExpiresAt DateTime?
// statut dérivé : invité = passwordHash null && token présent ; actif = hash présent
```
**Invariants R1** :
| Invariant | Où il vit |
| --- | --- |
| Hiérarchie d'emplacements limitée à 2 niveaux (site → zone) | service locations (+ test) |
| Un organe n'a pas d'emplacement propre | par construction (AssetComponent sans locationId) |
| `Category(kind)` cohérente avec l'usage (EQUIPMENT sur Asset, COMPONENT_TYPE sur organe) | services (+ test) |
| Catégorie utilisée : jamais supprimée (désactivation seulement) | service categories (+ test) |
| Lien d'activation : 7 jours, usage unique, aucun compte actif avant | service users (+ test e2e) |
| Position : lat/lng saisis, colonne PostGIS **générée** pour les requêtes spatiales futures | migration SQL |
## R2 — Exploitation
```prisma
enum WorkOrderType { CORRECTIVE PREVENTIVE WORKS } // Dépannage / Maintenance / Travaux
enum WorkOrderStatus { OPEN IN_PROGRESS ON_HOLD DONE CANCELLED }
enum WorkOrderPriority { NONE LOW MEDIUM HIGH PERSON_TRAPPED } // personne bloquée = priorité, pas statut
enum RequestStatus { RECEIVED APPROVED REJECTED } // « Résolue » = dérivé (OT lié DONE)
enum ChecklistState { PENDING DONE NA }
enum BilanField { DOOR_STATE CABIN_POSITION ANOMALY EXTERNAL_CAUSE ACTION_TAKEN COMPONENT_CONCERNED }
enum MeterKind { RUNNING_HOURS STARTS }
model WorkOrder { // machine à états STRICTE (service, table des transitions)
id String @id … reference String @unique // OT-2026-0341 (séquence + retry)
title String description String?
type WorkOrderType status WorkOrderStatus @default(OPEN)
priority WorkOrderPriority @default(NONE)
assetId → Asset dueDate DateTime?
assignees User[] ("WorkOrderAssignees") createdById → User?
startedAt? completedAt? cancelledAt?
events WorkOrderEvent[] checklist ChecklistItem[]
report InterventionReport? request Request?
}
model WorkOrderEvent { // activité chronologique : commentaires + traces de transition
id, workOrderId (cascade), kind String, message String?, byId → User?, createdAt
}
model Request { // demande — converge vers l'OT (lien 1-1, jamais de doublon)
id, reference @unique // DEM-2026-0112
description, isPersonTrapped Boolean @default(false)
status RequestStatus @default(RECEIVED) rejectionReason String? // motif REQUIS au rejet
assetId → Asset requestedById → User? requesterName String? // portail public R2.4
workOrderId String? @unique → WorkOrder
}
model ReferenceValue { // référentiels ADMINISTRABLES du bilan codé (un par champ)
id, field BilanField, label String, isActive Boolean @default(true)
@@unique([field, label])
}
model InterventionReport { // bilan codé 6 champs — 1-1 avec l'OT
id, workOrderId @unique (cascade), note String?
doorStateId? cabinPositionId? anomalyId? externalCauseId? actionTakenId? componentConcernedId?
// requis pour clôturer : DOOR_STATE, ACTION_TAKEN, COMPONENT_CONCERNED (maquette)
}
model TaskTemplate { // gabarit du préventif (période calendaire en mois)
id, label, componentTypeId? → Category, periodMonths Int, isRegulatory Boolean, isActive
}
model ChecklistItem { // la grille du mois d'un OT préventif
id, workOrderId (cascade), label, state ChecklistState @default(PENDING)
templateId? → TaskTemplate doneById? → User doneAt?
}
model Meter { id, assetId → Asset, kind MeterKind, @@unique([assetId, kind]) }
model MeterReading { id, meterId (cascade), value Int, readById? → User, createdAt }
```
**Invariants R2** :
| Invariant | Où il vit |
| --- | --- |
| Transitions : OPEN→(IN_PROGRESS·CANCELLED) ; IN_PROGRESS→(ON_HOLD·DONE·CANCELLED) ; ON_HOLD→(IN_PROGRESS·CANCELLED) ; DONE/CANCELLED terminaux | service work-orders (+ tests) |
| **Garde de clôture** : bilan (3 champs requis) + checklist sans PENDING | service (transition → DONE) |
| Demande approuvée = **un seul** OT (1-1), rejet ⇒ motif obligatoire | contrainte @unique + service |
| « Voir autre » : sans `canViewOther`, un rôle ne voit que SES objets (assigné/créateur/demandeur) | services (scoping des listes + accès) |
| ReferenceValue/TaskTemplate utilisés : désactivables, jamais supprimés | services (même règle que Category) |
| Relevé de compteur strictement croissant | service meters |
| Génération mensuelle idempotente ; appareil sans historique ⇒ premier contrôle (toutes tâches) | service préventif (R2.2, + tests) |
## À venir (référence v1 éprouvée, sera réintroduit release par release) ## À venir (référence v1 éprouvée, sera réintroduit release par release)
- **R1** : `Location` (hiérarchie + colonne PostGIS), `Asset` + organes (hiérarchie 2 niveaux,
CHECK « un organe n'a pas d'emplacement propre »), `Category`, `Team`.
- **R2** : `WorkOrder` (machine à états stricte, priorité « personne bloquée »), `Request`
(lien 1-1 vers OT), `InterventionReport` (bilan codé 6 champs → `ReferenceValue`),
`TaskTemplate`/`PreventivePlan`/`ChecklistItem` (périodicité calendrier), `Meter`.
- **R3** : `Part`/`StockMovement` (stock **dérivé des mouvements**), `PurchaseOrder`, - **R3** : `Part`/`StockMovement` (stock **dérivé des mouvements**), `PurchaseOrder`,
`Partner`, `LaborTime` (taux figé), `Document`. `Partner`, `LaborTime` (taux figé), `Document`.
- **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile). - **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile).

View File

@@ -4,6 +4,185 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
--- ---
## 2026-07-16 — Pr. Daaif (+ Claude) — 🏁 R2 CLOSE : recettée, déployée, taguée
**Actions**
- **Recette R2 prononcée par le référent** (une anomalie détectée et corrigée en cours de recette — voir entrée précédente : tri des « Interventions récentes »).
- **Déploiement vérifié en ligne** : portail public `/q/A1` opérationnel (résolution QR 200), 13 OT avec l'urgence en tête, préventif de juillet généré sur l'instance (8 grilles, 7 premiers contrôles).
- DoD complète → **tag `release/r2`**. Le carnet papier est remplacé : signalement QR sans compte → OT → bilan codé → clôture gardée → suivi, plus préventif idempotent et compteurs.
**Prochaine étape** : R3 Gestion — design d'abord : maquettes stock/BC/tiers/coûts OT/analytics/bibliothèque à produire et faire valider.
---
## 2026-07-16 — Pr. Daaif (+ Claude) — Recette R2 : anomalie du référent corrigée
**Anomalie (recette du référent)** : un OT créé, assigné à Ahmed, traité et clôturé apparaissait dans le tableau de bord d'Ahmed mais PAS dans celui de Salma ni de l'administrateur.
**Diagnostic** : la liste API triait **par statut d'abord** (les Terminés relégués en queue) et le tableau de bord affiche les 5 premières lignes. Ahmed, sans « voir autre », a une liste courte → son OT clôturé restait dans le top 5 ; Salma et l'admin voient tout le parc → l'OT clôturé était éjecté des « Interventions récentes ». (La liste OT filtrait aussi « actifs » par défaut, conforme à la maquette — c'est le tableau de bord qui trompait.)
**Correctif** : la liste trie par **dernière activité** (`updatedAt` desc) — un OT qui vient d'être clôturé remonte en tête pour tout le monde ; les urgences « personne bloquée » **actives** restent épinglées au sommet (un OT d'urgence annulé ne squattait plus la tête, corrigé au passage). Reproduit avant / vérifié après sur le scénario exact du référent ; **test de régression** ajouté à la recette e2e (position de l'OT clôturé dans la liste de Salma ≤ nombre d'urgences actives). 50 tests API + 11 Playwright verts.
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.4 : portail public QR — l'étiquette prend vie
**Actions**
- **Trois routes publiques `/portal`** (les seules routes @Public métier de l'API), **throttlées** (`@nestjs/throttler` : 10 signalements/min, 60 lectures/min — première surface sans compte) : résolution du QR (`référence → appareil`), signalement (retourne un **jeton de suivi opaque**), suivi par `référence + jeton` (pas de jeton, pas de lecture — aucune énumération possible).
- **Suivi sans compte** : `Request.publicToken` (migration `r2_portail`) ; le téléphone du gardien garde `[référence, jeton]` en localStorage (10 max) ; étapes sans jargon **Reçu → Intervention → Résolu** dérivées de l'OT lié ; un rejet s'affiche « Sans suite : {motif} ».
- **Page `/q/{réf}`** fidèle à l'écran 6 validé R0 (mobile d'abord) : équipement prérempli « détecté par le QR », gros interrupteur personne bloquée, description, photo annoncée (upload → R3), messages d'erreur en français métier (QR inconnu, throttling).
- **e2e Playwright** : LA boucle produit — le gardien signale sans compte sur `/q/A1`, l'équipe traite (approbation → démarrage → bilan → clôture via API), le gardien recharge et voit ✓ Résolu. 11 tests Playwright verts, 50 tests API, 52 opérations au contrat. Générateur OpenAPI : les paramètres non-`id` ne sont plus typés uuid.
**Décisions**
- Le portail n'expose JAMAIS de liste : lecture uniquement par jeton individuel.
- Throttling au niveau du contrôleur portail seulement (le reste de l'API est derrière JWT).
**R2 est fonctionnellement complète** (OT, bilan codé, demandes, portail QR, préventif, compteurs). Prochaine étape : recette R2 avec le référent (revue pixel ↔ maquettes R0+R2), déploiement, tag `release/r2`.
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.3 : écrans web de l'exploitation
**Actions**
- **7 écrans/évolutions fidèles aux maquettes validées** : Liste OT (filtres, strie rouge, « immédiat »), **Fiche OT** (boutons de transition issus d'`allowedTransitions`, clôture grisée avec l'explication de la garde, bilan codé 6 selects alimentés par les référentiels, checklist Fait→N-A→à faire au clic, activité chronologique + commentaires, assignation, annulation motivée), Nouvel OT (interrupteur « personne bloquée » qui force la priorité), Demandes (table + panneau d'approbation, rejet en modale à motif), Préventif (4 tuiles réelles, bouton générer avec résumé « regénérer ne double rien », gabarits administrables), Compteurs (saisie + historique), **Tableau de bord réel** (bandeau urgence cliquable, KPIs, OT par statut, interventions récentes ; coûts et pannes par organe annoncés R3).
- **L'urgence traverse l'app** : chip pulsante dans la topbar (rafraîchie 60 s), badges de nav (urgences OT, demandes à traiter), bandeau dashboard, tête de liste. Fiche appareil : historique réel + accès compteurs. Accueil dédié aux rôles sans exploitation (Demandeur).
- **Trou de conception débusqué par l'e2e** : le Demandeur n'a pas `ASSETS.view` → son sélecteur d'équipement était vide. Correctif : `GET /assets/options` (authentification seule — les références sont affichées en cabine). 49 opérations au contrat.
- **Deux fragilités réelles corrigées dans la génération du préventif** : collision de référence (course avec une création d'OT) désormais RETENTÉE au lieu de sauter silencieusement un appareil ; appareil supprimé entre lecture et écriture toléré (P2003). 3 runs Jest complets consécutifs verts.
- **9 tests Playwright verts** dont la recette R2 officielle : Karim signale → Salma approuve+assigne → OT → démarrage → clôture bloquée visible → bilan → clôture → la demande de Karim passe « Résolue » ; + préventif idempotent via l'UI, urgence bout-en-bout, compteur croissant. 50 tests API (94,6 % / 78,9 %).
**Prochaine étape** : R2.4 — portail public QR (`/q/{ref}` → signalement prérempli sans compte, suivi sans jargon — écran validé R0), puis recette R2 + déploiement + tag.
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.2 : préventif (génération idempotente) + compteurs
**Actions**
- **Modèle** (migration `r2_preventif_compteurs`) : `Asset.underContract` (contrat préventif, maquette fiche appareil) et `WorkOrder.periodKey` avec **unicité `[assetId, periodKey]` : l'idempotence de la génération est EN BASE**, pas seulement dans le code — deux générations concurrentes ne peuvent pas doubler une grille.
- **Génération mensuelle** (`POST /preventive/generate`, 200 idempotent) : une grille par appareil sous contrat ; tâches dues par périodicité **ancrée sur la mise en service** (mensuelles toujours dues) ; appareil sans historique préventif → OT « **Premier contrôle** » avec toutes les tâches ; échéance = fin de mois ; événement GENERATED tracé. Déclenchement manuel en R2 (bouton maquette) — l'automatisation cron/BullMQ viendra avec le durcissement production.
- Gabarits administrables (ajout/renommage/désactivation — un gabarit désactivé sort des générations suivantes) ; `GET /preventive/status` (générées, terminées, en retard, premiers contrôles du mois).
- **Compteurs** : `GET /assets/{id}/meters` (les 2 compteurs, relevés récents d'abord) ; `POST /assets/{id}/meter-readings`**relevé strictement croissant**, refus motivé sinon.
- Contrat : +7 opérations (48). **50 tests verts** (couverture 94,7 % / 78 %) : idempotence (8 grilles seed, jamais 16), premier contrôle vs grille normale, mois anniversaire (mars 2031 pour A1 : les 3/6/12 mois tombent ensemble), gabarit désactivé exclu, compteur qui refuse de redescendre. Smoke test prod : juillet 2026 généré (9 grilles, 8 premiers contrôles), 2ᵉ appel à zéro.
**Leçon**
- Les specs Jest partagent la base en parallèle : les assertions e2e doivent porter sur **leurs propres données** (ici les 8 appareils du seed), jamais sur des comptages globaux.
**Prochaine étape** : R2.3 — écrans web de l'exploitation (liste/fiche OT, demandes+approbation, nouvel OT, préventif, checklist, compteurs, chip urgence + bandeau tableau de bord).
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.1 : socle backend de l'exploitation
**Actions**
- **Modèle R2** (doc + migration `r2_exploitation`, 10 tables) : WorkOrder (référence séquentielle, horodatages par état), WorkOrderEvent (activité), Request (1-1 vers OT, motif de rejet), ReferenceValue (référentiels du bilan par champ), InterventionReport (6 FK nommées), TaskTemplate/ChecklistItem, Meter/MeterReading.
- **Contrat** : 15 opérations (41 total) — la **table des transitions** et les champs requis du bilan vivent dans `@siop/shared` (une seule loi pour l'API et l'UI) ; la fiche OT expose `allowedTransitions` et `closureBlockers` (messages métier).
- **API** : machine à états stricte (transition → DONE refusée si bilan incomplet OU checklist avec tâche sans réponse — messages « quoi faire », charte §7) ; approbation de demande = création d'OT lié en 1-1 (double traitement → 409) ; rejet à motif obligatoire ; **scoping « voir autre »** appliqué aux listes ET aux accès directs (404, pas de fuite) ; valeurs de bilan validées champ par champ.
- **Seed** : 31 valeurs de référentiels (6 champs), 8 gabarits de préventif (parachute réglementaire), 4 OT et 4 demandes rejouant la maquette, compteurs d'A1.
- **45 tests verts** (couverture 95 % / 79 % branches) dont la **recette officielle rejouée** : demande Karim → approbation Salma → OT assigné Ahmed → démarrage → clôture refusée sans bilan → bilan codé → clôture → Karim voit sa demande résolue. Smoke test build prod.
**Décisions**
- `POST` de transition/rejet répondent **200** (le contrat prime sur le défaut 201 de Nest — c'est le contrat qui a raison).
- La priorité « personne bloquée » trie en tête côté API : aucun client ne peut l'oublier.
**Prochaine étape** : R2.2 — génération mensuelle du préventif (BullMQ, idempotente, premier contrôle) + API compteurs, puis R2.3 écrans web.
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R2 ouverte : maquettes des écrans manquants à valider
**Actions**
- **R2 « Exploitation » ouverte — design d'abord.** Les écrans cœur sont déjà validés depuis R0 (tableau de bord, liste OT, fiche OT avec bilan codé 6 champs et garde de clôture, portail demandeur) ; `maquette-r2.html` complète avec les **5 écrans manquants** : Demandes (file + panneau d'approbation : approuver → OT 1-1, rejeter avec motif requis), Nouvel OT (interrupteur « personne bloquée » explicite), Préventif (gabarits à périodicité — parachute marqué réglementaire — génération du mois **idempotente**, premiers contrôles), OT préventif (checklist Fait/N-A, clôture bloquée si tâche sans réponse), Compteurs (relevés croissants, historique).
- Rendu vérifié (5 écrans + thème sombre, zéro erreur).
**Décisions (proposées à la validation)**
- Statuts de DEMANDE distincts (Reçue / Approuvée→OT lié / Rejetée / Résolue) réutilisant la sémantique chromatique des statuts OT ; à trancher : est-ce une entorse à « réservés » de la charte ou une extension cohérente ?
- Un rejet de demande **exige un motif** (lisible côté portail, sans jargon).
- Génération mensuelle : automatique le 1ᵉʳ à 06 h + bouton manuel ; **regénérer ne crée que le manquant** ; appareil sans historique → OT « premier contrôle » (toutes les tâches).
- Relevé de compteur strictement croissant (refus sinon).
**⛔ Bloquant** : validation des maquettes R2 par le référent avant toute ligne de code R2.
**→ Levé le 16/07/2026 : maquettes R2 et les 4 décisions VALIDÉES par le référent** (statuts de demande = extension cohérente, actée). Lancement R2.1 (socle backend exploitation).
---
## 2026-07-16 — Pr. Daaif (+ Claude) — 🏁 R1 CLOSE : recettée, déployée, taguée
**Actions**
- **Recette R1 prononcée par le référent** (écrans ↔ maquette-r1, deux thèmes).
- **Déploiement vérifié en ligne** (<https://siop2.apps.enset.top>) : migration `r1_referentiel` + seed appliqués au boot du conteneur — 5 sites, 8 appareils, écrans /sites servis ; parcours API admin vérifié (locations, assets).
- DoD complète (recette, CI 6 jobs verts, revue pixel, production, journal) → **tag `release/r1`**.
**Prochaine étape** : R2 Exploitation. Maquettes R0 déjà validées pour tableau de bord, liste OT, fiche OT (bilan codé), portail demandeur ; il restera à maquetter le préventif (gabarits, grille du mois, checklist) avant tout code R2 — design d'abord.
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R1.2 : écrans web du référentiel
**Actions**
- **7 écrans fidèles à maquette-r1.html** : Sites (liste + **carte Leaflet/OSM réelle**, pins maquette, création en modale avec position posée au clic), Fiche site (arbre site→zones, appareils, ajout de zone), Ascenseurs (table dense, filtres site/statut, strie rouge sur appareil à l'arrêt), Fiche appareil (écran validé R0 : identité, organes ± ajout/retrait, changement de statut, **QR réel** ; historique R2 et documents R3 annoncés sans simulation), Nouvel ascenseur (identité → rattachement → organes ligne à ligne), **Étiquette A6 imprimable** (`window.print` n'imprime qu'elle, objet papier même en sombre), Personnes & équipes (invitation → **lien d'activation affiché à copier** — pas d'email en R1, décision explicite ; renvoi de lien ; équipes), Catégories (ajout, renommage inline, désactivation).
- **Page /activation** (cible du lien d'invitation, moule visuel de la connexion) — la personne choisit son mot de passe et entre directement.
- **Navigation pilotée par la matrice** : les entrées livrées n'apparaissent que si `canView` (Catégories exige SETTINGS) ; boutons de création/édition conditionnés par `can()` — l'API re-vérifie de toute façon.
- **e2e Playwright** : la **recette R1 officielle rejouée intégralement** (site → zone → appareil+organe → étiquette → invitation → activation du compte → l'invité est connecté) + un parcours « la matrice pilote l'UI » (Technicien : lecture sans boutons) ; purge idempotente des données de test en globalSetup. 5 tests verts, 4 vitest, build prod, lint.
**Leçon**
- Paquet workspace CJS + Vite : les exports nommés runtime échouent (pas de pré-bundle des paquets liés) → alias Vite `@siop/shared`**source TypeScript** ; l'API garde le dist CJS. Symptôme vicieux : app blanche, tests e2e tous rouges.
**Prochaine étape** : R1.3 — recette R1 avec le référent (revue pixel écrans ↔ maquette-r1), déploiement (CI → Dokploy), tag `release/r1`. Puis R2 Exploitation (maquettes déjà validées pour OT/demandes/portail).
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R1.1 : socle backend du référentiel
**Actions**
- **Modèle R1** (doc + Prisma + migration `r1_referentiel`) : Category (kind EQUIPMENT/COMPONENT_TYPE), Location (site → zone, lat/lng + **colonne PostGIS générée** `geography(Point,4326)` + index GIST), Asset (statut d'équipement), AssetComponent (organe **sans emplacement par construction**), Team (m2m User), invitation sur User (token unique + expiration). Migration **autosuffisante** (`CREATE EXTENSION IF NOT EXISTS postgis`).
- **Contrat** : 21 nouvelles opérations (26 au total) — categories/locations/assets+organes/teams/users/roles/invitations/activate ; générateur OpenAPI étendu aux paramètres de chemin ; spec + client web régénérés dans le même commit.
- **API** : 4 nouveaux modules + gestion des personnes, tous sous `@RequirePermission` (la matrice décide) ; invariants en service : profondeur 2, kinds de catégories, catégorie jamais supprimée, lien d'activation 7 jours à usage unique qui connecte directement.
- **Seed** : parc de la maquette (5 sites + 8 zones, 8 appareils dont B2 à l'arrêt et M1 en maintenance, organes d'A1/B2, 9 catégories, 2 équipes).
- **36 tests verts** (couverture 96 % stmts / 85 % branches) : parcours de recette site→zone→appareil→organes, profondeur 3 refusée, matrice vivante (Technicien lit mais ne crée pas), invitation→activation complète (lien périmé/consommé/renvoyé). Smoke test sur build de prod : sites avec compteurs, fiche A1 et ses 4 organes.
- **CI** : bascule sur `postgis/postgis:18-3.6` (la migration R1 l'exige) — le moment anticipé dans le commentaire du workflow.
**Leçons**
- Migration modifiée après application locale ⇒ réaligner son checksum dans `_prisma_migrations` (ou reset) — d'où la règle : rendre la migration autosuffisante AVANT de l'appliquer.
- Un serveur `reuseExistingServer` de Playwright peut squatter :3000 et faire tester un dist périmé — tuer le port avant tout smoke test.
**Prochaine étape** : R1.2 `apps/web` — écrans Sites (+ carte Leaflet/OSM), Fiche site, Ascenseurs, Nouvel ascenseur, Étiquette QR, Personnes & équipes, Catégories, fidèles à maquette-r1.html.
---
## 2026-07-16 — Pr. Daaif (+ Claude) — R0 CLOSE (tag) · R1 ouverte : maquettes à valider
**Actions**
- **R0 close** : recette prononcée par le référent, tag `release/r0` poussé (DoD complète : CI verte, revue pixel, production en ligne, journal).
- **R1 « Référentiel » ouverte — design d'abord** (principe n°1) : `maquette-r1.html` produite dans le moule validé (CSS répliquée de `maquette-web.html`, mêmes tokens/typo/motifs) — **7 écrans** : Sites (liste + carte PostGIS), Fiche site (hiérarchie d'emplacements), Ascenseurs (liste, statuts d'équipement distincts des statuts OT), Nouvel ascenseur (identité → rattachement → organes), Étiquette QR imprimable (A6 papier, blanche même en thème sombre), Personnes & équipes (invitation par lien d'activation 7 jours, modale montrée), Catégories (référentiels administrables, jamais de suppression si utilisé).
- Rendu vérifié en Chrome headless : 7 écrans + 2 contrôles thème sombre, zéro erreur.
**Décisions (proposées à la validation)**
- Statuts d'équipement (En service / À l'arrêt / En maintenance) **distincts** des statuts OT ; l'appareil à l'arrêt porte la strie rouge.
- Un organe **n'a jamais d'emplacement propre** : il suit son appareil (contrainte en base, comme en v1).
- Invitation par **lien d'activation** (7 jours) — aucun mot de passe créé pour autrui ; compte inactif avant activation.
- Catégorie utilisée : renommage/désactivation seulement, jamais de suppression.
**⛔ Bloquant** : validation des maquettes R1 par le référent avant toute ligne de code applicatif R1.
**→ Levé le 16/07/2026 : maquettes R1 et les 4 décisions de conception VALIDÉES par le référent.** Lancement R1.1 (socle backend).
---
## 2026-07-16 — Pr. Daaif (+ Claude) — 🚀 R0 EN PRODUCTION : <https://siop2.apps.enset.top> ## 2026-07-16 — Pr. Daaif (+ Claude) — 🚀 R0 EN PRODUCTION : <https://siop2.apps.enset.top>
**Actions** **Actions**

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,20 @@ for (const op of API_CONTRACT) {
operationId: op.operationId, operationId: op.operationId,
summary: op.summary, summary: op.summary,
tags: op.tags, tags: op.tags,
...(op.pathParams?.length
? {
parameters: op.pathParams.map((name) => ({
name,
in: 'path',
required: true,
// Convention : « id » / «…Id » sont des UUID, le reste est libre
schema:
name === 'id' || name.endsWith('Id')
? { type: 'string', format: 'uuid' }
: { type: 'string' },
})),
}
: {}),
...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }), ...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }),
...(op.demoOnly ...(op.demoOnly
? { ? {

View File

@@ -7,6 +7,74 @@ import {
} from './schemas/auth'; } from './schemas/auth';
import { MeResponseSchema } from './schemas/users'; import { MeResponseSchema } from './schemas/users';
import { HealthResponseSchema } from './schemas/health'; import { HealthResponseSchema } from './schemas/health';
import {
AssetCreateSchema,
AssetComponentCreateSchema,
AssetComponentSchema,
AssetDetailSchema,
AssetOptionsResponseSchema,
AssetsResponseSchema,
AssetUpdateSchema,
CategoriesResponseSchema,
CategoryCreateSchema,
CategorySchema,
CategoryUpdateSchema,
LocationCreateSchema,
LocationSchema,
LocationsResponseSchema,
LocationUpdateSchema,
TeamCreateSchema,
TeamSchema,
TeamsResponseSchema,
TeamUpdateSchema,
} from './schemas/referentiel';
import {
ActivateRequestSchema,
ActivateResponseSchema,
InvitationCreateSchema,
InvitationResponseSchema,
RolesResponseSchema,
UserAdminSchema,
UsersResponseSchema,
UserUpdateSchema,
} from './schemas/users-admin';
import {
PortalAssetSchema,
PortalRequestCreatedSchema,
PortalRequestCreateSchema,
PortalRequestStatusSchema,
} from './schemas/portail';
import {
MeterReadingCreateSchema,
MetersResponseSchema,
PreventiveGenerateSchema,
PreventiveGenerationResultSchema,
PreventiveStatusSchema,
TaskTemplateCreateSchema,
TaskTemplateSchema,
TaskTemplatesResponseSchema,
TaskTemplateUpdateSchema,
} from './schemas/preventif';
import {
AssigneesUpdateSchema,
ChecklistItemSchema,
ChecklistPatchSchema,
CommentCreateSchema,
ReferenceValueCreateSchema,
ReferenceValueSchema,
ReferenceValuesResponseSchema,
ReferenceValueUpdateSchema,
ReportUpsertSchema,
RequestApproveSchema,
RequestCreateSchema,
RequestRejectSchema,
RequestsResponseSchema,
RequestSummarySchema,
TransitionRequestSchema,
WorkOrderCreateSchema,
WorkOrderDetailSchema,
WorkOrdersResponseSchema,
} from './schemas/exploitation';
/** /**
* Contrat d'API R0 — source unique de vérité (règle d'or ADR-001). * Contrat d'API R0 — source unique de vérité (règle d'or ADR-001).
@@ -23,6 +91,8 @@ export interface ApiOperation {
isPublic?: boolean; isPublic?: boolean;
/** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */ /** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */
demoOnly?: boolean; demoOnly?: boolean;
/** Paramètres de chemin (`{id}` dans path) — tous UUID en R1. */
pathParams?: string[];
request?: { name: string; schema: z.ZodType }; request?: { name: string; schema: z.ZodType };
responses: Record< responses: Record<
number, number,
@@ -86,6 +156,585 @@ export const API_CONTRACT: ApiOperation[] = [
401: { description: 'Non authentifié' }, 401: { description: 'Non authentifié' },
}, },
}, },
{
operationId: 'activateAccount',
method: 'post',
path: '/auth/activate',
summary: 'Activer un compte invité (lien 7 jours) — connecte directement',
tags: ['auth'],
isPublic: true,
request: { name: 'ActivateRequest', schema: ActivateRequestSchema },
responses: {
200: { description: 'Compte activé et connecté', name: 'AuthResponse', schema: ActivateResponseSchema },
400: { description: 'Lien invalide ou expiré' },
},
},
// ————— R1 · Référentiel —————
{
operationId: 'listCategories',
method: 'get',
path: '/categories',
summary: 'Référentiels administrables (catégories déquipement, types dorganes)',
tags: ['categories'],
responses: {
200: { description: 'Liste', name: 'CategoriesResponse', schema: CategoriesResponseSchema },
},
},
{
operationId: 'createCategory',
method: 'post',
path: '/categories',
summary: 'Ajouter une catégorie',
tags: ['categories'],
request: { name: 'CategoryCreate', schema: CategoryCreateSchema },
responses: {
201: { description: 'Créée', name: 'Category', schema: CategorySchema },
409: { description: 'Nom déjà utilisé pour ce type' },
},
},
{
operationId: 'updateCategory',
method: 'patch',
path: '/categories/{id}',
summary: 'Renommer ou (dés)activer — jamais de suppression si utilisée',
tags: ['categories'],
pathParams: ['id'],
request: { name: 'CategoryUpdate', schema: CategoryUpdateSchema },
responses: {
200: { description: 'Mise à jour', name: 'Category', schema: CategorySchema },
404: { description: 'Inconnue' },
},
},
{
operationId: 'listLocations',
method: 'get',
path: '/locations',
summary: 'Sites et zones (liste plate, le client construit larbre)',
tags: ['locations'],
responses: {
200: { description: 'Liste', name: 'LocationsResponse', schema: LocationsResponseSchema },
},
},
{
operationId: 'createLocation',
method: 'post',
path: '/locations',
summary: 'Créer un site (sans parent) ou une zone (profondeur max 2)',
tags: ['locations'],
request: { name: 'LocationCreate', schema: LocationCreateSchema },
responses: {
201: { description: 'Créé', name: 'Location', schema: LocationSchema },
400: { description: 'Hiérarchie trop profonde' },
},
},
{
operationId: 'updateLocation',
method: 'patch',
path: '/locations/{id}',
summary: 'Modifier un emplacement',
tags: ['locations'],
pathParams: ['id'],
request: { name: 'LocationUpdate', schema: LocationUpdateSchema },
responses: {
200: { description: 'Mis à jour', name: 'Location', schema: LocationSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'listAssets',
method: 'get',
path: '/assets',
summary: 'Inventaire des appareils',
tags: ['assets'],
responses: {
200: { description: 'Liste', name: 'AssetsResponse', schema: AssetsResponseSchema },
},
},
{
operationId: 'listAssetOptions',
method: 'get',
path: '/assets/options',
summary: 'Options dappareils pour le signalement (tout rôle authentifié)',
tags: ['assets'],
responses: {
200: {
description: 'Options',
name: 'AssetOptionsResponse',
schema: AssetOptionsResponseSchema,
},
},
},
{
operationId: 'getAsset',
method: 'get',
path: '/assets/{id}',
summary: 'Fiche appareil (identité + organes)',
tags: ['assets'],
pathParams: ['id'],
responses: {
200: { description: 'Fiche', name: 'AssetDetail', schema: AssetDetailSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'createAsset',
method: 'post',
path: '/assets',
summary: 'Créer un appareil (avec ses organes) — le QR découle de la référence',
tags: ['assets'],
request: { name: 'AssetCreate', schema: AssetCreateSchema },
responses: {
201: { description: 'Créé', name: 'AssetDetail', schema: AssetDetailSchema },
409: { description: 'Référence déjà utilisée' },
},
},
{
operationId: 'updateAsset',
method: 'patch',
path: '/assets/{id}',
summary: 'Modifier un appareil (dont son statut déquipement)',
tags: ['assets'],
pathParams: ['id'],
request: { name: 'AssetUpdate', schema: AssetUpdateSchema },
responses: {
200: { description: 'Mis à jour', name: 'AssetDetail', schema: AssetDetailSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'addAssetComponent',
method: 'post',
path: '/assets/{id}/components',
summary: 'Ajouter un organe (jamais demplacement propre)',
tags: ['assets'],
pathParams: ['id'],
request: { name: 'AssetComponentCreate', schema: AssetComponentCreateSchema },
responses: {
201: { description: 'Ajouté', name: 'AssetComponent', schema: AssetComponentSchema },
400: { description: 'Le type choisi nest pas un type dorgane' },
},
},
{
operationId: 'removeAssetComponent',
method: 'delete',
path: '/assets/{id}/components/{componentId}',
summary: 'Retirer un organe',
tags: ['assets'],
pathParams: ['id', 'componentId'],
responses: {
204: { description: 'Retiré' },
404: { description: 'Inconnu' },
},
},
{
operationId: 'listTeams',
method: 'get',
path: '/teams',
summary: 'Équipes et leurs membres',
tags: ['teams'],
responses: {
200: { description: 'Liste', name: 'TeamsResponse', schema: TeamsResponseSchema },
},
},
{
operationId: 'createTeam',
method: 'post',
path: '/teams',
summary: 'Créer une équipe',
tags: ['teams'],
request: { name: 'TeamCreate', schema: TeamCreateSchema },
responses: {
201: { description: 'Créée', name: 'Team', schema: TeamSchema },
409: { description: 'Nom déjà utilisé' },
},
},
{
operationId: 'updateTeam',
method: 'patch',
path: '/teams/{id}',
summary: 'Modifier une équipe (nom, description, membres)',
tags: ['teams'],
pathParams: ['id'],
request: { name: 'TeamUpdate', schema: TeamUpdateSchema },
responses: {
200: { description: 'Mise à jour', name: 'Team', schema: TeamSchema },
404: { description: 'Inconnue' },
},
},
{
operationId: 'listUsers',
method: 'get',
path: '/users',
summary: 'Personnes (statut dérivé : actif / invité / désactivé)',
tags: ['users'],
responses: {
200: { description: 'Liste', name: 'UsersResponse', schema: UsersResponseSchema },
},
},
{
operationId: 'listRoles',
method: 'get',
path: '/roles',
summary: 'Les 7 rôles (pour linvitation)',
tags: ['users'],
responses: {
200: { description: 'Liste', name: 'RolesResponse', schema: RolesResponseSchema },
},
},
{
operationId: 'inviteUser',
method: 'post',
path: '/users/invitations',
summary: 'Inviter — crée le compte inactif et émet le lien dactivation (7 j)',
tags: ['users'],
request: { name: 'InvitationCreate', schema: InvitationCreateSchema },
responses: {
201: { description: 'Invitation émise', name: 'InvitationResponse', schema: InvitationResponseSchema },
409: { description: 'Email déjà utilisé' },
},
},
{
operationId: 'resendInvitation',
method: 'post',
path: '/users/{id}/invitation',
summary: 'Régénérer le lien dactivation dun compte non activé',
tags: ['users'],
pathParams: ['id'],
responses: {
201: { description: 'Nouveau lien', name: 'InvitationResponse', schema: InvitationResponseSchema },
409: { description: 'Compte déjà activé' },
},
},
{
operationId: 'updateUser',
method: 'patch',
path: '/users/{id}',
summary: 'Modifier une personne (rôle, équipes, activation du compte)',
tags: ['users'],
pathParams: ['id'],
request: { name: 'UserUpdate', schema: UserUpdateSchema },
responses: {
200: { description: 'Mise à jour', name: 'UserAdmin', schema: UserAdminSchema },
404: { description: 'Inconnue' },
},
},
// ————— R2 · Portail public (QR cabine — aucun compte) —————
{
operationId: 'getPortalAsset',
method: 'get',
path: '/portal/assets/{reference}',
summary: 'Résoudre le QR scanné (référence → appareil, préremplissage)',
tags: ['portal'],
isPublic: true,
pathParams: ['reference'],
responses: {
200: { description: 'Appareil', name: 'PortalAsset', schema: PortalAssetSchema },
404: { description: 'Référence inconnue' },
},
},
{
operationId: 'createPortalRequest',
method: 'post',
path: '/portal/requests',
summary: 'Signaler sans compte — renvoie le jeton de suivi (à garder sur le téléphone)',
tags: ['portal'],
isPublic: true,
request: { name: 'PortalRequestCreate', schema: PortalRequestCreateSchema },
responses: {
201: {
description: 'Signalement enregistré',
name: 'PortalRequestCreated',
schema: PortalRequestCreatedSchema,
},
404: { description: 'Référence inconnue' },
429: { description: 'Trop de signalements — réessayez dans une minute' },
},
},
{
operationId: 'getPortalRequestStatus',
method: 'get',
path: '/portal/requests/{reference}/{token}',
summary: 'Suivi sans jargon (Reçu → Intervention → Résolu) — jeton requis',
tags: ['portal'],
isPublic: true,
pathParams: ['reference', 'token'],
responses: {
200: { description: 'Avancement', name: 'PortalRequestStatus', schema: PortalRequestStatusSchema },
404: { description: 'Signalement inconnu ou jeton invalide' },
},
},
// ————— R2 · Préventif & compteurs —————
{
operationId: 'listTaskTemplates',
method: 'get',
path: '/preventive/templates',
summary: 'Gabarits de tâches du préventif (périodicité en mois)',
tags: ['preventive'],
responses: {
200: { description: 'Liste', name: 'TaskTemplatesResponse', schema: TaskTemplatesResponseSchema },
},
},
{
operationId: 'createTaskTemplate',
method: 'post',
path: '/preventive/templates',
summary: 'Ajouter un gabarit',
tags: ['preventive'],
request: { name: 'TaskTemplateCreate', schema: TaskTemplateCreateSchema },
responses: {
201: { description: 'Créé', name: 'TaskTemplate', schema: TaskTemplateSchema },
409: { description: 'Libellé déjà utilisé' },
},
},
{
operationId: 'updateTaskTemplate',
method: 'patch',
path: '/preventive/templates/{id}',
summary: 'Modifier / (dés)activer un gabarit — jamais de suppression si utilisé',
tags: ['preventive'],
pathParams: ['id'],
request: { name: 'TaskTemplateUpdate', schema: TaskTemplateUpdateSchema },
responses: {
200: { description: 'Mis à jour', name: 'TaskTemplate', schema: TaskTemplateSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'generatePreventive',
method: 'post',
path: '/preventive/generate',
summary: 'Générer la grille du mois — IDEMPOTENT (regénérer ne double rien)',
tags: ['preventive'],
request: { name: 'PreventiveGenerate', schema: PreventiveGenerateSchema },
responses: {
200: {
description: 'Résumé de génération',
name: 'PreventiveGenerationResult',
schema: PreventiveGenerationResultSchema,
},
},
},
{
operationId: 'getPreventiveStatus',
method: 'get',
path: '/preventive/status',
summary: 'État du mois courant (générées, terminées, en retard, premiers contrôles)',
tags: ['preventive'],
responses: {
200: { description: 'État', name: 'PreventiveStatus', schema: PreventiveStatusSchema },
},
},
{
operationId: 'getAssetMeters',
method: 'get',
path: '/assets/{id}/meters',
summary: 'Compteurs de lappareil (relevés récents dabord)',
tags: ['meters'],
pathParams: ['id'],
responses: {
200: { description: 'Compteurs', name: 'MetersResponse', schema: MetersResponseSchema },
404: { description: 'Appareil inconnu' },
},
},
{
operationId: 'addMeterReading',
method: 'post',
path: '/assets/{id}/meter-readings',
summary: 'Enregistrer un relevé — strictement croissant',
tags: ['meters'],
pathParams: ['id'],
request: { name: 'MeterReadingCreate', schema: MeterReadingCreateSchema },
responses: {
201: { description: 'Relevé enregistré', name: 'MetersResponse', schema: MetersResponseSchema },
400: { description: 'Relevé inférieur ou égal au précédent' },
},
},
// ————— R2 · Exploitation —————
{
operationId: 'listWorkOrders',
method: 'get',
path: '/work-orders',
summary: 'Ordres de travail (sans « voir autre » : seulement les siens)',
tags: ['work-orders'],
responses: {
200: { description: 'Liste', name: 'WorkOrdersResponse', schema: WorkOrdersResponseSchema },
},
},
{
operationId: 'getWorkOrder',
method: 'get',
path: '/work-orders/{id}',
summary: 'Fiche OT (activité, checklist, bilan, transitions autorisées, blocages de clôture)',
tags: ['work-orders'],
pathParams: ['id'],
responses: {
200: { description: 'Fiche', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
404: { description: 'Inconnu (ou hors de son périmètre)' },
},
},
{
operationId: 'createWorkOrder',
method: 'post',
path: '/work-orders',
summary: 'Créer un OT (statut Ouvert)',
tags: ['work-orders'],
request: { name: 'WorkOrderCreate', schema: WorkOrderCreateSchema },
responses: {
201: { description: 'Créé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
},
},
{
operationId: 'transitionWorkOrder',
method: 'post',
path: '/work-orders/{id}/transition',
summary: 'Changer létat (machine à états stricte ; DONE exige bilan + checklist)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'TransitionRequest', schema: TransitionRequestSchema },
responses: {
200: { description: 'État changé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
409: { description: 'Transition interdite ou clôture bloquée (garde)' },
},
},
{
operationId: 'commentWorkOrder',
method: 'post',
path: '/work-orders/{id}/comments',
summary: 'Commenter (activité chronologique)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'CommentCreate', schema: CommentCreateSchema },
responses: {
201: { description: 'Commentaire ajouté', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
},
},
{
operationId: 'setWorkOrderAssignees',
method: 'put',
path: '/work-orders/{id}/assignees',
summary: 'Assigner (remplace la liste)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'AssigneesUpdate', schema: AssigneesUpdateSchema },
responses: {
200: { description: 'Assignés', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
},
},
{
operationId: 'upsertWorkOrderReport',
method: 'put',
path: '/work-orders/{id}/report',
summary: 'Renseigner le bilan codé (null efface un champ)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'ReportUpsert', schema: ReportUpsertSchema },
responses: {
200: { description: 'Bilan enregistré', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
400: { description: 'Valeur hors référentiel du champ' },
},
},
{
operationId: 'patchChecklistItem',
method: 'patch',
path: '/work-orders/{id}/checklist/{itemId}',
summary: 'Régler une tâche de la grille (Fait / N-A / à faire)',
tags: ['work-orders'],
pathParams: ['id', 'itemId'],
request: { name: 'ChecklistPatch', schema: ChecklistPatchSchema },
responses: {
200: { description: 'Tâche réglée', name: 'ChecklistItem', schema: ChecklistItemSchema },
404: { description: 'Tâche inconnue' },
},
},
{
operationId: 'listRequests',
method: 'get',
path: '/requests',
summary: 'Demandes (sans « voir autre » : seulement les siennes)',
tags: ['requests'],
responses: {
200: { description: 'Liste', name: 'RequestsResponse', schema: RequestsResponseSchema },
},
},
{
operationId: 'createRequest',
method: 'post',
path: '/requests',
summary: 'Signaler (interne — le portail public QR arrive en R2.4)',
tags: ['requests'],
request: { name: 'RequestCreate', schema: RequestCreateSchema },
responses: {
201: { description: 'Demande créée', name: 'RequestSummary', schema: RequestSummarySchema },
},
},
{
operationId: 'approveRequest',
method: 'post',
path: '/requests/{id}/approve',
summary: 'Approuver → crée lOT lié (1-1, jamais de doublon)',
tags: ['requests'],
pathParams: ['id'],
request: { name: 'RequestApprove', schema: RequestApproveSchema },
responses: {
201: { description: 'OT créé et lié', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
409: { description: 'Demande déjà traitée' },
},
},
{
operationId: 'rejectRequest',
method: 'post',
path: '/requests/{id}/reject',
summary: 'Rejeter — motif obligatoire, lisible côté demandeur',
tags: ['requests'],
pathParams: ['id'],
request: { name: 'RequestReject', schema: RequestRejectSchema },
responses: {
200: { description: 'Rejetée', name: 'RequestSummary', schema: RequestSummarySchema },
409: { description: 'Demande déjà traitée' },
},
},
{
operationId: 'listReferenceValues',
method: 'get',
path: '/reference-values',
summary: 'Référentiels du bilan codé (6 champs)',
tags: ['reference-values'],
responses: {
200: {
description: 'Liste',
name: 'ReferenceValuesResponse',
schema: ReferenceValuesResponseSchema,
},
},
},
{
operationId: 'createReferenceValue',
method: 'post',
path: '/reference-values',
summary: 'Ajouter une valeur de référentiel',
tags: ['reference-values'],
request: { name: 'ReferenceValueCreate', schema: ReferenceValueCreateSchema },
responses: {
201: { description: 'Créée', name: 'ReferenceValue', schema: ReferenceValueSchema },
409: { description: 'Libellé déjà présent pour ce champ' },
},
},
{
operationId: 'updateReferenceValue',
method: 'patch',
path: '/reference-values/{id}',
summary: 'Renommer ou (dés)activer — jamais de suppression si utilisée',
tags: ['reference-values'],
pathParams: ['id'],
request: { name: 'ReferenceValueUpdate', schema: ReferenceValueUpdateSchema },
responses: {
200: { description: 'Mise à jour', name: 'ReferenceValue', schema: ReferenceValueSchema },
404: { description: 'Inconnue' },
},
},
{ {
operationId: 'getHealth', operationId: 'getHealth',
method: 'get', method: 'get',

View File

@@ -0,0 +1,93 @@
/** Vocabulaires de l'exploitation (R2) — partagés API / web / seed. */
export const WORK_ORDER_TYPES = ['CORRECTIVE', 'PREVENTIVE', 'WORKS'] as const;
export type WorkOrderType = (typeof WORK_ORDER_TYPES)[number];
export const WORK_ORDER_TYPE_LABELS: Record<WorkOrderType, string> = {
CORRECTIVE: 'Dépannage',
PREVENTIVE: 'Maintenance',
WORKS: 'Travaux',
};
export const WORK_ORDER_STATUSES = [
'OPEN',
'IN_PROGRESS',
'ON_HOLD',
'DONE',
'CANCELLED',
] as const;
export type WorkOrderStatus = (typeof WORK_ORDER_STATUSES)[number];
export const WORK_ORDER_STATUS_LABELS: Record<WorkOrderStatus, string> = {
OPEN: 'Ouvert',
IN_PROGRESS: 'En cours',
ON_HOLD: 'En attente',
DONE: 'Terminé',
CANCELLED: 'Annulé',
};
/** Machine à états STRICTE — la table est la loi, côté API comme côté UI. */
export const WORK_ORDER_TRANSITIONS: Record<WorkOrderStatus, WorkOrderStatus[]> = {
OPEN: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['ON_HOLD', 'DONE', 'CANCELLED'],
ON_HOLD: ['IN_PROGRESS', 'CANCELLED'],
DONE: [],
CANCELLED: [],
};
export const WORK_ORDER_PRIORITIES = [
'NONE',
'LOW',
'MEDIUM',
'HIGH',
'PERSON_TRAPPED',
] as const;
export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITIES)[number];
export const WORK_ORDER_PRIORITY_LABELS: Record<WorkOrderPriority, string> = {
NONE: 'Aucune',
LOW: 'Basse',
MEDIUM: 'Moyenne',
HIGH: 'Haute',
PERSON_TRAPPED: 'Personne bloquée',
};
export const REQUEST_STATUSES = ['RECEIVED', 'APPROVED', 'REJECTED'] as const;
export type RequestStatus = (typeof REQUEST_STATUSES)[number];
export const REQUEST_STATUS_LABELS: Record<RequestStatus, string> = {
RECEIVED: 'Reçue',
APPROVED: 'Approuvée',
REJECTED: 'Rejetée',
};
export const CHECKLIST_STATES = ['PENDING', 'DONE', 'NA'] as const;
export type ChecklistState = (typeof CHECKLIST_STATES)[number];
export const BILAN_FIELDS = [
'DOOR_STATE',
'CABIN_POSITION',
'ANOMALY',
'EXTERNAL_CAUSE',
'ACTION_TAKEN',
'COMPONENT_CONCERNED',
] as const;
export type BilanField = (typeof BILAN_FIELDS)[number];
export const BILAN_FIELD_LABELS: Record<BilanField, string> = {
DOOR_STATE: 'État des portes',
CABIN_POSITION: 'Position cabine',
ANOMALY: 'Anomalie constatée',
EXTERNAL_CAUSE: 'Cause extérieure',
ACTION_TAKEN: 'Action réalisée',
COMPONENT_CONCERNED: 'Élément concerné',
};
/** Champs du bilan REQUIS pour clôturer (maquette fiche OT validée R0). */
export const REQUIRED_BILAN_FIELDS: readonly BilanField[] = [
'DOOR_STATE',
'ACTION_TAKEN',
'COMPONENT_CONCERNED',
];
export const METER_KINDS = ['RUNNING_HOURS', 'STARTS'] as const;
export type MeterKind = (typeof METER_KINDS)[number];
export const METER_KIND_LABELS: Record<MeterKind, string> = {
RUNNING_HOURS: 'Heures de marche',
STARTS: 'Démarrages',
};

View File

@@ -1,5 +1,12 @@
export * from './permissions'; export * from './permissions';
export * from './referentiel';
export * from './exploitation';
export * from './schemas/exploitation';
export * from './schemas/preventif';
export * from './schemas/portail';
export * from './schemas/auth'; export * from './schemas/auth';
export * from './schemas/users'; export * from './schemas/users';
export * from './schemas/users-admin';
export * from './schemas/referentiel';
export * from './schemas/health'; export * from './schemas/health';
export * from './contract'; export * from './contract';

View File

@@ -0,0 +1,29 @@
/** Vocabulaires du référentiel (R1) — partagés API / web / seed. */
export const ASSET_STATUSES = [
'IN_SERVICE',
'OUT_OF_SERVICE',
'UNDER_MAINTENANCE',
] as const;
export type AssetStatus = (typeof ASSET_STATUSES)[number];
/** Libellés métier (charte §7 : vocabulaire du carnet). */
export const ASSET_STATUS_LABELS: Record<AssetStatus, string> = {
IN_SERVICE: 'En service',
OUT_OF_SERVICE: 'À larrêt',
UNDER_MAINTENANCE: 'En maintenance',
};
export const CATEGORY_KINDS = ['EQUIPMENT', 'COMPONENT_TYPE'] as const;
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
export const CATEGORY_KIND_LABELS: Record<CategoryKind, string> = {
EQUIPMENT: 'Catégorie déquipement',
COMPONENT_TYPE: 'Type dorgane',
};
/** Hiérarchie d'emplacements : site → zone, jamais plus profond (invariant R1). */
export const LOCATION_MAX_DEPTH = 2;
/** Durée de validité d'un lien d'activation (invitation). */
export const INVITATION_TTL_DAYS = 7;

View File

@@ -0,0 +1,209 @@
import { z } from 'zod';
import {
BILAN_FIELDS,
CHECKLIST_STATES,
REQUEST_STATUSES,
WORK_ORDER_PRIORITIES,
WORK_ORDER_STATUSES,
WORK_ORDER_TYPES,
} from '../exploitation';
const PersonneSchema = z.object({
id: z.uuid(),
displayName: z.string(),
initials: z.string(),
});
// ————— Ordres de travail —————
export const WorkOrderSummarySchema = z.object({
id: z.uuid(),
reference: z.string(),
title: z.string(),
type: z.enum(WORK_ORDER_TYPES),
status: z.enum(WORK_ORDER_STATUSES),
priority: z.enum(WORK_ORDER_PRIORITIES),
assetId: z.uuid(),
assetReference: z.string(),
siteName: z.string(),
dueDate: z.iso.datetime().nullable(),
assignees: z.array(PersonneSchema),
createdAt: z.iso.datetime(),
});
export type WorkOrderSummary = z.infer<typeof WorkOrderSummarySchema>;
export const WorkOrdersResponseSchema = z.object({
workOrders: z.array(WorkOrderSummarySchema),
});
export type WorkOrdersResponse = z.infer<typeof WorkOrdersResponseSchema>;
export const WorkOrderEventSchema = z.object({
id: z.uuid(),
kind: z.string(),
message: z.string().nullable(),
by: PersonneSchema.nullable(),
createdAt: z.iso.datetime(),
});
export const ChecklistItemSchema = z.object({
id: z.uuid(),
label: z.string(),
state: z.enum(CHECKLIST_STATES),
doneBy: PersonneSchema.nullable(),
doneAt: z.iso.datetime().nullable(),
});
export type ChecklistItemDto = z.infer<typeof ChecklistItemSchema>;
const BilanValueSchema = z.object({ id: z.uuid(), label: z.string() }).nullable();
export const InterventionReportSchema = z.object({
note: z.string().nullable(),
doorState: BilanValueSchema,
cabinPosition: BilanValueSchema,
anomaly: BilanValueSchema,
externalCause: BilanValueSchema,
actionTaken: BilanValueSchema,
componentConcerned: BilanValueSchema,
});
export type InterventionReportDto = z.infer<typeof InterventionReportSchema>;
export const WorkOrderDetailSchema = WorkOrderSummarySchema.extend({
description: z.string().nullable(),
locationName: z.string(),
startedAt: z.iso.datetime().nullable(),
completedAt: z.iso.datetime().nullable(),
cancelledAt: z.iso.datetime().nullable(),
createdBy: PersonneSchema.nullable(),
request: z
.object({ id: z.uuid(), reference: z.string(), requesterLabel: z.string() })
.nullable(),
events: z.array(WorkOrderEventSchema),
checklist: z.array(ChecklistItemSchema),
report: InterventionReportSchema.nullable(),
/** Ce que la machine à états autorise depuis l'état courant. */
allowedTransitions: z.array(z.enum(WORK_ORDER_STATUSES)),
/** Ce qui bloque la clôture (vide = clôturable) — messages métier. */
closureBlockers: z.array(z.string()),
});
export type WorkOrderDetail = z.infer<typeof WorkOrderDetailSchema>;
export const WorkOrderCreateSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
type: z.enum(WORK_ORDER_TYPES),
priority: z.enum(WORK_ORDER_PRIORITIES).optional(),
assetId: z.uuid(),
dueDate: z.iso.datetime().optional(),
assigneeIds: z.array(z.uuid()).optional(),
});
export type WorkOrderCreate = z.infer<typeof WorkOrderCreateSchema>;
export const TransitionRequestSchema = z.object({
to: z.enum(WORK_ORDER_STATUSES),
comment: z.string().max(500).optional(),
});
export type TransitionRequest = z.infer<typeof TransitionRequestSchema>;
export const CommentCreateSchema = z.object({
message: z.string().min(1).max(1000),
});
export type CommentCreate = z.infer<typeof CommentCreateSchema>;
export const AssigneesUpdateSchema = z.object({
assigneeIds: z.array(z.uuid()).max(10),
});
export type AssigneesUpdate = z.infer<typeof AssigneesUpdateSchema>;
/** Upsert du bilan — null efface un champ. */
export const ReportUpsertSchema = z.object({
note: z.string().max(2000).nullable().optional(),
doorStateId: z.uuid().nullable().optional(),
cabinPositionId: z.uuid().nullable().optional(),
anomalyId: z.uuid().nullable().optional(),
externalCauseId: z.uuid().nullable().optional(),
actionTakenId: z.uuid().nullable().optional(),
componentConcernedId: z.uuid().nullable().optional(),
});
export type ReportUpsert = z.infer<typeof ReportUpsertSchema>;
export const ChecklistPatchSchema = z.object({
state: z.enum(CHECKLIST_STATES),
});
export type ChecklistPatch = z.infer<typeof ChecklistPatchSchema>;
// ————— Demandes —————
export const RequestSummarySchema = z.object({
id: z.uuid(),
reference: z.string(),
description: z.string(),
isPersonTrapped: z.boolean(),
status: z.enum(REQUEST_STATUSES),
rejectionReason: z.string().nullable(),
assetId: z.uuid(),
assetReference: z.string(),
siteName: z.string(),
requesterLabel: z.string(),
workOrder: z
.object({
id: z.uuid(),
reference: z.string(),
status: z.enum(WORK_ORDER_STATUSES),
})
.nullable(),
createdAt: z.iso.datetime(),
});
export type RequestSummary = z.infer<typeof RequestSummarySchema>;
export const RequestsResponseSchema = z.object({
requests: z.array(RequestSummarySchema),
});
export type RequestsResponse = z.infer<typeof RequestsResponseSchema>;
export const RequestCreateSchema = z.object({
assetId: z.uuid(),
description: z.string().min(1).max(2000),
isPersonTrapped: z.boolean().optional(),
});
export type RequestCreate = z.infer<typeof RequestCreateSchema>;
export const RequestApproveSchema = z.object({
title: z.string().min(1).max(200).optional(), // défaut : description tronquée
priority: z.enum(WORK_ORDER_PRIORITIES).optional(),
assigneeIds: z.array(z.uuid()).optional(),
dueDate: z.iso.datetime().optional(),
});
export type RequestApprove = z.infer<typeof RequestApproveSchema>;
export const RequestRejectSchema = z.object({
reason: z.string().min(3, 'Le motif est requis').max(500),
});
export type RequestReject = z.infer<typeof RequestRejectSchema>;
// ————— Référentiels du bilan codé —————
export const ReferenceValueSchema = z.object({
id: z.uuid(),
field: z.enum(BILAN_FIELDS),
label: z.string(),
isActive: z.boolean(),
usageCount: z.number().int(),
});
export type ReferenceValueDto = z.infer<typeof ReferenceValueSchema>;
export const ReferenceValuesResponseSchema = z.object({
referenceValues: z.array(ReferenceValueSchema),
});
export type ReferenceValuesResponse = z.infer<typeof ReferenceValuesResponseSchema>;
export const ReferenceValueCreateSchema = z.object({
field: z.enum(BILAN_FIELDS),
label: z.string().min(1).max(120),
});
export type ReferenceValueCreate = z.infer<typeof ReferenceValueCreateSchema>;
export const ReferenceValueUpdateSchema = z.object({
label: z.string().min(1).max(120).optional(),
isActive: z.boolean().optional(),
});
export type ReferenceValueUpdate = z.infer<typeof ReferenceValueUpdateSchema>;

Some files were not shown because too many files have changed in this diff Show More