7 Commits

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 13:33:43 +01:00
62 changed files with 11565 additions and 53 deletions

View File

@@ -44,5 +44,10 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location sitezone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`. - **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location sitezone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`.
- **R1.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` source TS (leçon CJS/workspace). - **R1.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). - 🏁 **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 Exploitation — reprise ici, design d'abord** : maquettes R0 déjà validées pour tableau de bord / liste OT / fiche OT (bilan codé) / portail demandeur ; **à maquetter avant tout code : préventif** (gabarits à périodicité, grille du mois, checklist) et écrans secondaires (création OT/demande). Ensuite : modèle R2 (WorkOrder machine à états, Request 1-1, InterventionReport bilan codé, TaskTemplate/PreventivePlan/ChecklistItem, Meter) API web mobile-ready. - **R2 — maquettes validées** (16/07) : `maquette-r2.html` (demandes/approbation, nouvel OT, préventif, checklist, compteurs) + 4 décisions actées.
- **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demandeOT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
- **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production.
- **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API.
- **R2.4 — portail public QR** : `/q/{réf}` sans compte (écran validé R0, mobile d'abord), 3 routes publiques `/portal` throttlées, suivi par jeton opaque (`Request.publicToken`, jamais de liste exposée), étapes Reçu Intervention Résolu dérivées de l'OT ; boucle produit complète en e2e (11 Playwright verts, 52 opérations).
- 🔄 **R2 — reste pour clore** : recette R2 avec le référent (revue pixel maquettes R0+R2, sur `pnpm dev` ou l'instance en ligne après déploiement), tag `release/r2` ouverture R3 Gestion (stock, BC, tiers, main-d'œuvre, analytics, bibliothèque maquettes à produire d'abord).
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.

View File

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

View File

@@ -6,6 +6,13 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { 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.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' }, parentId: { not: null } } });
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } }); await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,13 @@ model User {
activationToken String? @unique activationToken String? @unique
activationExpiresAt DateTime? activationExpiresAt DateTime?
teams Team[] 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
@@ -75,6 +82,7 @@ model Category {
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
assets Asset[] assets Asset[]
components AssetComponent[] components AssetComponent[]
taskTemplates TaskTemplate[]
@@unique([kind, name]) @@unique([kind, name])
} }
@@ -110,11 +118,15 @@ model Asset {
loadKg Int? loadKg Int?
floors Int? floors Int?
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
underContract Boolean @default(true) // contrat préventif (grille mensuelle)
categoryId String @db.Uuid categoryId String @db.Uuid
category Category @relation(fields: [categoryId], references: [id]) category Category @relation(fields: [categoryId], references: [id])
locationId String @db.Uuid locationId String @db.Uuid
location Location @relation(fields: [locationId], references: [id]) location Location @relation(fields: [locationId], references: [id])
components AssetComponent[] components AssetComponent[]
workOrders WorkOrder[]
requests Request[]
meters Meter[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -141,3 +153,208 @@ model Team {
description String? description String?
members User[] members User[]
} }
// ————— R2 — Exploitation (docs/03-architecture/modele-donnees.md §R2) —————
enum WorkOrderType {
CORRECTIVE // Dépannage
PREVENTIVE // Maintenance (grille du mois)
WORKS // Travaux
}
enum WorkOrderStatus {
OPEN
IN_PROGRESS
ON_HOLD
DONE
CANCELLED
}
enum WorkOrderPriority {
NONE
LOW
MEDIUM
HIGH
PERSON_TRAPPED // personne bloquée — urgence absolue
}
enum RequestStatus {
RECEIVED
APPROVED
REJECTED
}
enum ChecklistState {
PENDING
DONE
NA
}
enum BilanField {
DOOR_STATE
CABIN_POSITION
ANOMALY
EXTERNAL_CAUSE
ACTION_TAKEN
COMPONENT_CONCERNED
}
enum MeterKind {
RUNNING_HOURS
STARTS
}
model WorkOrder {
id String @id @default(uuid()) @db.Uuid
reference String @unique // OT-2026-0341
title String
description String?
type WorkOrderType
status WorkOrderStatus @default(OPEN) // machine à états stricte (service)
priority WorkOrderPriority @default(NONE)
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id])
dueDate DateTime?
assignees User[] @relation("WorkOrderAssignees")
createdById String? @db.Uuid
createdBy User? @relation("WorkOrderCreator", fields: [createdById], references: [id])
startedAt DateTime?
completedAt DateTime?
cancelledAt DateTime?
events WorkOrderEvent[]
checklist ChecklistItem[]
report InterventionReport?
request Request?
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
// l'idempotence de la génération (les OT correctifs restent à null).
periodKey String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([assetId, periodKey])
@@index([assetId])
@@index([status])
}
model WorkOrderEvent {
id String @id @default(uuid()) @db.Uuid
workOrderId String @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
kind String // COMMENT · STATUS_CHANGED · ASSIGNED · CREATED · FROM_REQUEST
message String?
byId String? @db.Uuid
by User? @relation(fields: [byId], references: [id])
createdAt DateTime @default(now())
@@index([workOrderId])
}
model Request {
id String @id @default(uuid()) @db.Uuid
reference String @unique // DEM-2026-0112
description String
isPersonTrapped Boolean @default(false)
status RequestStatus @default(RECEIVED)
rejectionReason String? // REQUIS au rejet (service)
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id])
requestedById String? @db.Uuid
requestedBy User? @relation(fields: [requestedById], references: [id])
requesterName String? // portail public via QR (R2.4)
// Suivi SANS COMPTE (portail) : le téléphone du gardien garde ce jeton,
// seul moyen de lire l'avancement — jamais listé, jamais devinable.
publicToken String? @unique
workOrderId String? @unique @db.Uuid // lien 1-1 — jamais de doublon
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([assetId])
}
// Référentiels administrables du bilan codé (un par champ)
model ReferenceValue {
id String @id @default(uuid()) @db.Uuid
field BilanField
label String
isActive Boolean @default(true)
doorStates InterventionReport[] @relation("BilanDoorState")
cabinPositions InterventionReport[] @relation("BilanCabinPosition")
anomalies InterventionReport[] @relation("BilanAnomaly")
externalCauses InterventionReport[] @relation("BilanExternalCause")
actionsTaken InterventionReport[] @relation("BilanActionTaken")
componentsConcerned InterventionReport[] @relation("BilanComponentConcerned")
@@unique([field, label])
}
model InterventionReport {
id String @id @default(uuid()) @db.Uuid
workOrderId String @unique @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
note String?
doorStateId String? @db.Uuid
doorState ReferenceValue? @relation("BilanDoorState", fields: [doorStateId], references: [id])
cabinPositionId String? @db.Uuid
cabinPosition ReferenceValue? @relation("BilanCabinPosition", fields: [cabinPositionId], references: [id])
anomalyId String? @db.Uuid
anomaly ReferenceValue? @relation("BilanAnomaly", fields: [anomalyId], references: [id])
externalCauseId String? @db.Uuid
externalCause ReferenceValue? @relation("BilanExternalCause", fields: [externalCauseId], references: [id])
actionTakenId String? @db.Uuid
actionTaken ReferenceValue? @relation("BilanActionTaken", fields: [actionTakenId], references: [id])
componentConcernedId String? @db.Uuid
componentConcerned ReferenceValue? @relation("BilanComponentConcerned", fields: [componentConcernedId], references: [id])
updatedAt DateTime @updatedAt
}
model TaskTemplate {
id String @id @default(uuid()) @db.Uuid
label String @unique
componentTypeId String? @db.Uuid
componentType Category? @relation(fields: [componentTypeId], references: [id])
periodMonths Int // 1, 3, 6, 12…
isRegulatory Boolean @default(false) // essai parachute
isActive Boolean @default(true)
checklistItems ChecklistItem[]
}
model ChecklistItem {
id String @id @default(uuid()) @db.Uuid
workOrderId String @db.Uuid
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
label String
state ChecklistState @default(PENDING)
templateId String? @db.Uuid
template TaskTemplate? @relation(fields: [templateId], references: [id])
doneById String? @db.Uuid
doneBy User? @relation(fields: [doneById], references: [id])
doneAt DateTime?
@@index([workOrderId])
}
model Meter {
id String @id @default(uuid()) @db.Uuid
assetId String @db.Uuid
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
kind MeterKind
readings MeterReading[]
@@unique([assetId, kind])
}
model MeterReading {
id String @id @default(uuid()) @db.Uuid
meterId String @db.Uuid
meter Meter @relation(fields: [meterId], references: [id], onDelete: Cascade)
value Int // strictement croissant (service)
readById String? @db.Uuid
readBy User? @relation(fields: [readById], references: [id])
createdAt DateTime @default(now())
@@index([meterId])
}

View File

@@ -133,6 +133,7 @@ export async function seed(prisma: PrismaClient): Promise<void> {
); );
await seedUsers(prisma, roleIds, passwordHash); await seedUsers(prisma, roleIds, passwordHash);
await seedReferentiel(prisma); await seedReferentiel(prisma);
await seedExploitation(prisma);
} }
async function seedUsers( async function seedUsers(
@@ -358,6 +359,227 @@ async function seedReferentiel(prisma: PrismaClient): Promise<void> {
} }
} }
// ————— R2 — Exploitation (référentiels du bilan, gabarits, données maquette) —————
const REFERENCE_VALUES: Record<string, string[]> = {
DOOR_STATE: [
'Fonctionnement normal',
'Porte bloquée ouverte',
'Porte bloquée fermée',
'Fermeture incomplète',
'Réouverture intempestive',
],
CABIN_POSITION: ['À niveau', 'Entre deux niveaux', 'Cuvette', 'Dernier niveau'],
ANOMALY: [
'Frottement mécanique',
'Défaut électrique',
'Usure normale',
'Choc / vandalisme',
'Aucune anomalie constatée',
],
EXTERNAL_CAUSE: ['Coupure électrique', 'Dégât des eaux', 'Mauvais usage', 'Aucune'],
ACTION_TAKEN: [
'Réglage',
'Remplacement de pièce',
'Nettoyage / graissage',
'Remise en service simple',
'Visite dentretien',
'Attente de pièce',
],
COMPONENT_CONCERNED: [
'Portes',
'Guides',
'Treuil / machinerie',
'Armoire de commande',
'Boutons / signalisation',
'Parachute',
'Cabine',
],
};
/** Gabarits du préventif (maquette R2) — période calendaire en mois. */
const TASK_TEMPLATES: {
label: string;
periodMonths: number;
componentType?: string;
isRegulatory?: boolean;
}[] = [
{ label: 'Contrôle fermeture / verrouillage des portes', periodMonths: 1, componentType: 'Portes cabine / palières' },
{ label: 'Nettoyage cuvette et toit de cabine', periodMonths: 1 },
{ label: 'Contrôle boutons cabine & paliers', periodMonths: 1, componentType: 'Boutons & signalisation' },
{ label: 'Vérification éclairage de secours', periodMonths: 1 },
{ label: 'Contrôle niveau dhuile réducteur', periodMonths: 3, componentType: 'Treuil / machinerie' },
{ label: 'Vérification jeu des coulisseaux', periodMonths: 6 },
{ label: 'Contrôle câbles de traction (usure, tension)', periodMonths: 6, componentType: 'Treuil / machinerie' },
{ label: 'Essai du parachute', periodMonths: 12, componentType: 'Parachute', isRegulatory: true },
];
async function seedExploitation(prisma: PrismaClient): Promise<void> {
const refIds = new Map<string, string>(); // « FIELD/label » → id
for (const [field, labels] of Object.entries(REFERENCE_VALUES)) {
for (const label of labels) {
const value = await prisma.referenceValue.upsert({
where: { field_label: { field: field as never, label } },
update: {},
create: { field: field as never, label },
});
refIds.set(`${field}/${label}`, value.id);
}
}
const componentTypes = new Map(
(await prisma.category.findMany({ where: { kind: 'COMPONENT_TYPE' } })).map((c) => [
c.name,
c.id,
]),
);
for (const t of TASK_TEMPLATES) {
await prisma.taskTemplate.upsert({
where: { label: t.label },
update: {},
create: {
label: t.label,
periodMonths: t.periodMonths,
isRegulatory: t.isRegulatory ?? false,
componentTypeId: t.componentType ? componentTypes.get(t.componentType) : undefined,
},
});
}
// Données de démonstration (rejouent la maquette : OT en cours, urgence,
// grille du mois, demandes à approuver). Idempotent par référence.
const parReference = async (ref: string) =>
(await prisma.asset.findUniqueOrThrow({ where: { reference: ref } })).id;
const parEmail = async (email: string) =>
(await prisma.user.findUniqueOrThrow({ where: { email } })).id;
const a1 = await parReference('A1');
const b2 = await parReference('B2');
const c1 = await parReference('C1');
const ahmed = await parEmail('technicien@demo.siop.ma');
const salma = await parEmail('dispatcher@demo.siop.ma');
const karim = await parEmail('demandeur@demo.siop.ma');
const annee = new Date().getFullYear();
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
(t) => t.label,
);
const OTS: {
ref: string; title: string; type: 'CORRECTIVE' | 'PREVENTIVE' | 'WORKS';
status: 'OPEN' | 'IN_PROGRESS' | 'ON_HOLD' | 'DONE' | 'CANCELLED';
priority: 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'PERSON_TRAPPED';
assetId: string; assignees?: string[]; dueJours?: number;
checklist?: string[]; bilan?: Record<string, string>;
}[] = [
{
ref: `OT-${annee}-0342`, title: 'Personne bloquée en cabine', type: 'CORRECTIVE',
status: 'OPEN', priority: 'PERSON_TRAPPED', assetId: b2,
},
{
ref: `OT-${annee}-0341`, title: 'Bruit anormal en gaine', type: 'CORRECTIVE',
status: 'IN_PROGRESS', priority: 'HIGH', assetId: a1, assignees: [ahmed], dueJours: 2,
},
{
ref: `OT-${annee}-0338`, title: 'Grille du mois — juillet', type: 'PREVENTIVE',
status: 'IN_PROGRESS', priority: 'LOW', assetId: a1, assignees: [ahmed],
dueJours: 15, checklist: grilleLabels,
},
{
ref: `OT-${annee}-0332`, title: 'Réglage nivellement cabine', type: 'CORRECTIVE',
status: 'DONE', priority: 'LOW', assetId: a1,
bilan: {
doorStateId: refIds.get('DOOR_STATE/Fonctionnement normal')!,
actionTakenId: refIds.get('ACTION_TAKEN/Réglage')!,
componentConcernedId: refIds.get('COMPONENT_CONCERNED/Guides')!,
},
},
];
for (const ot of OTS) {
const existant = await prisma.workOrder.findUnique({ where: { reference: ot.ref } });
if (existant) continue;
await prisma.workOrder.create({
data: {
reference: ot.ref,
title: ot.title,
type: ot.type,
status: ot.status,
priority: ot.priority,
assetId: ot.assetId,
createdById: salma,
dueDate: ot.dueJours
? new Date(Date.now() + ot.dueJours * 24 * 3600 * 1000)
: undefined,
startedAt: ot.status === 'IN_PROGRESS' || ot.status === 'DONE' ? new Date() : undefined,
completedAt: ot.status === 'DONE' ? new Date() : undefined,
assignees: ot.assignees ? { connect: ot.assignees.map((id) => ({ id })) } : undefined,
events: { create: { kind: 'CREATED', message: 'OT créé (seed)', byId: salma } },
checklist: ot.checklist
? { create: ot.checklist.map((label) => ({ label })) }
: undefined,
report: ot.bilan ? { create: ot.bilan } : undefined,
},
});
}
const DEMANDES: {
ref: string; description: string; assetId: string; isPersonTrapped?: boolean;
status: 'RECEIVED' | 'APPROVED' | 'REJECTED';
rejectionReason?: string; otRef?: string;
}[] = [
{
ref: `DEM-${annee}-0111`, assetId: a1, status: 'RECEIVED',
description: 'La porte ne se ferme plus au 3ᵉ étage, il faut la retenir à la main.',
},
{ ref: `DEM-${annee}-0110`, assetId: c1, status: 'RECEIVED', description: 'Voyant étage éteint.' },
{
ref: `DEM-${annee}-0107`, assetId: a1, status: 'APPROVED',
description: 'Bruit anormal en gaine.', otRef: `OT-${annee}-0341`,
},
{
ref: `DEM-${annee}-0104`, assetId: b2, status: 'REJECTED',
description: 'Odeur de brûlé.', rejectionReason: 'Fausse alerte confirmée sur place par le gardien.',
},
];
for (const dem of DEMANDES) {
const existant = await prisma.request.findUnique({ where: { reference: dem.ref } });
if (existant) continue;
const workOrderId = dem.otRef
? (await prisma.workOrder.findUnique({ where: { reference: dem.otRef } }))?.id
: undefined;
await prisma.request.create({
data: {
reference: dem.ref,
description: dem.description,
isPersonTrapped: dem.isPersonTrapped ?? false,
status: dem.status,
rejectionReason: dem.rejectionReason,
assetId: dem.assetId,
requestedById: karim,
workOrderId,
},
});
}
// Compteurs de la maquette (A1)
for (const [kind, valeurs] of [
['RUNNING_HOURS', [12246, 12322, 12411]],
['STARTS', [1815400, 1831970]],
] as const) {
const meter = await prisma.meter.upsert({
where: { assetId_kind: { assetId: a1, kind } },
update: {},
create: { assetId: a1, kind },
include: { _count: { select: { readings: true } } },
});
if (meter._count.readings === 0) {
await prisma.meterReading.createMany({
data: valeurs.map((value) => ({ meterId: meter.id, value, readById: ahmed })),
});
}
}
}
/* c8 ignore start — wrapper CLI */ /* c8 ignore start — wrapper CLI */
if (require.main === module) { if (require.main === module) {
const prisma = new PrismaClient(); const prisma = new PrismaClient();

View File

@@ -9,7 +9,13 @@ 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 { 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 { 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';
@@ -37,6 +43,13 @@ export class AppModule {
LocationsModule, LocationsModule,
AssetsModule, AssetsModule,
TeamsModule, TeamsModule,
// R2 — exploitation
WorkOrdersModule,
RequestsModule,
ReferenceValuesModule,
PreventiveModule,
MetersModule,
PortalModule,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404 // ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []), ...(demoModeEnabled() ? [DemoAuthModule] : []),
], ],

View File

@@ -31,6 +31,13 @@ export class AssetsController {
return this.assetsService.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') @Get(':id')
@RequirePermission('ASSETS', 'view') @RequirePermission('ASSETS', 'view')
get(@Param('id', ParseUUIDPipe) id: string) { get(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -11,6 +11,7 @@ import type {
AssetCreate, AssetCreate,
AssetDetail, AssetDetail,
AssetDto, AssetDto,
AssetOptionsResponse,
AssetsResponse, AssetsResponse,
AssetUpdate, AssetUpdate,
} from '@siop/shared'; } from '@siop/shared';
@@ -28,6 +29,21 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
export class AssetsService { export class AssetsService {
constructor(private readonly prisma: PrismaService) {} 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> { async list(): Promise<AssetsResponse> {
const rows = await this.prisma.asset.findMany({ const rows = await this.prisma.asset.findMany({
include: assetInclude, include: assetInclude,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,13 +5,20 @@ import { Coquille } from '@/layout/coquille';
import PageActivation from '@/pages/activation'; import PageActivation from '@/pages/activation';
import PageAscenseurs from '@/pages/ascenseurs'; import PageAscenseurs from '@/pages/ascenseurs';
import PageCategories from '@/pages/categories'; 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 PageEtiquette from '@/pages/etiquette';
import PageFicheAscenseur from '@/pages/fiche-ascenseur'; import PageFicheAscenseur from '@/pages/fiche-ascenseur';
import PageFicheOT from '@/pages/fiche-ot';
import PageFicheSite from '@/pages/fiche-site'; import PageFicheSite from '@/pages/fiche-site';
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur'; import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
import PageNouvelOT from '@/pages/nouvel-ot';
import PageOrdresTravail from '@/pages/ordres-travail';
import PagePersonnes from '@/pages/personnes'; import PagePersonnes from '@/pages/personnes';
import PagePortail from '@/pages/portail';
import PagePreventif from '@/pages/preventif';
import PageSites from '@/pages/sites'; import PageSites from '@/pages/sites';
import PageTableauDeBord from '@/pages/tableau-de-bord'; import PageTableauDeBord from '@/pages/tableau-de-bord';
@@ -28,6 +35,8 @@ export default function App() {
<Routes> <Routes>
<Route path="/connexion" element={<PageConnexion />} /> <Route path="/connexion" element={<PageConnexion />} />
<Route path="/activation" element={<PageActivation />} /> <Route path="/activation" element={<PageActivation />} />
{/* Portail public — cible du QR cabine, AUCUN compte */}
<Route path="/q/:reference" element={<PagePortail />} />
<Route path="/" element={dansCoquille(<PageTableauDeBord />)} /> <Route path="/" element={dansCoquille(<PageTableauDeBord />)} />
<Route path="/design" element={dansCoquille(<PageDesign />)} /> <Route path="/design" element={dansCoquille(<PageDesign />)} />
<Route path="/sites" element={dansCoquille(<PageSites />)} /> <Route path="/sites" element={dansCoquille(<PageSites />)} />
@@ -36,8 +45,14 @@ export default function App() {
<Route path="/ascenseurs/nouveau" element={dansCoquille(<PageNouvelAscenseur />)} /> <Route path="/ascenseurs/nouveau" element={dansCoquille(<PageNouvelAscenseur />)} />
<Route path="/ascenseurs/:id" element={dansCoquille(<PageFicheAscenseur />)} /> <Route path="/ascenseurs/:id" element={dansCoquille(<PageFicheAscenseur />)} />
<Route path="/ascenseurs/:id/etiquette" element={dansCoquille(<PageEtiquette />)} /> <Route path="/ascenseurs/:id/etiquette" element={dansCoquille(<PageEtiquette />)} />
<Route path="/ascenseurs/:id/compteurs" element={dansCoquille(<PageCompteurs />)} />
<Route path="/personnes" element={dansCoquille(<PagePersonnes />)} /> <Route path="/personnes" element={dansCoquille(<PagePersonnes />)} />
<Route path="/categories" element={dansCoquille(<PageCategories />)} /> <Route path="/categories" element={dansCoquille(<PageCategories />)} />
<Route path="/ot" element={dansCoquille(<PageOrdresTravail />)} />
<Route path="/ot/nouveau" element={dansCoquille(<PageNouvelOT />)} />
<Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} />
<Route path="/demandes" element={dansCoquille(<PageDemandes />)} />
<Route path="/preventif" element={dansCoquille(<PagePreventif />)} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>

View File

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

View File

@@ -38,6 +38,14 @@ export function useAssets() {
}); });
} }
/** 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) { export function useAsset(id: string | undefined) {
return useQuery({ return useQuery({
queryKey: ['assets', id], queryKey: ['assets', id],

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -6,7 +6,9 @@ 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 type { ObjectCategory, PermissionRight } from '@siop/shared';
import { api } from '@/api/client';
import { usePermissions } from '@/auth/use-permissions'; import { usePermissions } from '@/auth/use-permissions';
import { import {
IcoAscenseurs, IcoAscenseurs,
@@ -40,6 +42,7 @@ interface LienNav {
route?: string; route?: string;
release?: string; release?: string;
permission?: [ObjectCategory, PermissionRight]; permission?: [ObjectCategory, PermissionRight];
badge?: 'urgences' | 'demandes';
} }
const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
@@ -47,9 +50,9 @@ 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'] },
], ],
}, },
{ {
@@ -83,6 +86,29 @@ 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();
@@ -91,9 +117,18 @@ export function Coquille({ children }: { children: ReactNode }) {
const logout = useLogout(); const logout = useLogout();
const { can } = usePermissions(); 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);
@@ -116,7 +151,7 @@ export function Coquille({ children }: { children: ReactNode }) {
<div className="groupe">{groupe}</div> <div className="groupe">{groupe}</div>
{liens {liens
.filter(({ permission }) => !permission || can(...permission)) .filter(({ permission }) => !permission || can(...permission))
.map(({ libelle, icone: Icone, route, release }) => .map(({ libelle, icone: Icone, route, release, badge }) =>
route ? ( route ? (
<NavLink <NavLink
key={libelle} key={libelle}
@@ -126,6 +161,9 @@ export function Coquille({ children }: { children: ReactNode }) {
> >
<Icone /> <Icone />
{libelle} {libelle}
{badge && badges[badge] > 0 ? (
<span className="badge-nav num">{badges[badge]}</span>
) : null}
</NavLink> </NavLink>
) : ( ) : (
<a <a
@@ -151,6 +189,19 @@ export function Coquille({ children }: { children: ReactNode }) {
<IcoRecherche /> <IcoRecherche />
Rechercher un OT, un ascenseur, un site&ensp;K Rechercher un OT, un ascenseur, un site&ensp;K
</div> </div>
{urgences.length > 0 ? (
<button
type="button"
className="chip-urgence"
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/ot/${urgences[0]!.id}`)}
>
<span className="point-pulse" />
{urgences.length === 1
? '1 personne bloquée'
: `${urgences.length} personnes bloquées`}
</button>
) : null}
<button <button
type="button" type="button"
className="btn ghost" className="btn ghost"

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { QRCodeSVG } from 'qrcode.react';
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared'; import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
import { useWorkOrders } from '@/api/exploitation';
import { import {
useAddComponent, useAddComponent,
useAsset, useAsset,
@@ -10,6 +11,7 @@ import {
useUpdateAsset, useUpdateAsset,
} from '@/api/referentiel'; } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions'; import { usePermissions } from '@/auth/use-permissions';
import { StatutOT } from '@/components/chips-ot';
import { StatutEquipement } from '@/components/statut-equipement'; import { StatutEquipement } from '@/components/statut-equipement';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale'; import { Modale } from '@/components/ui/modale';
@@ -51,6 +53,9 @@ export default function PageFicheAscenseur() {
))} ))}
</select> </select>
) : null} ) : null}
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/compteurs`)}>
Compteurs
</Button>
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/etiquette`)}> <Button onClick={() => navigate(`/ascenseurs/${asset.id}/etiquette`)}>
Étiquette QR Étiquette QR
</Button> </Button>
@@ -129,12 +134,7 @@ export default function PageFicheAscenseur() {
) : null} ) : null}
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="carte" style={{ borderStyle: 'dashed' }}> <CarteHistorique assetId={asset.id} />
<h2>Historique</h2>
<p className="text-encre-3">
Les interventions de cet appareil apparaîtront ici avec lexploitation (R2).
</p>
</div>
<div className="carte" style={{ borderStyle: 'dashed' }}> <div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Documents</h2> <h2>Documents</h2>
<p className="text-encre-3"> <p className="text-encre-3">
@@ -148,6 +148,37 @@ export default function PageFicheAscenseur() {
); );
} }
/** 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({ function NouvelOrgane({
assetId, assetId,
ouverte, ouverte,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -750,6 +750,168 @@ table {
.avatars .avatar { width: 24px; height: 24px; font-size: 10px; border: 2px solid var(--surface); margin-left: -6px; } .avatars .avatar { width: 24px; height: 24px; font-size: 10px; border: 2px solid var(--surface); margin-left: -6px; }
.avatars .avatar:first-child { margin-left: 0; } .avatars .avatar:first-child { margin-left: 0; }
/* ═══ R2 — Exploitation (classes extraites des maquettes validées) ═══ */
/* Statuts de DEMANDE (validés R2 : extension cohérente des couleurs) */
.st.recue { color: var(--st-ouvert); background: var(--st-ouvert-fond); }
.st.approuvee { color: var(--st-encours); background: var(--st-encours-fond); }
.st.rejetee { color: var(--st-annule); background: var(--st-annule-fond); }
.st.resolue { color: var(--st-termine); background: var(--st-termine-fond); }
/* Urgence « personne bloquée » — seule animation continue de l'app */
.chip-urgence {
display: flex;
align-items: center;
gap: 7px;
background: var(--prio-bloque-fond);
color: var(--prio-bloque);
border: 1px solid color-mix(in srgb, var(--prio-bloque) 30%, transparent);
padding: 6px 12px;
border-radius: var(--rayon-pastille);
font-weight: 700;
font-size: 12.5px;
}
.bandeau-urgence {
display: flex;
align-items: center;
gap: 14px;
background: var(--prio-bloque-fond);
border: 1px solid color-mix(in srgb, var(--prio-bloque) 35%, transparent);
border-left: 4px solid var(--prio-bloque);
border-radius: var(--rayon);
padding: 12px 16px;
}
.bandeau-urgence b { color: var(--prio-bloque); }
.bandeau-urgence .depuis { color: var(--encre-2); font-size: 12.5px; }
/* Tuiles KPI (tableau de bord + préventif) */
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
@media (max-width: 1100px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
.kpi { background: var(--surface); border: 1px solid var(--bordure); border-radius: var(--rayon); padding: 14px 16px; }
.kpi .lib { font-size: 11.5px; letter-spacing: 0.05em; text-transform: uppercase; color: var(--encre-2); font-weight: 600; }
.kpi .val { font-size: 27px; font-weight: 700; letter-spacing: -0.02em; margin-top: 4px; }
.kpi .delta { font-size: 12px; font-weight: 600; margin-top: 2px; }
.delta.bon { color: var(--succes); }
.delta.mauvais { color: var(--danger); }
.delta.neutre { color: var(--encre-3); }
.kpi.alarme { border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
.kpi.alarme .val { color: var(--danger); }
/* Barres horizontales (OT par statut) */
.barres-h .ligne-b { display: grid; grid-template-columns: 110px 1fr 40px; align-items: center; gap: 10px; font-size: 13px; }
.barres-h .fond-b { background: var(--graf-grille); border-radius: 4px; height: 14px; overflow: hidden; }
.barres-h .rempli { height: 100%; border-radius: 4px 0 0 4px; }
.barres-h .valeur { text-align: right; font-weight: 600; }
/* Activité chronologique */
.chrono { list-style: none; display: flex; flex-direction: column; gap: 0; font-size: 13px; padding: 0; }
.chrono li { position: relative; padding: 0 0 16px 22px; }
.chrono li::before { content: ''; position: absolute; left: 5px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--bordure-forte); }
.chrono li.fait::before { background: var(--primaire); }
.chrono li::after { content: ''; position: absolute; left: 8.5px; top: 15px; bottom: -2px; width: 1px; background: var(--bordure); }
.chrono li:last-child::after { display: none; }
.chrono .quand { color: var(--encre-3); font-size: 11.5px; }
/* Checklist Fait / N-A */
.check { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--bordure); font-size: 13.5px; }
.check:last-of-type { border-bottom: 0; }
.coche {
width: 19px; height: 19px; border-radius: 5px; border: 1.5px solid var(--bordure-forte);
flex: none; display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 11px; background: none; padding: 0; cursor: pointer;
}
.coche.ok { background: var(--succes); border-color: var(--succes); }
.coche.na { background: var(--encre-3); border-color: var(--encre-3); font-size: 8.5px; }
.check .qui-fait { margin-left: auto; color: var(--encre-3); font-size: 12px; white-space: nowrap; }
/* Bilan codé (6 champs) */
.bilan { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; }
@media (max-width: 900px) { .bilan { grid-template-columns: 1fr; } }
.bilan .champ-b { font-size: 12.5px; }
.bilan .champ-b label { color: var(--encre-2); font-weight: 600; display: block; margin-bottom: 4px; }
.bilan .champ-b label em { color: var(--danger); font-style: normal; }
.bilan select {
width: 100%; padding: 8px 11px; border: 1px solid var(--bordure-forte);
border-radius: var(--rayon-controle); font: inherit; background: var(--surface); color: var(--encre);
}
.avertissement { margin-top: 12px; font-size: 12.5px; color: var(--alerte); }
/* Préventif */
.periode {
display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 700;
color: var(--primaire); background: var(--primaire-doux); padding: 2px 9px; border-radius: var(--rayon-pastille);
}
.periode.reglementaire { color: var(--safran); background: var(--safran-doux); }
.gen-info { border-left: 3px solid var(--succes); }
/* Panneau d'approbation (demandes) */
.panneau { border: 1px solid var(--bordure); border-radius: var(--rayon); background: var(--surface); padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; }
/* Interrupteur (personne bloquée) */
.gros-toggle {
display: flex; align-items: center; gap: 12px; background: var(--prio-bloque-fond);
border: 1px solid color-mix(in srgb, var(--prio-bloque) 30%, transparent);
border-radius: 10px; padding: 12px 14px;
}
.gros-toggle b { color: var(--prio-bloque); }
.interrupteur {
margin-left: auto; width: 44px; height: 24px; border-radius: var(--rayon-pastille);
background: var(--bordure-forte); position: relative; flex: none; border: 0; cursor: pointer;
transition: background var(--transition);
}
.interrupteur::after {
content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px;
border-radius: 50%; background: #fff; transition: left var(--transition);
}
.interrupteur[aria-checked='true'] { background: var(--prio-bloque); }
.interrupteur[aria-checked='true']::after { left: 22px; }
/* Relevés de compteurs */
.releve-saisie { display: flex; gap: 8px; align-items: center; }
.releve-saisie input {
width: 140px; padding: 8px 11px; border: 1px solid var(--bordure-forte);
border-radius: var(--rayon-controle); font: inherit; background: var(--surface);
color: var(--encre); text-align: right;
}
/* ═══ Portail demandeur (écran 6 validé R0 — aucun compte, aucun jargon) ═══ */
.portail {
max-width: 430px;
margin: 28px auto;
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 14px;
min-height: 100vh;
}
.zone-photo {
border: 1.5px dashed var(--bordure-forte);
border-radius: 10px;
padding: 18px;
text-align: center;
color: var(--encre-2);
font-size: 13px;
}
.bandeau-detecte {
display: flex;
align-items: center;
gap: 10px;
background: var(--primaire-doux);
border-radius: var(--rayon-controle);
padding: 10px 12px;
font-size: 13px;
}
.suivi-simple {
display: flex;
align-items: center;
gap: 0;
font-size: 11.5px;
color: var(--encre-3);
margin-top: 6px;
}
.suivi-simple .etape { display: flex; align-items: center; gap: 6px; }
.suivi-simple .etape.fait { color: var(--succes); font-weight: 700; }
.suivi-simple .lien-e { width: 26px; height: 1px; background: var(--bordure-forte); margin: 0 6px; }
/* ═══ Page /design : nuanciers ═══ */ /* ═══ Page /design : nuanciers ═══ */
.nuancier { .nuancier {
display: flex; display: flex;

View File

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

View File

@@ -116,11 +116,78 @@ model Team { // équipes par zone — l'assignation d'OT (R2) s
| Lien d'activation : 7 jours, usage unique, aucun compte actif avant | service users (+ test e2e) | | 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 | | 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)
- **R2** : `WorkOrder` (machine à états stricte, priorité « personne bloquée »), `Request`
(lien 1-1 vers OT), `InterventionReport` (bilan codé 6 champs → `ReferenceValue`),
`TaskTemplate`/`PreventivePlan`/`ChecklistItem` (périodicité calendrier), `Meter`.
- **R3** : `Part`/`StockMovement` (stock **dérivé des mouvements**), `PurchaseOrder`, - **R3** : `Part`/`StockMovement` (stock **dérivé des mouvements**), `PurchaseOrder`,
`Partner`, `LaborTime` (taux figé), `Document`. `Partner`, `LaborTime` (taux figé), `Document`.
- **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile). - **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile).

View File

@@ -4,6 +4,116 @@ 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 ## 2026-07-16 — Pr. Daaif (+ Claude) — 🏁 R1 CLOSE : recettée, déployée, taguée
**Actions** **Actions**

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,11 @@ for (const op of API_CONTRACT) {
name, name,
in: 'path', in: 'path',
required: true, required: true,
schema: { type: 'string', format: 'uuid' }, // Convention : « id » / «…Id » sont des UUID, le reste est libre
schema:
name === 'id' || name.endsWith('Id')
? { type: 'string', format: 'uuid' }
: { type: 'string' },
})), })),
} }
: {}), : {}),

View File

@@ -12,6 +12,7 @@ import {
AssetComponentCreateSchema, AssetComponentCreateSchema,
AssetComponentSchema, AssetComponentSchema,
AssetDetailSchema, AssetDetailSchema,
AssetOptionsResponseSchema,
AssetsResponseSchema, AssetsResponseSchema,
AssetUpdateSchema, AssetUpdateSchema,
CategoriesResponseSchema, CategoriesResponseSchema,
@@ -37,6 +38,43 @@ import {
UsersResponseSchema, UsersResponseSchema,
UserUpdateSchema, UserUpdateSchema,
} from './schemas/users-admin'; } 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).
@@ -213,6 +251,20 @@ export const API_CONTRACT: ApiOperation[] = [
200: { description: 'Liste', name: 'AssetsResponse', schema: AssetsResponseSchema }, 200: { description: 'Liste', name: 'AssetsResponse', schema: AssetsResponseSchema },
}, },
}, },
{
operationId: 'listAssetOptions',
method: 'get',
path: '/assets/options',
summary: 'Options dappareils pour le signalement (tout rôle authentifié)',
tags: ['assets'],
responses: {
200: {
description: 'Options',
name: 'AssetOptionsResponse',
schema: AssetOptionsResponseSchema,
},
},
},
{ {
operationId: 'getAsset', operationId: 'getAsset',
method: 'get', method: 'get',
@@ -367,6 +419,322 @@ export const API_CONTRACT: ApiOperation[] = [
404: { description: 'Inconnue' }, 404: { description: 'Inconnue' },
}, },
}, },
// ————— R2 · Portail public (QR cabine — aucun compte) —————
{
operationId: 'getPortalAsset',
method: 'get',
path: '/portal/assets/{reference}',
summary: 'Résoudre le QR scanné (référence → appareil, préremplissage)',
tags: ['portal'],
isPublic: true,
pathParams: ['reference'],
responses: {
200: { description: 'Appareil', name: 'PortalAsset', schema: PortalAssetSchema },
404: { description: 'Référence inconnue' },
},
},
{
operationId: 'createPortalRequest',
method: 'post',
path: '/portal/requests',
summary: 'Signaler sans compte — renvoie le jeton de suivi (à garder sur le téléphone)',
tags: ['portal'],
isPublic: true,
request: { name: 'PortalRequestCreate', schema: PortalRequestCreateSchema },
responses: {
201: {
description: 'Signalement enregistré',
name: 'PortalRequestCreated',
schema: PortalRequestCreatedSchema,
},
404: { description: 'Référence inconnue' },
429: { description: 'Trop de signalements — réessayez dans une minute' },
},
},
{
operationId: 'getPortalRequestStatus',
method: 'get',
path: '/portal/requests/{reference}/{token}',
summary: 'Suivi sans jargon (Reçu → Intervention → Résolu) — jeton requis',
tags: ['portal'],
isPublic: true,
pathParams: ['reference', 'token'],
responses: {
200: { description: 'Avancement', name: 'PortalRequestStatus', schema: PortalRequestStatusSchema },
404: { description: 'Signalement inconnu ou jeton invalide' },
},
},
// ————— R2 · Préventif & compteurs —————
{
operationId: 'listTaskTemplates',
method: 'get',
path: '/preventive/templates',
summary: 'Gabarits de tâches du préventif (périodicité en mois)',
tags: ['preventive'],
responses: {
200: { description: 'Liste', name: 'TaskTemplatesResponse', schema: TaskTemplatesResponseSchema },
},
},
{
operationId: 'createTaskTemplate',
method: 'post',
path: '/preventive/templates',
summary: 'Ajouter un gabarit',
tags: ['preventive'],
request: { name: 'TaskTemplateCreate', schema: TaskTemplateCreateSchema },
responses: {
201: { description: 'Créé', name: 'TaskTemplate', schema: TaskTemplateSchema },
409: { description: 'Libellé déjà utilisé' },
},
},
{
operationId: 'updateTaskTemplate',
method: 'patch',
path: '/preventive/templates/{id}',
summary: 'Modifier / (dés)activer un gabarit — jamais de suppression si utilisé',
tags: ['preventive'],
pathParams: ['id'],
request: { name: 'TaskTemplateUpdate', schema: TaskTemplateUpdateSchema },
responses: {
200: { description: 'Mis à jour', name: 'TaskTemplate', schema: TaskTemplateSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'generatePreventive',
method: 'post',
path: '/preventive/generate',
summary: 'Générer la grille du mois — IDEMPOTENT (regénérer ne double rien)',
tags: ['preventive'],
request: { name: 'PreventiveGenerate', schema: PreventiveGenerateSchema },
responses: {
200: {
description: 'Résumé de génération',
name: 'PreventiveGenerationResult',
schema: PreventiveGenerationResultSchema,
},
},
},
{
operationId: 'getPreventiveStatus',
method: 'get',
path: '/preventive/status',
summary: 'État du mois courant (générées, terminées, en retard, premiers contrôles)',
tags: ['preventive'],
responses: {
200: { description: 'État', name: 'PreventiveStatus', schema: PreventiveStatusSchema },
},
},
{
operationId: 'getAssetMeters',
method: 'get',
path: '/assets/{id}/meters',
summary: 'Compteurs de lappareil (relevés récents dabord)',
tags: ['meters'],
pathParams: ['id'],
responses: {
200: { description: 'Compteurs', name: 'MetersResponse', schema: MetersResponseSchema },
404: { description: 'Appareil inconnu' },
},
},
{
operationId: 'addMeterReading',
method: 'post',
path: '/assets/{id}/meter-readings',
summary: 'Enregistrer un relevé — strictement croissant',
tags: ['meters'],
pathParams: ['id'],
request: { name: 'MeterReadingCreate', schema: MeterReadingCreateSchema },
responses: {
201: { description: 'Relevé enregistré', name: 'MetersResponse', schema: MetersResponseSchema },
400: { description: 'Relevé inférieur ou égal au précédent' },
},
},
// ————— R2 · Exploitation —————
{
operationId: 'listWorkOrders',
method: 'get',
path: '/work-orders',
summary: 'Ordres de travail (sans « voir autre » : seulement les siens)',
tags: ['work-orders'],
responses: {
200: { description: 'Liste', name: 'WorkOrdersResponse', schema: WorkOrdersResponseSchema },
},
},
{
operationId: 'getWorkOrder',
method: 'get',
path: '/work-orders/{id}',
summary: 'Fiche OT (activité, checklist, bilan, transitions autorisées, blocages de clôture)',
tags: ['work-orders'],
pathParams: ['id'],
responses: {
200: { description: 'Fiche', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
404: { description: 'Inconnu (ou hors de son périmètre)' },
},
},
{
operationId: 'createWorkOrder',
method: 'post',
path: '/work-orders',
summary: 'Créer un OT (statut Ouvert)',
tags: ['work-orders'],
request: { name: 'WorkOrderCreate', schema: WorkOrderCreateSchema },
responses: {
201: { description: 'Créé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
},
},
{
operationId: 'transitionWorkOrder',
method: 'post',
path: '/work-orders/{id}/transition',
summary: 'Changer létat (machine à états stricte ; DONE exige bilan + checklist)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'TransitionRequest', schema: TransitionRequestSchema },
responses: {
200: { description: 'État changé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
409: { description: 'Transition interdite ou clôture bloquée (garde)' },
},
},
{
operationId: 'commentWorkOrder',
method: 'post',
path: '/work-orders/{id}/comments',
summary: 'Commenter (activité chronologique)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'CommentCreate', schema: CommentCreateSchema },
responses: {
201: { description: 'Commentaire ajouté', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
},
},
{
operationId: 'setWorkOrderAssignees',
method: 'put',
path: '/work-orders/{id}/assignees',
summary: 'Assigner (remplace la liste)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'AssigneesUpdate', schema: AssigneesUpdateSchema },
responses: {
200: { description: 'Assignés', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
},
},
{
operationId: 'upsertWorkOrderReport',
method: 'put',
path: '/work-orders/{id}/report',
summary: 'Renseigner le bilan codé (null efface un champ)',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'ReportUpsert', schema: ReportUpsertSchema },
responses: {
200: { description: 'Bilan enregistré', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
400: { description: 'Valeur hors référentiel du champ' },
},
},
{
operationId: 'patchChecklistItem',
method: 'patch',
path: '/work-orders/{id}/checklist/{itemId}',
summary: 'Régler une tâche de la grille (Fait / N-A / à faire)',
tags: ['work-orders'],
pathParams: ['id', 'itemId'],
request: { name: 'ChecklistPatch', schema: ChecklistPatchSchema },
responses: {
200: { description: 'Tâche réglée', name: 'ChecklistItem', schema: ChecklistItemSchema },
404: { description: 'Tâche inconnue' },
},
},
{
operationId: 'listRequests',
method: 'get',
path: '/requests',
summary: 'Demandes (sans « voir autre » : seulement les siennes)',
tags: ['requests'],
responses: {
200: { description: 'Liste', name: 'RequestsResponse', schema: RequestsResponseSchema },
},
},
{
operationId: 'createRequest',
method: 'post',
path: '/requests',
summary: 'Signaler (interne — le portail public QR arrive en R2.4)',
tags: ['requests'],
request: { name: 'RequestCreate', schema: RequestCreateSchema },
responses: {
201: { description: 'Demande créée', name: 'RequestSummary', schema: RequestSummarySchema },
},
},
{
operationId: 'approveRequest',
method: 'post',
path: '/requests/{id}/approve',
summary: 'Approuver → crée lOT lié (1-1, jamais de doublon)',
tags: ['requests'],
pathParams: ['id'],
request: { name: 'RequestApprove', schema: RequestApproveSchema },
responses: {
201: { description: 'OT créé et lié', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
409: { description: 'Demande déjà traitée' },
},
},
{
operationId: 'rejectRequest',
method: 'post',
path: '/requests/{id}/reject',
summary: 'Rejeter — motif obligatoire, lisible côté demandeur',
tags: ['requests'],
pathParams: ['id'],
request: { name: 'RequestReject', schema: RequestRejectSchema },
responses: {
200: { description: 'Rejetée', name: 'RequestSummary', schema: RequestSummarySchema },
409: { description: 'Demande déjà traitée' },
},
},
{
operationId: 'listReferenceValues',
method: 'get',
path: '/reference-values',
summary: 'Référentiels du bilan codé (6 champs)',
tags: ['reference-values'],
responses: {
200: {
description: 'Liste',
name: 'ReferenceValuesResponse',
schema: ReferenceValuesResponseSchema,
},
},
},
{
operationId: 'createReferenceValue',
method: 'post',
path: '/reference-values',
summary: 'Ajouter une valeur de référentiel',
tags: ['reference-values'],
request: { name: 'ReferenceValueCreate', schema: ReferenceValueCreateSchema },
responses: {
201: { description: 'Créée', name: 'ReferenceValue', schema: ReferenceValueSchema },
409: { description: 'Libellé déjà présent pour ce champ' },
},
},
{
operationId: 'updateReferenceValue',
method: 'patch',
path: '/reference-values/{id}',
summary: 'Renommer ou (dés)activer — jamais de suppression si utilisée',
tags: ['reference-values'],
pathParams: ['id'],
request: { name: 'ReferenceValueUpdate', schema: ReferenceValueUpdateSchema },
responses: {
200: { description: 'Mise à jour', name: 'ReferenceValue', schema: ReferenceValueSchema },
404: { description: 'Inconnue' },
},
},
{ {
operationId: 'getHealth', operationId: 'getHealth',
method: 'get', method: 'get',

View File

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

View File

@@ -1,5 +1,9 @@
export * from './permissions'; export * from './permissions';
export * from './referentiel'; 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/users-admin';

View File

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

View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
import { REQUEST_STATUSES } from '../exploitation';
/**
* Portail public (QR cabine) — AUCUN compte, AUCUN jargon (charte §7).
* Le suivi se fait par jeton opaque gardé sur le téléphone du gardien.
*/
export const PortalAssetSchema = z.object({
reference: z.string(),
siteName: z.string(),
locationName: z.string(),
});
export type PortalAsset = z.infer<typeof PortalAssetSchema>;
export const PortalRequestCreateSchema = z.object({
assetReference: z.string().min(1).max(30),
description: z.string().min(3).max(1000),
isPersonTrapped: z.boolean().optional(),
requesterName: z.string().max(120).optional(),
});
export type PortalRequestCreate = z.infer<typeof PortalRequestCreateSchema>;
/** Étapes lisibles par tous : Reçu → Intervention → Résolu. */
export const PORTAL_STEPS = ['RECEIVED', 'IN_PROGRESS', 'RESOLVED'] as const;
export type PortalStep = (typeof PORTAL_STEPS)[number];
export const PortalRequestStatusSchema = z.object({
reference: z.string(),
description: z.string(),
isPersonTrapped: z.boolean(),
status: z.enum(REQUEST_STATUSES),
step: z.enum(PORTAL_STEPS),
rejectionReason: z.string().nullable(),
createdAt: z.iso.datetime(),
});
export type PortalRequestStatus = z.infer<typeof PortalRequestStatusSchema>;
export const PortalRequestCreatedSchema = PortalRequestStatusSchema.extend({
publicToken: z.string(), // à conserver côté téléphone — seul accès au suivi
});
export type PortalRequestCreated = z.infer<typeof PortalRequestCreatedSchema>;

View File

@@ -0,0 +1,92 @@
import { z } from 'zod';
import { METER_KINDS } from '../exploitation';
// ————— Gabarits du préventif —————
export const TaskTemplateSchema = z.object({
id: z.uuid(),
label: z.string(),
periodMonths: z.number().int(),
isRegulatory: z.boolean(),
isActive: z.boolean(),
componentTypeId: z.uuid().nullable(),
componentTypeName: z.string().nullable(),
});
export type TaskTemplateDto = z.infer<typeof TaskTemplateSchema>;
export const TaskTemplatesResponseSchema = z.object({
templates: z.array(TaskTemplateSchema),
});
export type TaskTemplatesResponse = z.infer<typeof TaskTemplatesResponseSchema>;
export const TaskTemplateCreateSchema = z.object({
label: z.string().min(1).max(200),
periodMonths: z.number().int().min(1).max(60),
componentTypeId: z.uuid().optional(),
isRegulatory: z.boolean().optional(),
});
export type TaskTemplateCreate = z.infer<typeof TaskTemplateCreateSchema>;
export const TaskTemplateUpdateSchema = z.object({
label: z.string().min(1).max(200).optional(),
periodMonths: z.number().int().min(1).max(60).optional(),
componentTypeId: z.uuid().nullable().optional(),
isRegulatory: z.boolean().optional(),
isActive: z.boolean().optional(),
});
export type TaskTemplateUpdate = z.infer<typeof TaskTemplateUpdateSchema>;
// ————— Génération mensuelle —————
const MOIS = /^\d{4}-(0[1-9]|1[0-2])$/;
export const PreventiveGenerateSchema = z.object({
/** « AAAA-MM » — défaut : le mois courant. */
month: z.string().regex(MOIS, 'Format attendu : AAAA-MM').optional(),
});
export type PreventiveGenerate = z.infer<typeof PreventiveGenerateSchema>;
export const PreventiveGenerationResultSchema = z.object({
month: z.string(),
created: z.number().int(), // grilles créées par CET appel
skipped: z.number().int(), // déjà générées (idempotence)
firstControls: z.number().int(), // parmi les créées
references: z.array(z.string()), // OT créés
});
export type PreventiveGenerationResult = z.infer<
typeof PreventiveGenerationResultSchema
>;
export const PreventiveStatusSchema = z.object({
month: z.string(),
assetsUnderContract: z.number().int(),
generated: z.number().int(),
done: z.number().int(),
late: z.number().int(), // échéance dépassée et non terminé
firstControls: z.number().int(),
});
export type PreventiveStatus = z.infer<typeof PreventiveStatusSchema>;
// ————— Compteurs —————
export const MeterReadingSchema = z.object({
id: z.uuid(),
value: z.number().int(),
readBy: z.object({ id: z.uuid(), displayName: z.string() }).nullable(),
createdAt: z.iso.datetime(),
});
export const MeterSchema = z.object({
kind: z.enum(METER_KINDS),
readings: z.array(MeterReadingSchema), // les plus récents d'abord
});
export type MeterDto = z.infer<typeof MeterSchema>;
export const MetersResponseSchema = z.object({ meters: z.array(MeterSchema) });
export type MetersResponse = z.infer<typeof MetersResponseSchema>;
export const MeterReadingCreateSchema = z.object({
kind: z.enum(METER_KINDS),
value: z.number().int().min(0), // strictement croissant (service)
});
export type MeterReadingCreate = z.infer<typeof MeterReadingCreateSchema>;

View File

@@ -97,6 +97,18 @@ export type AssetDto = z.infer<typeof AssetSchema>;
export const AssetsResponseSchema = z.object({ assets: z.array(AssetSchema) }); export const AssetsResponseSchema = z.object({ assets: z.array(AssetSchema) });
export type AssetsResponse = z.infer<typeof AssetsResponseSchema>; export type AssetsResponse = z.infer<typeof AssetsResponseSchema>;
/** Option minimale pour désigner un appareil (signalement) — accessible à
* tout rôle authentifié : les références sont affichées en cabine. */
export const AssetOptionSchema = z.object({
id: z.uuid(),
reference: z.string(),
siteName: z.string(),
});
export const AssetOptionsResponseSchema = z.object({
options: z.array(AssetOptionSchema),
});
export type AssetOptionsResponse = z.infer<typeof AssetOptionsResponseSchema>;
export const AssetDetailSchema = AssetSchema.extend({ export const AssetDetailSchema = AssetSchema.extend({
components: z.array(AssetComponentSchema), components: z.array(AssetComponentSchema),
}); });

16
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^11.1.0 specifier: ^11.1.0
version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.1.28)(supports-color@10.2.2) version: 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.1.28)(supports-color@10.2.2)
'@nestjs/throttler':
specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)
'@prisma/client': '@prisma/client':
specifier: ^6.8.0 specifier: ^6.8.0
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
@@ -1161,6 +1164,13 @@ packages:
'@nestjs/platform-express': '@nestjs/platform-express':
optional: true optional: true
'@nestjs/throttler@6.5.0':
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
peerDependencies:
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
reflect-metadata: ^0.1.13 || ^0.2.0
'@noble/hashes@1.8.0': '@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16} engines: {node: ^14.21.3 || >=16}
@@ -5695,6 +5705,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.1.28)(supports-color@10.2.2) '@nestjs/platform-express': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.1.28)(supports-color@10.2.2)
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/core@11.1.28)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2)
'@nestjs/core': 11.1.28(@nestjs/common@11.1.28(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@10.2.2))(@nestjs/platform-express@11.1.28)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
'@noble/hashes@1.8.0': {} '@noble/hashes@1.8.0': {}
'@nodable/entities@2.2.0': {} '@nodable/entities@2.2.0': {}