mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
Compare commits
11 Commits
release/r0
...
release/r2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aa7189e96 | ||
|
|
f7c0da1b0f | ||
|
|
50254daea4 | ||
|
|
6a2dae1710 | ||
|
|
902efa6192 | ||
|
|
f7702e4252 | ||
|
|
54d926e9f4 | ||
|
|
c4d1ab7225 | ||
|
|
9778629827 | ||
|
|
266ffaaf1b | ||
|
|
6d9aafdab4 |
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -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 l’image 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
|
||||||
|
|||||||
13
CLAUDE.md
13
CLAUDE.md
@@ -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.
|
||||||
- 🔄 **R0 — reste** : 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.
|
- ✅ **R1 — maquettes 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 site→zone + 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), demande→OT 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 R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
27
apps/api/prisma/cleanup-e2e.ts
Normal file
27
apps/api/prisma/cleanup-e2e.ts
Normal 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());
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- Migration autosuffisante : les extensions requises sont créées si absentes
|
||||||
|
-- (la CI et tout environnement neuf n’ont 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");
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -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");
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Request" ADD COLUMN "publicToken" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Request_publicToken_key" ON "Request"("publicToken");
|
||||||
|
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 l’Océ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 d’entretien',
|
||||||
|
'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 d’huile 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();
|
||||||
|
|||||||
@@ -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] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
80
apps/api/src/assets/assets.controller.ts
Normal file
80
apps/api/src/assets/assets.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/assets/assets.module.ts
Normal file
10
apps/api/src/assets/assets.module.ts
Normal 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 {}
|
||||||
199
apps/api/src/assets/assets.service.ts
Normal file
199
apps/api/src/assets/assets.service.ts
Normal 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 n’est 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 d’organe 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 d’activation 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({
|
||||||
|
|||||||
44
apps/api/src/categories/categories.controller.ts
Normal file
44
apps/api/src/categories/categories.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/categories/categories.module.ts
Normal file
10
apps/api/src/categories/categories.module.ts
Normal 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 {}
|
||||||
75
apps/api/src/categories/categories.service.ts
Normal file
75
apps/api/src/categories/categories.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/locations/locations.controller.ts
Normal file
44
apps/api/src/locations/locations.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/locations/locations.module.ts
Normal file
10
apps/api/src/locations/locations.module.ts
Normal 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 {}
|
||||||
113
apps/api/src/locations/locations.service.ts
Normal file
113
apps/api/src/locations/locations.service.ts
Normal 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 d’emplacement',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/api/src/meters/meters.controller.ts
Normal file
40
apps/api/src/meters/meters.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/meters/meters.module.ts
Normal file
9
apps/api/src/meters/meters.module.ts
Normal 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 {}
|
||||||
77
apps/api/src/meters/meters.service.ts
Normal file
77
apps/api/src/meters/meters.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/api/src/portal/portal.controller.ts
Normal file
40
apps/api/src/portal/portal.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/portal/portal.module.ts
Normal file
11
apps/api/src/portal/portal.module.ts
Normal 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 {}
|
||||||
106
apps/api/src/portal/portal.service.ts
Normal file
106
apps/api/src/portal/portal.service.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
69
apps/api/src/preventive/preventive.controller.ts
Normal file
69
apps/api/src/preventive/preventive.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/preventive/preventive.module.ts
Normal file
9
apps/api/src/preventive/preventive.module.ts
Normal 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 {}
|
||||||
234
apps/api/src/preventive/preventive.service.ts
Normal file
234
apps/api/src/preventive/preventive.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/api/src/reference-values/reference-values.controller.ts
Normal file
46
apps/api/src/reference-values/reference-values.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/reference-values/reference-values.module.ts
Normal file
10
apps/api/src/reference-values/reference-values.module.ts
Normal 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 {}
|
||||||
93
apps/api/src/reference-values/reference-values.service.ts
Normal file
93
apps/api/src/reference-values/reference-values.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
65
apps/api/src/requests/requests.controller.ts
Normal file
65
apps/api/src/requests/requests.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/requests/requests.module.ts
Normal file
11
apps/api/src/requests/requests.module.ts
Normal 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 {}
|
||||||
170
apps/api/src/requests/requests.service.ts
Normal file
170
apps/api/src/requests/requests.service.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/teams/teams.controller.ts
Normal file
44
apps/api/src/teams/teams.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/teams/teams.module.ts
Normal file
10
apps/api/src/teams/teams.module.ts
Normal 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 {}
|
||||||
92
apps/api/src/teams/teams.service.ts
Normal file
92
apps/api/src/teams/teams.service.ts
Normal 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(''),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
141
apps/api/src/users/users.service.ts
Normal file
141
apps/api/src/users/users.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
110
apps/api/src/work-orders/work-orders.controller.ts
Normal file
110
apps/api/src/work-orders/work-orders.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/work-orders/work-orders.module.ts
Normal file
10
apps/api/src/work-orders/work-orders.module.ts
Normal 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 {}
|
||||||
437
apps/api/src/work-orders/work-orders.service.ts
Normal file
437
apps/api/src/work-orders/work-orders.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
221
apps/api/test/administration.e2e-spec.ts
Normal file
221
apps/api/test/administration.e2e-spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
366
apps/api/test/exploitation.e2e-spec.ts
Normal file
366
apps/api/test/exploitation.e2e-spec.ts
Normal 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 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
128
apps/api/test/invitation.e2e-spec.ts
Normal file
128
apps/api/test/invitation.e2e-spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
208
apps/api/test/preventif.e2e-spec.ts
Normal file
208
apps/api/test/preventif.e2e-spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
185
apps/api/test/referentiel.e2e-spec.ts
Normal file
185
apps/api/test/referentiel.e2e-spec.ts
Normal 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 n’est 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 n’existe 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
77
apps/web/e2e/parcours-portail.spec.ts
Normal file
77
apps/web/e2e/parcours-portail.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
96
apps/web/e2e/parcours-r1.spec.ts
Normal file
96
apps/web/e2e/parcours-r1.spec.ts
Normal 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 à l’activation de l’invité', 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 l’UI : 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);
|
||||||
|
});
|
||||||
117
apps/web/e2e/parcours-r2.spec.ts
Normal file
117
apps/web/e2e/parcours-r2.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
235
apps/web/src/api/exploitation.ts
Normal file
235
apps/web/src/api/exploitation.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
198
apps/web/src/api/referentiel.ts
Normal file
198
apps/web/src/api/referentiel.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
2670
apps/web/src/api/schema.d.ts
vendored
2670
apps/web/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
19
apps/web/src/auth/use-permissions.ts
Normal file
19
apps/web/src/auth/use-permissions.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
77
apps/web/src/components/carte-geo.tsx
Normal file
77
apps/web/src/components/carte-geo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
apps/web/src/components/chips-ot.tsx
Normal file
66
apps/web/src/components/chips-ot.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
13
apps/web/src/components/statut-equipement.tsx
Normal file
13
apps/web/src/components/statut-equipement.tsx
Normal 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>;
|
||||||
|
}
|
||||||
29
apps/web/src/components/ui/modale.tsx
Normal file
29
apps/web/src/components/ui/modale.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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… ⌘K
|
Rechercher un OT, un ascenseur, un site… ⌘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"
|
||||||
|
|||||||
29
apps/web/src/lib/lieux.test.ts
Normal file
29
apps/web/src/lib/lieux.test.ts
Normal 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 d’appareils', () => {
|
||||||
|
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
21
apps/web/src/lib/lieux.ts
Normal 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));
|
||||||
|
}
|
||||||
96
apps/web/src/pages/activation.tsx
Normal file
96
apps/web/src/pages/activation.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/web/src/pages/ascenseurs.tsx
Normal file
97
apps/web/src/pages/ascenseurs.tsx
Normal 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} à l’arrê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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
apps/web/src/pages/categories.tsx
Normal file
136
apps/web/src/pages/categories.tsx
Normal 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 d’organes',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** É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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/web/src/pages/compteurs.tsx
Normal file
89
apps/web/src/pages/compteurs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
301
apps/web/src/pages/demandes.tsx
Normal file
301
apps/web/src/pages/demandes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/web/src/pages/etiquette.tsx
Normal file
76
apps/web/src/pages/etiquette.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
apps/web/src/pages/fiche-ascenseur.tsx
Normal file
233
apps/web/src/pages/fiche-ascenseur.tsx
Normal 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 d’organe *</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
354
apps/web/src/pages/fiche-ot.tsx
Normal file
354
apps/web/src/pages/fiche-ot.tsx
Normal 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 d’annulation (visible dans l’activité) :') ?? 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
apps/web/src/pages/fiche-site.tsx
Normal file
165
apps/web/src/pages/fiche-site.tsx
Normal 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 l’instant.</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 d’emplacement 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
apps/web/src/pages/nouvel-ascenseur.tsx
Normal file
206
apps/web/src/pages/nouvel-ascenseur.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
apps/web/src/pages/nouvel-ot.tsx
Normal file
135
apps/web/src/pages/nouvel-ot.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/web/src/pages/ordres-travail.tsx
Normal file
126
apps/web/src/pages/ordres-travail.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
apps/web/src/pages/personnes.tsx
Normal file
297
apps/web/src/pages/personnes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
apps/web/src/pages/portail.tsx
Normal file
203
apps/web/src/pages/portail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/web/src/pages/preventif.tsx
Normal file
166
apps/web/src/pages/preventif.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
apps/web/src/pages/sites.tsx
Normal file
172
apps/web/src/pages/sites.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
|
||||||
La référence vivante du design est consultable sur la page{' '}
|
|
||||||
<Link to="/design" className="font-semibold text-primaire">
|
|
||||||
/design
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{me ? (
|
|
||||||
<div className="carte">
|
|
||||||
<h2>Vos droits (matrice en base)</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{me.permissions
|
|
||||||
.filter((p) => p.canView)
|
|
||||||
.map((p) => (
|
|
||||||
<span key={p.objectCategory} className="st ouvert">
|
|
||||||
{p.objectCategory}
|
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
|
<Button variant="prim" onClick={() => navigate(`/ot/${u.id}`)}>
|
||||||
|
Ouvrir l'OT d'urgence
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{me.permissions.every((p) => !p.canView) ? (
|
<div className="kpis">
|
||||||
<p className="text-encre-2">Ce rôle ne voit aucune catégorie d'objets.</p>
|
<div className="kpi">
|
||||||
) : null}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
863
docs/02-design/maquettes/maquette-r1.html
Normal file
863
docs/02-design/maquettes/maquette-r1.html
Normal 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>
|
||||||
755
docs/02-design/maquettes/maquette-r2.html
Normal file
755
docs/02-design/maquettes/maquette-r2.html
Normal 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>N°</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 & 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>N°</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>
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
5316
docs/openapi.json
5316
docs/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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 d’organes)',
|
||||||
|
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 l’arbre)',
|
||||||
|
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 d’appareils 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 d’emplacement propre)',
|
||||||
|
tags: ['assets'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'AssetComponentCreate', schema: AssetComponentCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Ajouté', name: 'AssetComponent', schema: AssetComponentSchema },
|
||||||
|
400: { description: 'Le type choisi n’est pas un type d’organe' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 l’invitation)',
|
||||||
|
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 d’activation (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 d’activation d’un 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 l’appareil (relevés récents d’abord)',
|
||||||
|
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 l’OT 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',
|
||||||
|
|||||||
93
packages/shared/src/exploitation.ts
Normal file
93
packages/shared/src/exploitation.ts
Normal 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',
|
||||||
|
};
|
||||||
@@ -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';
|
||||||
|
|||||||
29
packages/shared/src/referentiel.ts
Normal file
29
packages/shared/src/referentiel.ts
Normal 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: 'À l’arrê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 d’organe',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 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;
|
||||||
209
packages/shared/src/schemas/exploitation.ts
Normal file
209
packages/shared/src/schemas/exploitation.ts
Normal 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
Reference in New Issue
Block a user