mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
Compare commits
7 Commits
release/r1
...
release/r2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aa7189e96 | ||
|
|
f7c0da1b0f | ||
|
|
50254daea4 | ||
|
|
6a2dae1710 | ||
|
|
902efa6192 | ||
|
|
f7702e4252 | ||
|
|
54d926e9f4 |
@@ -44,5 +44,10 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
- ✅ **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`.
|
- ✅ **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`.
|
||||||
- ✅ **R1.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` → source TS (leçon CJS/workspace).
|
- ✅ **R1.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), demande→OT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
|
||||||
|
- ✅ **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production.
|
||||||
|
- ✅ **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API.
|
||||||
|
- ✅ **R2.4 — portail public QR** : `/q/{réf}` sans compte (écran validé R0, mobile d'abord), 3 routes publiques `/portal` throttlées, suivi par jeton opaque (`Request.publicToken`, jamais de liste exposée), étapes Reçu → Intervention → Résolu dérivées de l'OT ; boucle produit complète en e2e (11 Playwright verts, 52 opérations).
|
||||||
|
- 🔄 **R2 — reste pour clore** : recette R2 avec le référent (revue pixel ↔ maquettes R0+R2, sur `pnpm dev` ou l'instance en ligne après déploiement), tag `release/r2` → ouverture R3 Gestion (stock, BC, tiers, main-d'œuvre, analytics, bibliothèque — maquettes à produire d'abord).
|
||||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@nestjs/core": "^11.1.0",
|
"@nestjs/core": "^11.1.0",
|
||||||
"@nestjs/jwt": "^11.0.0",
|
"@nestjs/jwt": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.1.0",
|
"@nestjs/platform-express": "^11.1.0",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/client": "^6.8.0",
|
"@prisma/client": "^6.8.0",
|
||||||
"@siop/shared": "workspace:*",
|
"@siop/shared": "workspace:*",
|
||||||
"argon2": "^0.43.0",
|
"argon2": "^0.43.0",
|
||||||
|
|||||||
@@ -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' } } });
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WorkOrderType" AS ENUM ('CORRECTIVE', 'PREVENTIVE', 'WORKS');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WorkOrderStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'ON_HOLD', 'DONE', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "WorkOrderPriority" AS ENUM ('NONE', 'LOW', 'MEDIUM', 'HIGH', 'PERSON_TRAPPED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "RequestStatus" AS ENUM ('RECEIVED', 'APPROVED', 'REJECTED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ChecklistState" AS ENUM ('PENDING', 'DONE', 'NA');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "BilanField" AS ENUM ('DOOR_STATE', 'CABIN_POSITION', 'ANOMALY', 'EXTERNAL_CAUSE', 'ACTION_TAKEN', 'COMPONENT_CONCERNED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MeterKind" AS ENUM ('RUNNING_HOURS', 'STARTS');
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "Location_position_gix";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Location" DROP COLUMN "position";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WorkOrder" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"type" "WorkOrderType" NOT NULL,
|
||||||
|
"status" "WorkOrderStatus" NOT NULL DEFAULT 'OPEN',
|
||||||
|
"priority" "WorkOrderPriority" NOT NULL DEFAULT 'NONE',
|
||||||
|
"assetId" UUID NOT NULL,
|
||||||
|
"dueDate" TIMESTAMP(3),
|
||||||
|
"createdById" UUID,
|
||||||
|
"startedAt" TIMESTAMP(3),
|
||||||
|
"completedAt" TIMESTAMP(3),
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WorkOrder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "WorkOrderEvent" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"workOrderId" UUID NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"message" TEXT,
|
||||||
|
"byId" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "WorkOrderEvent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Request" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"isPersonTrapped" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"status" "RequestStatus" NOT NULL DEFAULT 'RECEIVED',
|
||||||
|
"rejectionReason" TEXT,
|
||||||
|
"assetId" UUID NOT NULL,
|
||||||
|
"requestedById" UUID,
|
||||||
|
"requesterName" TEXT,
|
||||||
|
"workOrderId" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Request_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ReferenceValue" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"field" "BilanField" NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "ReferenceValue_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "InterventionReport" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"workOrderId" UUID NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"doorStateId" UUID,
|
||||||
|
"cabinPositionId" UUID,
|
||||||
|
"anomalyId" UUID,
|
||||||
|
"externalCauseId" UUID,
|
||||||
|
"actionTakenId" UUID,
|
||||||
|
"componentConcernedId" UUID,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "InterventionReport_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TaskTemplate" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"componentTypeId" UUID,
|
||||||
|
"periodMonths" INTEGER NOT NULL,
|
||||||
|
"isRegulatory" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "TaskTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ChecklistItem" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"workOrderId" UUID NOT NULL,
|
||||||
|
"label" TEXT NOT NULL,
|
||||||
|
"state" "ChecklistState" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"templateId" UUID,
|
||||||
|
"doneById" UUID,
|
||||||
|
"doneAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "ChecklistItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Meter" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"assetId" UUID NOT NULL,
|
||||||
|
"kind" "MeterKind" NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Meter_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MeterReading" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"meterId" UUID NOT NULL,
|
||||||
|
"value" INTEGER NOT NULL,
|
||||||
|
"readById" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MeterReading_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_WorkOrderAssignees" (
|
||||||
|
"A" UUID NOT NULL,
|
||||||
|
"B" UUID NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_WorkOrderAssignees_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WorkOrder_reference_key" ON "WorkOrder"("reference");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WorkOrder_assetId_idx" ON "WorkOrder"("assetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WorkOrder_status_idx" ON "WorkOrder"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "WorkOrderEvent_workOrderId_idx" ON "WorkOrderEvent"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Request_reference_key" ON "Request"("reference");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Request_workOrderId_key" ON "Request"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Request_assetId_idx" ON "Request"("assetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ReferenceValue_field_label_key" ON "ReferenceValue"("field", "label");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "InterventionReport_workOrderId_key" ON "InterventionReport"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TaskTemplate_label_key" ON "TaskTemplate"("label");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ChecklistItem_workOrderId_idx" ON "ChecklistItem"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Meter_assetId_kind_key" ON "Meter"("assetId", "kind");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MeterReading_meterId_idx" ON "MeterReading"("meterId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_WorkOrderAssignees_B_index" ON "_WorkOrderAssignees"("B");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WorkOrder" ADD CONSTRAINT "WorkOrder_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WorkOrder" ADD CONSTRAINT "WorkOrder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WorkOrderEvent" ADD CONSTRAINT "WorkOrderEvent_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "WorkOrderEvent" ADD CONSTRAINT "WorkOrderEvent_byId_fkey" FOREIGN KEY ("byId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Request" ADD CONSTRAINT "Request_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Request" ADD CONSTRAINT "Request_requestedById_fkey" FOREIGN KEY ("requestedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Request" ADD CONSTRAINT "Request_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_doorStateId_fkey" FOREIGN KEY ("doorStateId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_cabinPositionId_fkey" FOREIGN KEY ("cabinPositionId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_anomalyId_fkey" FOREIGN KEY ("anomalyId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_externalCauseId_fkey" FOREIGN KEY ("externalCauseId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_actionTakenId_fkey" FOREIGN KEY ("actionTakenId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "InterventionReport" ADD CONSTRAINT "InterventionReport_componentConcernedId_fkey" FOREIGN KEY ("componentConcernedId") REFERENCES "ReferenceValue"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TaskTemplate" ADD CONSTRAINT "TaskTemplate_componentTypeId_fkey" FOREIGN KEY ("componentTypeId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "TaskTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ChecklistItem" ADD CONSTRAINT "ChecklistItem_doneById_fkey" FOREIGN KEY ("doneById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Meter" ADD CONSTRAINT "Meter_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MeterReading" ADD CONSTRAINT "MeterReading_meterId_fkey" FOREIGN KEY ("meterId") REFERENCES "Meter"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MeterReading" ADD CONSTRAINT "MeterReading_readById_fkey" FOREIGN KEY ("readById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_WorkOrderAssignees" ADD CONSTRAINT "_WorkOrderAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_WorkOrderAssignees" ADD CONSTRAINT "_WorkOrderAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Asset" ADD COLUMN "underContract" BOOLEAN NOT NULL DEFAULT true;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "WorkOrder" ADD COLUMN "periodKey" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "WorkOrder_assetId_periodKey_key" ON "WorkOrder"("assetId", "periodKey");
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Request" ADD COLUMN "publicToken" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Request_publicToken_key" ON "Request"("publicToken");
|
||||||
|
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 d’entretien',
|
||||||
|
'Attente de pièce',
|
||||||
|
],
|
||||||
|
COMPONENT_CONCERNED: [
|
||||||
|
'Portes',
|
||||||
|
'Guides',
|
||||||
|
'Treuil / machinerie',
|
||||||
|
'Armoire de commande',
|
||||||
|
'Boutons / signalisation',
|
||||||
|
'Parachute',
|
||||||
|
'Cabine',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Gabarits du préventif (maquette R2) — période calendaire en mois. */
|
||||||
|
const TASK_TEMPLATES: {
|
||||||
|
label: string;
|
||||||
|
periodMonths: number;
|
||||||
|
componentType?: string;
|
||||||
|
isRegulatory?: boolean;
|
||||||
|
}[] = [
|
||||||
|
{ label: 'Contrôle fermeture / verrouillage des portes', periodMonths: 1, componentType: 'Portes cabine / palières' },
|
||||||
|
{ label: 'Nettoyage cuvette et toit de cabine', periodMonths: 1 },
|
||||||
|
{ label: 'Contrôle boutons cabine & paliers', periodMonths: 1, componentType: 'Boutons & signalisation' },
|
||||||
|
{ label: 'Vérification éclairage de secours', periodMonths: 1 },
|
||||||
|
{ label: 'Contrôle niveau d’huile réducteur', periodMonths: 3, componentType: 'Treuil / machinerie' },
|
||||||
|
{ label: 'Vérification jeu des coulisseaux', periodMonths: 6 },
|
||||||
|
{ label: 'Contrôle câbles de traction (usure, tension)', periodMonths: 6, componentType: 'Treuil / machinerie' },
|
||||||
|
{ label: 'Essai du parachute', periodMonths: 12, componentType: 'Parachute', isRegulatory: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedExploitation(prisma: PrismaClient): Promise<void> {
|
||||||
|
const refIds = new Map<string, string>(); // « FIELD/label » → id
|
||||||
|
for (const [field, labels] of Object.entries(REFERENCE_VALUES)) {
|
||||||
|
for (const label of labels) {
|
||||||
|
const value = await prisma.referenceValue.upsert({
|
||||||
|
where: { field_label: { field: field as never, label } },
|
||||||
|
update: {},
|
||||||
|
create: { field: field as never, label },
|
||||||
|
});
|
||||||
|
refIds.set(`${field}/${label}`, value.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const componentTypes = new Map(
|
||||||
|
(await prisma.category.findMany({ where: { kind: 'COMPONENT_TYPE' } })).map((c) => [
|
||||||
|
c.name,
|
||||||
|
c.id,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
for (const t of TASK_TEMPLATES) {
|
||||||
|
await prisma.taskTemplate.upsert({
|
||||||
|
where: { label: t.label },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
label: t.label,
|
||||||
|
periodMonths: t.periodMonths,
|
||||||
|
isRegulatory: t.isRegulatory ?? false,
|
||||||
|
componentTypeId: t.componentType ? componentTypes.get(t.componentType) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Données de démonstration (rejouent la maquette : OT en cours, urgence,
|
||||||
|
// grille du mois, demandes à approuver). Idempotent par référence.
|
||||||
|
const parReference = async (ref: string) =>
|
||||||
|
(await prisma.asset.findUniqueOrThrow({ where: { reference: ref } })).id;
|
||||||
|
const parEmail = async (email: string) =>
|
||||||
|
(await prisma.user.findUniqueOrThrow({ where: { email } })).id;
|
||||||
|
|
||||||
|
const a1 = await parReference('A1');
|
||||||
|
const b2 = await parReference('B2');
|
||||||
|
const c1 = await parReference('C1');
|
||||||
|
const ahmed = await parEmail('technicien@demo.siop.ma');
|
||||||
|
const salma = await parEmail('dispatcher@demo.siop.ma');
|
||||||
|
const karim = await parEmail('demandeur@demo.siop.ma');
|
||||||
|
const annee = new Date().getFullYear();
|
||||||
|
|
||||||
|
const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map(
|
||||||
|
(t) => t.label,
|
||||||
|
);
|
||||||
|
|
||||||
|
const OTS: {
|
||||||
|
ref: string; title: string; type: 'CORRECTIVE' | 'PREVENTIVE' | 'WORKS';
|
||||||
|
status: 'OPEN' | 'IN_PROGRESS' | 'ON_HOLD' | 'DONE' | 'CANCELLED';
|
||||||
|
priority: 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'PERSON_TRAPPED';
|
||||||
|
assetId: string; assignees?: string[]; dueJours?: number;
|
||||||
|
checklist?: string[]; bilan?: Record<string, string>;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
ref: `OT-${annee}-0342`, title: 'Personne bloquée en cabine', type: 'CORRECTIVE',
|
||||||
|
status: 'OPEN', priority: 'PERSON_TRAPPED', assetId: b2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: `OT-${annee}-0341`, title: 'Bruit anormal en gaine', type: 'CORRECTIVE',
|
||||||
|
status: 'IN_PROGRESS', priority: 'HIGH', assetId: a1, assignees: [ahmed], dueJours: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: `OT-${annee}-0338`, title: 'Grille du mois — juillet', type: 'PREVENTIVE',
|
||||||
|
status: 'IN_PROGRESS', priority: 'LOW', assetId: a1, assignees: [ahmed],
|
||||||
|
dueJours: 15, checklist: grilleLabels,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: `OT-${annee}-0332`, title: 'Réglage nivellement cabine', type: 'CORRECTIVE',
|
||||||
|
status: 'DONE', priority: 'LOW', assetId: a1,
|
||||||
|
bilan: {
|
||||||
|
doorStateId: refIds.get('DOOR_STATE/Fonctionnement normal')!,
|
||||||
|
actionTakenId: refIds.get('ACTION_TAKEN/Réglage')!,
|
||||||
|
componentConcernedId: refIds.get('COMPONENT_CONCERNED/Guides')!,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const ot of OTS) {
|
||||||
|
const existant = await prisma.workOrder.findUnique({ where: { reference: ot.ref } });
|
||||||
|
if (existant) continue;
|
||||||
|
await prisma.workOrder.create({
|
||||||
|
data: {
|
||||||
|
reference: ot.ref,
|
||||||
|
title: ot.title,
|
||||||
|
type: ot.type,
|
||||||
|
status: ot.status,
|
||||||
|
priority: ot.priority,
|
||||||
|
assetId: ot.assetId,
|
||||||
|
createdById: salma,
|
||||||
|
dueDate: ot.dueJours
|
||||||
|
? new Date(Date.now() + ot.dueJours * 24 * 3600 * 1000)
|
||||||
|
: undefined,
|
||||||
|
startedAt: ot.status === 'IN_PROGRESS' || ot.status === 'DONE' ? new Date() : undefined,
|
||||||
|
completedAt: ot.status === 'DONE' ? new Date() : undefined,
|
||||||
|
assignees: ot.assignees ? { connect: ot.assignees.map((id) => ({ id })) } : undefined,
|
||||||
|
events: { create: { kind: 'CREATED', message: 'OT créé (seed)', byId: salma } },
|
||||||
|
checklist: ot.checklist
|
||||||
|
? { create: ot.checklist.map((label) => ({ label })) }
|
||||||
|
: undefined,
|
||||||
|
report: ot.bilan ? { create: ot.bilan } : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEMANDES: {
|
||||||
|
ref: string; description: string; assetId: string; isPersonTrapped?: boolean;
|
||||||
|
status: 'RECEIVED' | 'APPROVED' | 'REJECTED';
|
||||||
|
rejectionReason?: string; otRef?: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
ref: `DEM-${annee}-0111`, assetId: a1, status: 'RECEIVED',
|
||||||
|
description: 'La porte ne se ferme plus au 3ᵉ étage, il faut la retenir à la main.',
|
||||||
|
},
|
||||||
|
{ ref: `DEM-${annee}-0110`, assetId: c1, status: 'RECEIVED', description: 'Voyant étage éteint.' },
|
||||||
|
{
|
||||||
|
ref: `DEM-${annee}-0107`, assetId: a1, status: 'APPROVED',
|
||||||
|
description: 'Bruit anormal en gaine.', otRef: `OT-${annee}-0341`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ref: `DEM-${annee}-0104`, assetId: b2, status: 'REJECTED',
|
||||||
|
description: 'Odeur de brûlé.', rejectionReason: 'Fausse alerte confirmée sur place par le gardien.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const dem of DEMANDES) {
|
||||||
|
const existant = await prisma.request.findUnique({ where: { reference: dem.ref } });
|
||||||
|
if (existant) continue;
|
||||||
|
const workOrderId = dem.otRef
|
||||||
|
? (await prisma.workOrder.findUnique({ where: { reference: dem.otRef } }))?.id
|
||||||
|
: undefined;
|
||||||
|
await prisma.request.create({
|
||||||
|
data: {
|
||||||
|
reference: dem.ref,
|
||||||
|
description: dem.description,
|
||||||
|
isPersonTrapped: dem.isPersonTrapped ?? false,
|
||||||
|
status: dem.status,
|
||||||
|
rejectionReason: dem.rejectionReason,
|
||||||
|
assetId: dem.assetId,
|
||||||
|
requestedById: karim,
|
||||||
|
workOrderId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compteurs de la maquette (A1)
|
||||||
|
for (const [kind, valeurs] of [
|
||||||
|
['RUNNING_HOURS', [12246, 12322, 12411]],
|
||||||
|
['STARTS', [1815400, 1831970]],
|
||||||
|
] as const) {
|
||||||
|
const meter = await prisma.meter.upsert({
|
||||||
|
where: { assetId_kind: { assetId: a1, kind } },
|
||||||
|
update: {},
|
||||||
|
create: { assetId: a1, kind },
|
||||||
|
include: { _count: { select: { readings: true } } },
|
||||||
|
});
|
||||||
|
if (meter._count.readings === 0) {
|
||||||
|
await prisma.meterReading.createMany({
|
||||||
|
data: valeurs.map((value) => ({ meterId: meter.id, value, readById: ahmed })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* c8 ignore start — wrapper CLI */
|
/* c8 ignore start — wrapper CLI */
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|||||||
@@ -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] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
40
apps/api/src/meters/meters.controller.ts
Normal file
40
apps/api/src/meters/meters.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
MeterReadingCreateSchema,
|
||||||
|
type MeterReadingCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { MetersService } from './meters.service';
|
||||||
|
|
||||||
|
@Controller('assets/:id')
|
||||||
|
export class MetersController {
|
||||||
|
constructor(private readonly meters: MetersService) {}
|
||||||
|
|
||||||
|
@Get('meters')
|
||||||
|
@RequirePermission('METERS', 'view')
|
||||||
|
list(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.meters.list(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('meter-readings')
|
||||||
|
@RequirePermission('METERS', 'create')
|
||||||
|
addReading(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(MeterReadingCreateSchema)) body: MeterReadingCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.meters.addReading(id, body, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/meters/meters.module.ts
Normal file
9
apps/api/src/meters/meters.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MetersController } from './meters.controller';
|
||||||
|
import { MetersService } from './meters.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [MetersController],
|
||||||
|
providers: [MetersService],
|
||||||
|
})
|
||||||
|
export class MetersModule {}
|
||||||
77
apps/api/src/meters/meters.service.ts
Normal file
77
apps/api/src/meters/meters.service.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { METER_KINDS, type MeterReadingCreate, type MetersResponse } from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MetersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(assetId: string): Promise<MetersResponse> {
|
||||||
|
await this.assertAsset(assetId);
|
||||||
|
const meters = await this.prisma.meter.findMany({
|
||||||
|
where: { assetId },
|
||||||
|
include: {
|
||||||
|
readings: {
|
||||||
|
include: { readBy: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Les deux compteurs existent toujours dans la réponse, même vides
|
||||||
|
return {
|
||||||
|
meters: METER_KINDS.map((kind) => {
|
||||||
|
const meter = meters.find((m) => m.kind === kind);
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
readings:
|
||||||
|
meter?.readings.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
value: r.value,
|
||||||
|
readBy: r.readBy
|
||||||
|
? { id: r.readBy.id, displayName: r.readBy.displayName }
|
||||||
|
: null,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
})) ?? [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invariant : un compteur ne redescend jamais. */
|
||||||
|
async addReading(
|
||||||
|
assetId: string,
|
||||||
|
dto: MeterReadingCreate,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<MetersResponse> {
|
||||||
|
await this.assertAsset(assetId);
|
||||||
|
const meter = await this.prisma.meter.upsert({
|
||||||
|
where: { assetId_kind: { assetId, kind: dto.kind } },
|
||||||
|
update: {},
|
||||||
|
create: { assetId, kind: dto.kind },
|
||||||
|
});
|
||||||
|
const dernier = await this.prisma.meterReading.findFirst({
|
||||||
|
where: { meterId: meter.id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (dernier && dto.value <= dernier.value) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Relevé refusé : ${dto.value} est inférieur ou égal au précédent (${dernier.value}) — un compteur ne redescend pas`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.prisma.meterReading.create({
|
||||||
|
data: { meterId: meter.id, value: dto.value, readById: user.userId },
|
||||||
|
});
|
||||||
|
return this.list(assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertAsset(assetId: string): Promise<void> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||||
|
if (!asset) throw new NotFoundException('Appareil inconnu');
|
||||||
|
}
|
||||||
|
}
|
||||||
40
apps/api/src/portal/portal.controller.ts
Normal file
40
apps/api/src/portal/portal.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import {
|
||||||
|
PortalRequestCreateSchema,
|
||||||
|
type PortalRequestCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { PortalService } from './portal.service';
|
||||||
|
|
||||||
|
/** Portail public (QR cabine) : seules routes @Public métier de l'API —
|
||||||
|
* protégées par throttling (surface exposée sans compte). */
|
||||||
|
@Controller('portal')
|
||||||
|
@UseGuards(ThrottlerGuard)
|
||||||
|
export class PortalController {
|
||||||
|
constructor(private readonly portal: PortalService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('assets/:reference')
|
||||||
|
@Throttle({ default: { ttl: 60_000, limit: 60 } })
|
||||||
|
resolveAsset(@Param('reference') reference: string) {
|
||||||
|
return this.portal.resolveAsset(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('requests')
|
||||||
|
@Throttle({ default: { ttl: 60_000, limit: 10 } }) // anti-spam signalement
|
||||||
|
createRequest(
|
||||||
|
@Body(new ZodValidationPipe(PortalRequestCreateSchema)) body: PortalRequestCreate,
|
||||||
|
) {
|
||||||
|
return this.portal.createRequest(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('requests/:reference/:token')
|
||||||
|
@Throttle({ default: { ttl: 60_000, limit: 60 } })
|
||||||
|
status(@Param('reference') reference: string, @Param('token') token: string) {
|
||||||
|
return this.portal.status(reference, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/portal/portal.module.ts
Normal file
11
apps/api/src/portal/portal.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { PortalController } from './portal.controller';
|
||||||
|
import { PortalService } from './portal.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ThrottlerModule.forRoot([{ ttl: 60_000, limit: 60 }])],
|
||||||
|
controllers: [PortalController],
|
||||||
|
providers: [PortalService],
|
||||||
|
})
|
||||||
|
export class PortalModule {}
|
||||||
106
apps/api/src/portal/portal.service.ts
Normal file
106
apps/api/src/portal/portal.service.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
PortalAsset,
|
||||||
|
PortalRequestCreate,
|
||||||
|
PortalRequestCreated,
|
||||||
|
PortalRequestStatus,
|
||||||
|
PortalStep,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const requestInclude = { workOrder: true } satisfies Prisma.RequestInclude;
|
||||||
|
type Row = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PortalService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Le QR encode la référence (imprimée sur l'étiquette). */
|
||||||
|
async resolveAsset(reference: string): Promise<PortalAsset> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({
|
||||||
|
where: { reference },
|
||||||
|
include: { location: { include: { parent: true } } },
|
||||||
|
});
|
||||||
|
if (!asset) throw new NotFoundException('Référence inconnue');
|
||||||
|
return {
|
||||||
|
reference: asset.reference,
|
||||||
|
siteName: asset.location.parent?.name ?? asset.location.name,
|
||||||
|
locationName: asset.location.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRequest(dto: PortalRequestCreate): Promise<PortalRequestCreated> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({
|
||||||
|
where: { reference: dto.assetReference },
|
||||||
|
});
|
||||||
|
if (!asset) throw new NotFoundException('Référence inconnue');
|
||||||
|
for (let essai = 0; ; essai++) {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.request.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
description: dto.description,
|
||||||
|
isPersonTrapped: dto.isPersonTrapped ?? false,
|
||||||
|
assetId: asset.id,
|
||||||
|
requesterName: dto.requesterName || 'Portail (QR cabine)',
|
||||||
|
publicToken: randomBytes(24).toString('base64url'),
|
||||||
|
},
|
||||||
|
include: requestInclude,
|
||||||
|
});
|
||||||
|
return { ...this.toStatus(created), publicToken: created.publicToken! };
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002' &&
|
||||||
|
essai < 3
|
||||||
|
) {
|
||||||
|
continue; // collision de référence — on retente
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suivi par jeton opaque — pas de jeton, pas de lecture (aucune fuite). */
|
||||||
|
async status(reference: string, token: string): Promise<PortalRequestStatus> {
|
||||||
|
const request = await this.prisma.request.findUnique({
|
||||||
|
where: { reference },
|
||||||
|
include: requestInclude,
|
||||||
|
});
|
||||||
|
if (!request || !request.publicToken || request.publicToken !== token) {
|
||||||
|
throw new NotFoundException('Signalement inconnu ou jeton invalide');
|
||||||
|
}
|
||||||
|
return this.toStatus(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
const annee = new Date().getFullYear();
|
||||||
|
const dernier = await this.prisma.request.findFirst({
|
||||||
|
where: { reference: { startsWith: `DEM-${annee}-` } },
|
||||||
|
orderBy: { reference: 'desc' },
|
||||||
|
select: { reference: true },
|
||||||
|
});
|
||||||
|
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
||||||
|
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toStatus(row: Row): PortalRequestStatus {
|
||||||
|
const step: PortalStep =
|
||||||
|
row.workOrder?.status === 'DONE'
|
||||||
|
? 'RESOLVED'
|
||||||
|
: row.workOrder && row.workOrder.startedAt
|
||||||
|
? 'IN_PROGRESS'
|
||||||
|
: 'RECEIVED';
|
||||||
|
return {
|
||||||
|
reference: row.reference,
|
||||||
|
description: row.description,
|
||||||
|
isPersonTrapped: row.isPersonTrapped,
|
||||||
|
status: row.status,
|
||||||
|
step,
|
||||||
|
rejectionReason: row.rejectionReason,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
69
apps/api/src/preventive/preventive.controller.ts
Normal file
69
apps/api/src/preventive/preventive.controller.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PreventiveGenerateSchema,
|
||||||
|
TaskTemplateCreateSchema,
|
||||||
|
TaskTemplateUpdateSchema,
|
||||||
|
type PreventiveGenerate,
|
||||||
|
type TaskTemplateCreate,
|
||||||
|
type TaskTemplateUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { PreventiveService } from './preventive.service';
|
||||||
|
|
||||||
|
@Controller('preventive')
|
||||||
|
export class PreventiveController {
|
||||||
|
constructor(private readonly preventive: PreventiveService) {}
|
||||||
|
|
||||||
|
@Get('templates')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'view')
|
||||||
|
listTemplates() {
|
||||||
|
return this.preventive.listTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('templates')
|
||||||
|
@RequirePermission('SETTINGS', 'create')
|
||||||
|
createTemplate(
|
||||||
|
@Body(new ZodValidationPipe(TaskTemplateCreateSchema)) body: TaskTemplateCreate,
|
||||||
|
) {
|
||||||
|
return this.preventive.createTemplate(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('templates/:id')
|
||||||
|
@RequirePermission('SETTINGS', 'edit')
|
||||||
|
updateTemplate(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(TaskTemplateUpdateSchema)) body: TaskTemplateUpdate,
|
||||||
|
) {
|
||||||
|
return this.preventive.updateTemplate(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('generate')
|
||||||
|
@HttpCode(200) // idempotent : rien n'est « créé » de plus au second appel
|
||||||
|
@RequirePermission('WORK_ORDERS', 'create')
|
||||||
|
generate(
|
||||||
|
@Body(new ZodValidationPipe(PreventiveGenerateSchema)) body: PreventiveGenerate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.preventive.generate(body.month, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('status')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'view')
|
||||||
|
status() {
|
||||||
|
return this.preventive.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/preventive/preventive.module.ts
Normal file
9
apps/api/src/preventive/preventive.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PreventiveController } from './preventive.controller';
|
||||||
|
import { PreventiveService } from './preventive.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PreventiveController],
|
||||||
|
providers: [PreventiveService],
|
||||||
|
})
|
||||||
|
export class PreventiveModule {}
|
||||||
234
apps/api/src/preventive/preventive.service.ts
Normal file
234
apps/api/src/preventive/preventive.service.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
PreventiveGenerationResult,
|
||||||
|
PreventiveStatus,
|
||||||
|
TaskTemplateCreate,
|
||||||
|
TaskTemplateDto,
|
||||||
|
TaskTemplatesResponse,
|
||||||
|
TaskTemplateUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const templateInclude = {
|
||||||
|
componentType: true,
|
||||||
|
} satisfies Prisma.TaskTemplateInclude;
|
||||||
|
|
||||||
|
type TemplateRow = Prisma.TaskTemplateGetPayload<{ include: typeof templateInclude }>;
|
||||||
|
|
||||||
|
const MOIS_FR = [
|
||||||
|
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||||
|
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** « AAAA-MM » → index absolu de mois. */
|
||||||
|
const indexMois = (period: string): number => {
|
||||||
|
const [annee, mois] = period.split('-').map(Number);
|
||||||
|
return annee! * 12 + (mois! - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PreventiveService {
|
||||||
|
private readonly logger = new Logger(PreventiveService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// ————— Gabarits —————
|
||||||
|
|
||||||
|
async listTemplates(): Promise<TaskTemplatesResponse> {
|
||||||
|
const rows = await this.prisma.taskTemplate.findMany({
|
||||||
|
include: templateInclude,
|
||||||
|
orderBy: [{ periodMonths: 'asc' }, { label: 'asc' }],
|
||||||
|
});
|
||||||
|
return { templates: rows.map((t) => this.toDto(t)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTemplate(dto: TaskTemplateCreate): Promise<TaskTemplateDto> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.taskTemplate.create({
|
||||||
|
data: dto,
|
||||||
|
include: templateInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce libellé de tâche existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTemplate(id: string, dto: TaskTemplateUpdate): Promise<TaskTemplateDto> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.taskTemplate.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: templateInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Gabarit inconnu');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce libellé de tâche existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Génération mensuelle (IDEMPOTENTE) —————
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Une grille par appareil sous contrat et par mois — l'unicité
|
||||||
|
* [assetId, periodKey] est en BASE : regénérer ne double jamais rien,
|
||||||
|
* même en cas d'appels concurrents.
|
||||||
|
* Tâches dues : périodicité ancrée sur la mise en service (à défaut, tout
|
||||||
|
* mois) ; appareil sans historique préventif → « premier contrôle » (tout).
|
||||||
|
*/
|
||||||
|
async generate(
|
||||||
|
month: string | undefined,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<PreventiveGenerationResult> {
|
||||||
|
const period = month ?? new Date().toISOString().slice(0, 7);
|
||||||
|
const [annee, mois] = period.split('-').map(Number);
|
||||||
|
const dernierJour = new Date(Date.UTC(annee!, mois!, 0, 23, 59, 59));
|
||||||
|
const libelleMois = `${MOIS_FR[mois! - 1]} ${annee}`;
|
||||||
|
|
||||||
|
const [assets, templates] = await Promise.all([
|
||||||
|
this.prisma.asset.findMany({
|
||||||
|
where: { underContract: true },
|
||||||
|
include: { _count: { select: { workOrders: { where: { type: 'PREVENTIVE' } } } } },
|
||||||
|
}),
|
||||||
|
this.prisma.taskTemplate.findMany({ where: { isActive: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let firstControls = 0;
|
||||||
|
const references: string[] = [];
|
||||||
|
|
||||||
|
for (const asset of assets) {
|
||||||
|
const premierControle = asset._count.workOrders === 0;
|
||||||
|
const dues = premierControle
|
||||||
|
? templates
|
||||||
|
: templates.filter((t) => {
|
||||||
|
if (t.periodMonths === 1) return true;
|
||||||
|
const ancre = asset.commissionedAt
|
||||||
|
? asset.commissionedAt.getUTCFullYear() * 12 + asset.commissionedAt.getUTCMonth()
|
||||||
|
: indexMois(period);
|
||||||
|
return (indexMois(period) - ancre) % t.periodMonths === 0;
|
||||||
|
});
|
||||||
|
if (dues.length === 0) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let issue: 'créé' | 'sauté' | null = null;
|
||||||
|
for (let essai = 0; essai < 4 && issue === null; essai++) {
|
||||||
|
try {
|
||||||
|
const ot = await this.prisma.workOrder.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
title: premierControle
|
||||||
|
? `Premier contrôle — ${libelleMois}`
|
||||||
|
: `Grille du mois — ${libelleMois}`,
|
||||||
|
type: 'PREVENTIVE',
|
||||||
|
priority: 'LOW',
|
||||||
|
assetId: asset.id,
|
||||||
|
periodKey: period,
|
||||||
|
dueDate: dernierJour,
|
||||||
|
createdById: user.userId,
|
||||||
|
checklist: {
|
||||||
|
create: dues.map((t) => ({ label: t.label, templateId: t.id })),
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
create: {
|
||||||
|
kind: 'GENERATED',
|
||||||
|
message: `Généré (${premierControle ? 'premier contrôle' : 'grille mensuelle'} · ${dues.length} tâches)`,
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { reference: true },
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
if (premierControle) firstControls++;
|
||||||
|
references.push(ot.reference);
|
||||||
|
issue = 'créé';
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
const cible = (e.meta?.target as string[] | string | undefined) ?? '';
|
||||||
|
if (String(cible).includes('periodKey')) {
|
||||||
|
issue = 'sauté'; // grille déjà en place : idempotence
|
||||||
|
}
|
||||||
|
// sinon : collision de référence (course avec une création d'OT)
|
||||||
|
// → on recalcule et on retente
|
||||||
|
} else if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2003'
|
||||||
|
) {
|
||||||
|
issue = 'sauté'; // appareil supprimé entre lecture et écriture
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (issue !== 'créé') skipped++;
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Préventif ${period} : ${created} générés, ${skipped} déjà en place, ${firstControls} premiers contrôles.`,
|
||||||
|
);
|
||||||
|
return { month: period, created, skipped, firstControls, references };
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(): Promise<PreventiveStatus> {
|
||||||
|
const period = new Date().toISOString().slice(0, 7);
|
||||||
|
const [assetsUnderContract, grilles] = await Promise.all([
|
||||||
|
this.prisma.asset.count({ where: { underContract: true } }),
|
||||||
|
this.prisma.workOrder.findMany({
|
||||||
|
where: { periodKey: period },
|
||||||
|
select: { status: true, dueDate: true, title: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const maintenant = new Date();
|
||||||
|
return {
|
||||||
|
month: period,
|
||||||
|
assetsUnderContract,
|
||||||
|
generated: grilles.length,
|
||||||
|
done: grilles.filter((g) => g.status === 'DONE').length,
|
||||||
|
late: grilles.filter(
|
||||||
|
(g) => g.status !== 'DONE' && g.status !== 'CANCELLED' && g.dueDate! < maintenant,
|
||||||
|
).length,
|
||||||
|
firstControls: grilles.filter((g) => g.title.startsWith('Premier contrôle')).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
const annee = new Date().getFullYear();
|
||||||
|
const dernier = await this.prisma.workOrder.findFirst({
|
||||||
|
where: { reference: { startsWith: `OT-${annee}-` } },
|
||||||
|
orderBy: { reference: 'desc' },
|
||||||
|
select: { reference: true },
|
||||||
|
});
|
||||||
|
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
||||||
|
return `OT-${annee}-${String(n).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: TemplateRow): TaskTemplateDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
label: row.label,
|
||||||
|
periodMonths: row.periodMonths,
|
||||||
|
isRegulatory: row.isRegulatory,
|
||||||
|
isActive: row.isActive,
|
||||||
|
componentTypeId: row.componentTypeId,
|
||||||
|
componentTypeName: row.componentType?.name ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/api/src/reference-values/reference-values.controller.ts
Normal file
46
apps/api/src/reference-values/reference-values.controller.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ReferenceValueCreateSchema,
|
||||||
|
ReferenceValueUpdateSchema,
|
||||||
|
type ReferenceValueCreate,
|
||||||
|
type ReferenceValueUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { ReferenceValuesService } from './reference-values.service';
|
||||||
|
|
||||||
|
@Controller('reference-values')
|
||||||
|
export class ReferenceValuesController {
|
||||||
|
constructor(private readonly referenceValues: ReferenceValuesService) {}
|
||||||
|
|
||||||
|
/** Lu par le formulaire de bilan — authentification seule. */
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.referenceValues.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('SETTINGS', 'create')
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(ReferenceValueCreateSchema)) body: ReferenceValueCreate,
|
||||||
|
) {
|
||||||
|
return this.referenceValues.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('SETTINGS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(ReferenceValueUpdateSchema)) body: ReferenceValueUpdate,
|
||||||
|
) {
|
||||||
|
return this.referenceValues.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/reference-values/reference-values.module.ts
Normal file
10
apps/api/src/reference-values/reference-values.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ReferenceValuesController } from './reference-values.controller';
|
||||||
|
import { ReferenceValuesService } from './reference-values.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ReferenceValuesController],
|
||||||
|
providers: [ReferenceValuesService],
|
||||||
|
exports: [ReferenceValuesService],
|
||||||
|
})
|
||||||
|
export class ReferenceValuesModule {}
|
||||||
93
apps/api/src/reference-values/reference-values.service.ts
Normal file
93
apps/api/src/reference-values/reference-values.service.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
ReferenceValueCreate,
|
||||||
|
ReferenceValueDto,
|
||||||
|
ReferenceValuesResponse,
|
||||||
|
ReferenceValueUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const usageInclude = {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
doorStates: true,
|
||||||
|
cabinPositions: true,
|
||||||
|
anomalies: true,
|
||||||
|
externalCauses: true,
|
||||||
|
actionsTaken: true,
|
||||||
|
componentsConcerned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.ReferenceValueInclude;
|
||||||
|
|
||||||
|
type Row = Prisma.ReferenceValueGetPayload<{ include: typeof usageInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReferenceValuesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<ReferenceValuesResponse> {
|
||||||
|
const rows = await this.prisma.referenceValue.findMany({
|
||||||
|
include: usageInclude,
|
||||||
|
orderBy: [{ field: 'asc' }, { label: 'asc' }],
|
||||||
|
});
|
||||||
|
return { referenceValues: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: ReferenceValueCreate): Promise<ReferenceValueDto> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.referenceValue.create({
|
||||||
|
data: dto,
|
||||||
|
include: usageInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce libellé existe déjà pour ce champ');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renommage / (dés)activation — jamais de suppression (même règle que Category). */
|
||||||
|
async update(id: string, dto: ReferenceValueUpdate): Promise<ReferenceValueDto> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.referenceValue.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: usageInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Valeur inconnue');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce libellé existe déjà pour ce champ');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: Row): ReferenceValueDto {
|
||||||
|
const c = row._count;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
field: row.field,
|
||||||
|
label: row.label,
|
||||||
|
isActive: row.isActive,
|
||||||
|
usageCount:
|
||||||
|
c.doorStates +
|
||||||
|
c.cabinPositions +
|
||||||
|
c.anomalies +
|
||||||
|
c.externalCauses +
|
||||||
|
c.actionsTaken +
|
||||||
|
c.componentsConcerned,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
65
apps/api/src/requests/requests.controller.ts
Normal file
65
apps/api/src/requests/requests.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
RequestApproveSchema,
|
||||||
|
RequestCreateSchema,
|
||||||
|
RequestRejectSchema,
|
||||||
|
type RequestApprove,
|
||||||
|
type RequestCreate,
|
||||||
|
type RequestReject,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { RequestsService } from './requests.service';
|
||||||
|
|
||||||
|
@Controller('requests')
|
||||||
|
export class RequestsController {
|
||||||
|
constructor(private readonly requests: RequestsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('REQUESTS', 'view')
|
||||||
|
list(@CurrentUser() user: AuthenticatedUser) {
|
||||||
|
return this.requests.list(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('REQUESTS', 'create')
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(RequestCreateSchema)) body: RequestCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.requests.create(body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/approve')
|
||||||
|
@RequirePermission('REQUESTS', 'edit')
|
||||||
|
approve(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(RequestApproveSchema)) body: RequestApprove,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.requests.approve(id, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/reject')
|
||||||
|
@HttpCode(200) // le contrat : 200, la demande est mise à jour
|
||||||
|
@RequirePermission('REQUESTS', 'edit')
|
||||||
|
reject(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(RequestRejectSchema)) body: RequestReject,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.requests.reject(id, body, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/requests/requests.module.ts
Normal file
11
apps/api/src/requests/requests.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WorkOrdersModule } from '../work-orders/work-orders.module';
|
||||||
|
import { RequestsController } from './requests.controller';
|
||||||
|
import { RequestsService } from './requests.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [WorkOrdersModule],
|
||||||
|
controllers: [RequestsController],
|
||||||
|
providers: [RequestsService],
|
||||||
|
})
|
||||||
|
export class RequestsModule {}
|
||||||
170
apps/api/src/requests/requests.service.ts
Normal file
170
apps/api/src/requests/requests.service.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
RequestApprove,
|
||||||
|
RequestCreate,
|
||||||
|
RequestReject,
|
||||||
|
RequestsResponse,
|
||||||
|
RequestSummary,
|
||||||
|
WorkOrderDetail,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { WorkOrdersService } from '../work-orders/work-orders.service';
|
||||||
|
|
||||||
|
const requestInclude = {
|
||||||
|
asset: { include: { location: { include: { parent: true } } } },
|
||||||
|
requestedBy: true,
|
||||||
|
workOrder: true,
|
||||||
|
} satisfies Prisma.RequestInclude;
|
||||||
|
|
||||||
|
type RequestRow = Prisma.RequestGetPayload<{ include: typeof requestInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RequestsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly workOrders: WorkOrdersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async scope(user: AuthenticatedUser): Promise<Prisma.RequestWhereInput> {
|
||||||
|
const viewOther = await this.permissions.can(user.roleId, 'REQUESTS', 'viewOther');
|
||||||
|
return viewOther ? {} : { requestedById: user.userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(user: AuthenticatedUser): Promise<RequestsResponse> {
|
||||||
|
const rows = await this.prisma.request.findMany({
|
||||||
|
where: await this.scope(user),
|
||||||
|
include: requestInclude,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
rows.sort((a, b) => Number(b.isPersonTrapped) - Number(a.isPersonTrapped));
|
||||||
|
return { requests: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
||||||
|
if (!asset) throw new BadRequestException('Équipement inconnu');
|
||||||
|
for (let essai = 0; ; essai++) {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.request.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
description: dto.description,
|
||||||
|
isPersonTrapped: dto.isPersonTrapped ?? false,
|
||||||
|
assetId: dto.assetId,
|
||||||
|
requestedById: user.userId,
|
||||||
|
},
|
||||||
|
include: requestInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002' &&
|
||||||
|
essai < 3
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approuver = créer l'OT lié (1-1). Une demande ne se traite qu'une fois. */
|
||||||
|
async approve(
|
||||||
|
id: string,
|
||||||
|
dto: RequestApprove,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
const request = await this.prisma.request.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: requestInclude,
|
||||||
|
});
|
||||||
|
if (!request) throw new NotFoundException('Demande inconnue');
|
||||||
|
if (request.status !== 'RECEIVED') {
|
||||||
|
throw new ConflictException('Cette demande a déjà été traitée');
|
||||||
|
}
|
||||||
|
const created = await this.workOrders.createRaw({
|
||||||
|
title: dto.title ?? request.description.slice(0, 120),
|
||||||
|
description: `${request.description}\n\n(Demande ${request.reference} — ${this.requesterLabel(request)})`,
|
||||||
|
type: 'CORRECTIVE',
|
||||||
|
priority: dto.priority ?? (request.isPersonTrapped ? 'PERSON_TRAPPED' : 'MEDIUM'),
|
||||||
|
assetId: request.assetId,
|
||||||
|
dueDate: dto.dueDate,
|
||||||
|
assigneeIds: dto.assigneeIds,
|
||||||
|
createdById: user.userId,
|
||||||
|
eventKind: 'FROM_REQUEST',
|
||||||
|
eventMessage: `OT créé depuis la demande ${request.reference}`,
|
||||||
|
});
|
||||||
|
await this.prisma.request.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'APPROVED', workOrderId: created.id },
|
||||||
|
});
|
||||||
|
return this.workOrders.get(created.id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(
|
||||||
|
id: string,
|
||||||
|
dto: RequestReject,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<RequestSummary> {
|
||||||
|
void user;
|
||||||
|
const request = await this.prisma.request.findUnique({ where: { id } });
|
||||||
|
if (!request) throw new NotFoundException('Demande inconnue');
|
||||||
|
if (request.status !== 'RECEIVED') {
|
||||||
|
throw new ConflictException('Cette demande a déjà été traitée');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.request.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'REJECTED', rejectionReason: dto.reason },
|
||||||
|
include: requestInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
const annee = new Date().getFullYear();
|
||||||
|
const dernier = await this.prisma.request.findFirst({
|
||||||
|
where: { reference: { startsWith: `DEM-${annee}-` } },
|
||||||
|
orderBy: { reference: 'desc' },
|
||||||
|
select: { reference: true },
|
||||||
|
});
|
||||||
|
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
||||||
|
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requesterLabel(row: RequestRow): string {
|
||||||
|
return row.requestedBy?.displayName ?? row.requesterName ?? 'Portail';
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: RequestRow): RequestSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
description: row.description,
|
||||||
|
isPersonTrapped: row.isPersonTrapped,
|
||||||
|
status: row.status,
|
||||||
|
rejectionReason: row.rejectionReason,
|
||||||
|
assetId: row.assetId,
|
||||||
|
assetReference: row.asset.reference,
|
||||||
|
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
|
||||||
|
requesterLabel: this.requesterLabel(row),
|
||||||
|
workOrder: row.workOrder
|
||||||
|
? {
|
||||||
|
id: row.workOrder.id,
|
||||||
|
reference: row.workOrder.reference,
|
||||||
|
status: row.workOrder.status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
110
apps/api/src/work-orders/work-orders.controller.ts
Normal file
110
apps/api/src/work-orders/work-orders.controller.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AssigneesUpdateSchema,
|
||||||
|
ChecklistPatchSchema,
|
||||||
|
CommentCreateSchema,
|
||||||
|
ReportUpsertSchema,
|
||||||
|
TransitionRequestSchema,
|
||||||
|
WorkOrderCreateSchema,
|
||||||
|
type AssigneesUpdate,
|
||||||
|
type ChecklistPatch,
|
||||||
|
type CommentCreate,
|
||||||
|
type ReportUpsert,
|
||||||
|
type TransitionRequest,
|
||||||
|
type WorkOrderCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { WorkOrdersService } from './work-orders.service';
|
||||||
|
|
||||||
|
@Controller('work-orders')
|
||||||
|
export class WorkOrdersController {
|
||||||
|
constructor(private readonly workOrders: WorkOrdersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('WORK_ORDERS', 'view')
|
||||||
|
list(@CurrentUser() user: AuthenticatedUser) {
|
||||||
|
return this.workOrders.list(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'view')
|
||||||
|
get(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
||||||
|
return this.workOrders.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('WORK_ORDERS', 'create')
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(WorkOrderCreateSchema)) body: WorkOrderCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.create(body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/transition')
|
||||||
|
@HttpCode(200) // le contrat : 200, l'état change — rien n'est « créé »
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
transition(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(TransitionRequestSchema)) body: TransitionRequest,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.transition(id, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/comments')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
comment(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(CommentCreateSchema)) body: CommentCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.comment(id, body.message, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/assignees')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
setAssignees(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(AssigneesUpdateSchema)) body: AssigneesUpdate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.setAssignees(id, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/report')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
upsertReport(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(ReportUpsertSchema)) body: ReportUpsert,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.upsertReport(id, body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/checklist/:itemId')
|
||||||
|
@RequirePermission('WORK_ORDERS', 'edit')
|
||||||
|
patchChecklist(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('itemId', ParseUUIDPipe) itemId: string,
|
||||||
|
@Body(new ZodValidationPipe(ChecklistPatchSchema)) body: ChecklistPatch,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.workOrders.patchChecklist(id, itemId, body, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/work-orders/work-orders.module.ts
Normal file
10
apps/api/src/work-orders/work-orders.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WorkOrdersController } from './work-orders.controller';
|
||||||
|
import { WorkOrdersService } from './work-orders.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [WorkOrdersController],
|
||||||
|
providers: [WorkOrdersService],
|
||||||
|
exports: [WorkOrdersService],
|
||||||
|
})
|
||||||
|
export class WorkOrdersModule {}
|
||||||
437
apps/api/src/work-orders/work-orders.service.ts
Normal file
437
apps/api/src/work-orders/work-orders.service.ts
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
BILAN_FIELD_LABELS,
|
||||||
|
REQUIRED_BILAN_FIELDS,
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
WORK_ORDER_TRANSITIONS,
|
||||||
|
type AssigneesUpdate,
|
||||||
|
type BilanField,
|
||||||
|
type ChecklistItemDto,
|
||||||
|
type ChecklistPatch,
|
||||||
|
type ReportUpsert,
|
||||||
|
type TransitionRequest,
|
||||||
|
type WorkOrderCreate,
|
||||||
|
type WorkOrderDetail,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
type WorkOrdersResponse,
|
||||||
|
type WorkOrderSummary,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const detailInclude = {
|
||||||
|
asset: { include: { location: { include: { parent: true } } } },
|
||||||
|
assignees: true,
|
||||||
|
createdBy: true,
|
||||||
|
request: { include: { requestedBy: true } },
|
||||||
|
events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } },
|
||||||
|
checklist: { include: { doneBy: true }, orderBy: { label: 'asc' as const } },
|
||||||
|
report: {
|
||||||
|
include: {
|
||||||
|
doorState: true,
|
||||||
|
cabinPosition: true,
|
||||||
|
anomaly: true,
|
||||||
|
externalCause: true,
|
||||||
|
actionTaken: true,
|
||||||
|
componentConcerned: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Prisma.WorkOrderInclude;
|
||||||
|
|
||||||
|
type DetailRow = Prisma.WorkOrderGetPayload<{ include: typeof detailInclude }>;
|
||||||
|
|
||||||
|
const initialsOf = (name: string) =>
|
||||||
|
name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]!.toUpperCase()).join('');
|
||||||
|
|
||||||
|
const toPersonne = (u: { id: string; displayName: string }) => ({
|
||||||
|
id: u.id,
|
||||||
|
displayName: u.displayName,
|
||||||
|
initials: initialsOf(u.displayName),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Champ du bilan → colonne du rapport (validation des référentiels). */
|
||||||
|
const REPORT_FIELDS: Record<string, BilanField> = {
|
||||||
|
doorStateId: 'DOOR_STATE',
|
||||||
|
cabinPositionId: 'CABIN_POSITION',
|
||||||
|
anomalyId: 'ANOMALY',
|
||||||
|
externalCauseId: 'EXTERNAL_CAUSE',
|
||||||
|
actionTakenId: 'ACTION_TAKEN',
|
||||||
|
componentConcernedId: 'COMPONENT_CONCERNED',
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkOrdersService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */
|
||||||
|
private async scope(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
|
||||||
|
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
|
||||||
|
if (viewOther) return {};
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ assignees: { some: { id: user.userId } } },
|
||||||
|
{ createdById: user.userId },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(user: AuthenticatedUser): Promise<WorkOrdersResponse> {
|
||||||
|
const rows = await this.prisma.workOrder.findMany({
|
||||||
|
where: await this.scope(user),
|
||||||
|
include: {
|
||||||
|
asset: { include: { location: { include: { parent: true } } } },
|
||||||
|
assignees: true,
|
||||||
|
},
|
||||||
|
// Dernière activité d'abord : un OT qui vient d'être clôturé remonte
|
||||||
|
// (« Interventions récentes » du tableau de bord = tête de liste).
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
});
|
||||||
|
// « personne bloquée » ACTIVE saute en tête, toujours (tri stable)
|
||||||
|
const urgenceActive = (r: (typeof rows)[number]) =>
|
||||||
|
r.priority === 'PERSON_TRAPPED' && r.status !== 'DONE' && r.status !== 'CANCELLED';
|
||||||
|
rows.sort((a, b) => Number(urgenceActive(b)) - Number(urgenceActive(a)));
|
||||||
|
return { workOrders: rows.map((r) => this.toSummary(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string, user: AuthenticatedUser): Promise<WorkOrderDetail> {
|
||||||
|
const row = await this.prisma.workOrder.findFirst({
|
||||||
|
where: { AND: [{ id }, await this.scope(user)] },
|
||||||
|
include: detailInclude,
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('OT inconnu');
|
||||||
|
return this.toDetail(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: WorkOrderCreate, user: AuthenticatedUser): Promise<WorkOrderDetail> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } });
|
||||||
|
if (!asset) throw new BadRequestException('Équipement inconnu');
|
||||||
|
const created = await this.createRaw({
|
||||||
|
...dto,
|
||||||
|
createdById: user.userId,
|
||||||
|
eventKind: 'CREATED',
|
||||||
|
eventMessage: 'OT créé',
|
||||||
|
});
|
||||||
|
return this.get(created.id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Création partagée (OT manuel, approbation de demande, préventif R2.2). */
|
||||||
|
async createRaw(input: {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
type: WorkOrderCreate['type'];
|
||||||
|
priority?: WorkOrderCreate['priority'];
|
||||||
|
assetId: string;
|
||||||
|
dueDate?: string;
|
||||||
|
assigneeIds?: string[];
|
||||||
|
createdById?: string;
|
||||||
|
eventKind: string;
|
||||||
|
eventMessage: string;
|
||||||
|
}): Promise<{ id: string }> {
|
||||||
|
for (let essai = 0; ; essai++) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.workOrder.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
type: input.type,
|
||||||
|
priority: input.priority ?? 'NONE',
|
||||||
|
assetId: input.assetId,
|
||||||
|
dueDate: input.dueDate ? new Date(input.dueDate) : undefined,
|
||||||
|
createdById: input.createdById,
|
||||||
|
assignees: input.assigneeIds?.length
|
||||||
|
? { connect: input.assigneeIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
events: {
|
||||||
|
create: {
|
||||||
|
kind: input.eventKind,
|
||||||
|
message: input.eventMessage,
|
||||||
|
byId: input.createdById,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// collision de référence (concurrence) : on retente
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002' &&
|
||||||
|
essai < 3
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
const annee = new Date().getFullYear();
|
||||||
|
const dernier = await this.prisma.workOrder.findFirst({
|
||||||
|
where: { reference: { startsWith: `OT-${annee}-` } },
|
||||||
|
orderBy: { reference: 'desc' },
|
||||||
|
select: { reference: true },
|
||||||
|
});
|
||||||
|
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
|
||||||
|
return `OT-${annee}-${String(n).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Machine à états stricte + garde de clôture. */
|
||||||
|
async transition(
|
||||||
|
id: string,
|
||||||
|
dto: TransitionRequest,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
const detail = await this.get(id, user);
|
||||||
|
if (!WORK_ORDER_TRANSITIONS[detail.status].includes(dto.to)) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Transition interdite : ${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dto.to === 'DONE' && detail.closureBlockers.length > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Clôture bloquée : ${detail.closureBlockers.join(' ; ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const horodatage: Prisma.WorkOrderUpdateInput =
|
||||||
|
dto.to === 'IN_PROGRESS' && !detail.startedAt
|
||||||
|
? { startedAt: new Date() }
|
||||||
|
: dto.to === 'DONE'
|
||||||
|
? { completedAt: new Date() }
|
||||||
|
: dto.to === 'CANCELLED'
|
||||||
|
? { cancelledAt: new Date() }
|
||||||
|
: {};
|
||||||
|
await this.prisma.workOrder.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: dto.to,
|
||||||
|
...horodatage,
|
||||||
|
events: {
|
||||||
|
create: {
|
||||||
|
kind: 'STATUS_CHANGED',
|
||||||
|
message:
|
||||||
|
`${WORK_ORDER_STATUS_LABELS[detail.status]} → ${WORK_ORDER_STATUS_LABELS[dto.to]}` +
|
||||||
|
(dto.comment ? ` — ${dto.comment}` : ''),
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async comment(
|
||||||
|
id: string,
|
||||||
|
message: string,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
await this.get(id, user); // périmètre
|
||||||
|
await this.prisma.workOrderEvent.create({
|
||||||
|
data: { workOrderId: id, kind: 'COMMENT', message, byId: user.userId },
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setAssignees(
|
||||||
|
id: string,
|
||||||
|
dto: AssigneesUpdate,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
await this.get(id, user);
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: dto.assigneeIds } },
|
||||||
|
});
|
||||||
|
if (users.length !== new Set(dto.assigneeIds).size) {
|
||||||
|
throw new BadRequestException('Personne inconnue dans la liste');
|
||||||
|
}
|
||||||
|
await this.prisma.workOrder.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
assignees: { set: dto.assigneeIds.map((assigneeId) => ({ id: assigneeId })) },
|
||||||
|
events: {
|
||||||
|
create: {
|
||||||
|
kind: 'ASSIGNED',
|
||||||
|
message: users.length
|
||||||
|
? `Assigné(s) : ${users.map((u) => u.displayName).join(', ')}`
|
||||||
|
: 'Assignation retirée',
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertReport(
|
||||||
|
id: string,
|
||||||
|
dto: ReportUpsert,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<WorkOrderDetail> {
|
||||||
|
await this.get(id, user);
|
||||||
|
// Chaque valeur fournie doit appartenir au référentiel de SON champ (et être active)
|
||||||
|
for (const [colonne, field] of Object.entries(REPORT_FIELDS)) {
|
||||||
|
const valeur = dto[colonne as keyof ReportUpsert];
|
||||||
|
if (typeof valeur === 'string') {
|
||||||
|
const ref = await this.prisma.referenceValue.findUnique({ where: { id: valeur } });
|
||||||
|
if (!ref || ref.field !== field || !ref.isActive) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Valeur hors référentiel pour « ${BILAN_FIELD_LABELS[field]} »`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const donnees = {
|
||||||
|
note: dto.note,
|
||||||
|
doorStateId: dto.doorStateId,
|
||||||
|
cabinPositionId: dto.cabinPositionId,
|
||||||
|
anomalyId: dto.anomalyId,
|
||||||
|
externalCauseId: dto.externalCauseId,
|
||||||
|
actionTakenId: dto.actionTakenId,
|
||||||
|
componentConcernedId: dto.componentConcernedId,
|
||||||
|
};
|
||||||
|
await this.prisma.interventionReport.upsert({
|
||||||
|
where: { workOrderId: id },
|
||||||
|
update: donnees,
|
||||||
|
create: { workOrderId: id, ...donnees },
|
||||||
|
});
|
||||||
|
return this.get(id, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async patchChecklist(
|
||||||
|
id: string,
|
||||||
|
itemId: string,
|
||||||
|
dto: ChecklistPatch,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<ChecklistItemDto> {
|
||||||
|
await this.get(id, user);
|
||||||
|
const { count } = await this.prisma.checklistItem.updateMany({
|
||||||
|
where: { id: itemId, workOrderId: id },
|
||||||
|
data: {
|
||||||
|
state: dto.state,
|
||||||
|
doneById: dto.state === 'PENDING' ? null : user.userId,
|
||||||
|
doneAt: dto.state === 'PENDING' ? null : new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (count === 0) throw new NotFoundException('Tâche inconnue');
|
||||||
|
const item = await this.prisma.checklistItem.findUniqueOrThrow({
|
||||||
|
where: { id: itemId },
|
||||||
|
include: { doneBy: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
label: item.label,
|
||||||
|
state: item.state,
|
||||||
|
doneBy: item.doneBy ? toPersonne(item.doneBy) : null,
|
||||||
|
doneAt: item.doneAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— mapping —————
|
||||||
|
|
||||||
|
private toSummary(
|
||||||
|
row: Prisma.WorkOrderGetPayload<{
|
||||||
|
include: {
|
||||||
|
asset: { include: { location: { include: { parent: true } } } };
|
||||||
|
assignees: true;
|
||||||
|
};
|
||||||
|
}>,
|
||||||
|
): WorkOrderSummary {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
title: row.title,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status,
|
||||||
|
priority: row.priority,
|
||||||
|
assetId: row.assetId,
|
||||||
|
assetReference: row.asset.reference,
|
||||||
|
siteName: row.asset.location.parent?.name ?? row.asset.location.name,
|
||||||
|
dueDate: row.dueDate?.toISOString() ?? null,
|
||||||
|
assignees: row.assignees.map(toPersonne),
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDetail(row: DetailRow): WorkOrderDetail {
|
||||||
|
const report = row.report;
|
||||||
|
const blockers: string[] = [];
|
||||||
|
if (row.checklist.some((c) => c.state === 'PENDING')) {
|
||||||
|
blockers.push('la checklist est incomplète : traitez chaque tâche (Fait ou N-A)');
|
||||||
|
}
|
||||||
|
const manquants = REQUIRED_BILAN_FIELDS.filter((field) => {
|
||||||
|
const parChamp: Record<BilanField, string | null | undefined> = {
|
||||||
|
DOOR_STATE: report?.doorStateId,
|
||||||
|
CABIN_POSITION: report?.cabinPositionId,
|
||||||
|
ANOMALY: report?.anomalyId,
|
||||||
|
EXTERNAL_CAUSE: report?.externalCauseId,
|
||||||
|
ACTION_TAKEN: report?.actionTakenId,
|
||||||
|
COMPONENT_CONCERNED: report?.componentConcernedId,
|
||||||
|
};
|
||||||
|
return !parChamp[field];
|
||||||
|
});
|
||||||
|
if (manquants.length) {
|
||||||
|
blockers.push(
|
||||||
|
`bilan incomplet : ${manquants.map((f) => `« ${BILAN_FIELD_LABELS[f]} »`).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const versValeur = (v: { id: string; label: string } | null) =>
|
||||||
|
v ? { id: v.id, label: v.label } : null;
|
||||||
|
return {
|
||||||
|
...this.toSummary(row),
|
||||||
|
description: row.description,
|
||||||
|
locationName: row.asset.location.name,
|
||||||
|
startedAt: row.startedAt?.toISOString() ?? null,
|
||||||
|
completedAt: row.completedAt?.toISOString() ?? null,
|
||||||
|
cancelledAt: row.cancelledAt?.toISOString() ?? null,
|
||||||
|
createdBy: row.createdBy ? toPersonne(row.createdBy) : null,
|
||||||
|
request: row.request
|
||||||
|
? {
|
||||||
|
id: row.request.id,
|
||||||
|
reference: row.request.reference,
|
||||||
|
requesterLabel:
|
||||||
|
row.request.requestedBy?.displayName ??
|
||||||
|
row.request.requesterName ??
|
||||||
|
'Portail',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
events: row.events.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
kind: e.kind,
|
||||||
|
message: e.message,
|
||||||
|
by: e.by ? toPersonne(e.by) : null,
|
||||||
|
createdAt: e.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
checklist: row.checklist.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
label: c.label,
|
||||||
|
state: c.state,
|
||||||
|
doneBy: c.doneBy ? toPersonne(c.doneBy) : null,
|
||||||
|
doneAt: c.doneAt?.toISOString() ?? null,
|
||||||
|
})),
|
||||||
|
report: report
|
||||||
|
? {
|
||||||
|
note: report.note,
|
||||||
|
doorState: versValeur(report.doorState),
|
||||||
|
cabinPosition: versValeur(report.cabinPosition),
|
||||||
|
anomaly: versValeur(report.anomaly),
|
||||||
|
externalCause: versValeur(report.externalCause),
|
||||||
|
actionTaken: versValeur(report.actionTaken),
|
||||||
|
componentConcerned: versValeur(report.componentConcerned),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
||||||
|
closureBlockers: blockers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
366
apps/api/test/exploitation.e2e-spec.ts
Normal file
366
apps/api/test/exploitation.e2e-spec.ts
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* E2E R2 — exploitation : machine à états stricte, garde de clôture
|
||||||
|
* (bilan + checklist), demande → OT (1-1), scoping « voir autre ».
|
||||||
|
* Rejoue la recette officielle : demande gardien → approbation → OT →
|
||||||
|
* intervention → bilan codé → clôture → suivi demandeur.
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Exploitation (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let salma: string; // Dispatcher — voir autre, approuve
|
||||||
|
let ahmed: string; // Technicien — SES OT seulement
|
||||||
|
let karim: string; // Demandeur — SES demandes seulement
|
||||||
|
let ahmedId: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
const res = await http().post('/auth/demo-login').send({ userId: compte.id });
|
||||||
|
return res.body.accessToken as string;
|
||||||
|
};
|
||||||
|
salma = await login('Dispatcher');
|
||||||
|
ahmed = await login('Technicien');
|
||||||
|
karim = await login('Demandeur');
|
||||||
|
ahmedId = (
|
||||||
|
await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } })
|
||||||
|
).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.request.deleteMany({ where: { description: { contains: suffix } } });
|
||||||
|
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
|
||||||
|
it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => {
|
||||||
|
// 1. Karim (gardien) signale
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
const demande = await http()
|
||||||
|
.post('/requests')
|
||||||
|
.set(auth(karim))
|
||||||
|
.send({ assetId: a1.id, description: `Porte qui grince (${suffix})` })
|
||||||
|
.expect(201);
|
||||||
|
expect(demande.body.status).toBe('RECEIVED');
|
||||||
|
|
||||||
|
// 2. Salma approuve → OT créé et lié (1-1)
|
||||||
|
const ot = await http()
|
||||||
|
.post(`/requests/${demande.body.id}/approve`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ title: `Réparer porte qui grince (${suffix})`, priority: 'MEDIUM', assigneeIds: [ahmedId] })
|
||||||
|
.expect(201);
|
||||||
|
expect(ot.body.status).toBe('OPEN');
|
||||||
|
expect(ot.body.request.reference).toBe(demande.body.reference);
|
||||||
|
// double approbation → refus
|
||||||
|
await http()
|
||||||
|
.post(`/requests/${demande.body.id}/approve`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({})
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
// 3. Ahmed démarre, commente
|
||||||
|
const id = ot.body.id;
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'IN_PROGRESS' })
|
||||||
|
.expect(200);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${id}/comments`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ message: 'Charnière usée, graissage effectué.' })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// 4. Clôture SANS bilan → bloquée (garde)
|
||||||
|
const refus = await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'DONE' })
|
||||||
|
.expect(409);
|
||||||
|
expect(refus.body.message).toContain('bilan incomplet');
|
||||||
|
|
||||||
|
// 5. Bilan codé (3 champs requis) puis clôture
|
||||||
|
const { body: refs } = await http().get('/reference-values').set(auth(ahmed));
|
||||||
|
const valeur = (field: string, label: string) =>
|
||||||
|
refs.referenceValues.find(
|
||||||
|
(v: { field: string; label: string }) => v.field === field && v.label === label,
|
||||||
|
).id;
|
||||||
|
await http()
|
||||||
|
.put(`/work-orders/${id}/report`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({
|
||||||
|
doorStateId: valeur('DOOR_STATE', 'Fonctionnement normal'),
|
||||||
|
actionTakenId: valeur('ACTION_TAKEN', 'Nettoyage / graissage'),
|
||||||
|
componentConcernedId: valeur('COMPONENT_CONCERNED', 'Portes'),
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
const clos = await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'DONE', comment: 'RAS après graissage' })
|
||||||
|
.expect(200);
|
||||||
|
expect(clos.body.status).toBe('DONE');
|
||||||
|
expect(clos.body.completedAt).toBeTruthy();
|
||||||
|
|
||||||
|
// 6. Karim suit SA demande : l'OT lié est terminé
|
||||||
|
const { body: mesDemandes } = await http().get('/requests').set(auth(karim)).expect(200);
|
||||||
|
const laMienne = mesDemandes.requests.find(
|
||||||
|
(r: { id: string }) => r.id === demande.body.id,
|
||||||
|
);
|
||||||
|
expect(laMienne.workOrder.status).toBe('DONE');
|
||||||
|
|
||||||
|
// 6 bis. Régression recette R2 : l'OT fraîchement clôturé remonte EN TÊTE
|
||||||
|
// de la liste de Salma (après les urgences actives) — il doit apparaître
|
||||||
|
// dans les « Interventions récentes » de tout le monde, pas seulement
|
||||||
|
// chez l'assigné.
|
||||||
|
const { body: listeSalma } = await http()
|
||||||
|
.get('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.expect(200);
|
||||||
|
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
|
||||||
|
const urgencesActives = listeSalma.workOrders.filter(
|
||||||
|
(w: { priority: string; status: string }) =>
|
||||||
|
w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
|
||||||
|
).length;
|
||||||
|
expect(position).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(position).toBeLessThanOrEqual(urgencesActives);
|
||||||
|
|
||||||
|
// 7. Terminal : plus aucune transition
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ to: 'IN_PROGRESS' })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('machine à états : OPEN → DONE direct interdit ; OPEN → ON_HOLD interdit', async () => {
|
||||||
|
const { body } = await http().get('/work-orders').set(auth(salma));
|
||||||
|
const ouvert = body.workOrders.find(
|
||||||
|
(w: { status: string; priority: string }) =>
|
||||||
|
w.status === 'OPEN' && w.priority === 'PERSON_TRAPPED',
|
||||||
|
);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${ouvert.id}/transition`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ to: 'DONE' })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${ouvert.id}/transition`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ to: 'ON_HOLD' })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('garde de clôture : la checklist incomplète bloque la grille du mois', async () => {
|
||||||
|
const { body } = await http().get('/work-orders').set(auth(ahmed));
|
||||||
|
const grille = body.workOrders.find((w: { type: string }) => w.type === 'PREVENTIVE');
|
||||||
|
const detail = await http().get(`/work-orders/${grille.id}`).set(auth(ahmed)).expect(200);
|
||||||
|
expect(detail.body.closureBlockers.join(' ')).toContain('checklist');
|
||||||
|
|
||||||
|
// Régler toutes les tâches (Fait/N-A) → le blocage checklist disparaît
|
||||||
|
for (const [i, item] of detail.body.checklist.entries()) {
|
||||||
|
await http()
|
||||||
|
.patch(`/work-orders/${grille.id}/checklist/${item.id}`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ state: i % 2 ? 'NA' : 'DONE' })
|
||||||
|
.expect(200);
|
||||||
|
}
|
||||||
|
const apres = await http().get(`/work-orders/${grille.id}`).set(auth(ahmed)).expect(200);
|
||||||
|
expect(apres.body.closureBlockers.join(' ')).not.toContain('checklist');
|
||||||
|
expect(apres.body.checklist.every((c: { doneBy: unknown }) => c.doneBy)).toBe(true);
|
||||||
|
// remise en état (seed idempotent)
|
||||||
|
for (const item of apres.body.checklist) {
|
||||||
|
await http()
|
||||||
|
.patch(`/work-orders/${grille.id}/checklist/${item.id}`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ state: 'PENDING' })
|
||||||
|
.expect(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('« voir autre » : Ahmed ne voit que SES OT ; Karim que SES demandes', async () => {
|
||||||
|
const { body: tous } = await http().get('/work-orders').set(auth(salma));
|
||||||
|
const { body: siens } = await http().get('/work-orders').set(auth(ahmed));
|
||||||
|
expect(tous.workOrders.length).toBeGreaterThan(siens.workOrders.length);
|
||||||
|
expect(
|
||||||
|
siens.workOrders.every((w: { assignees: { id: string }[] }) =>
|
||||||
|
w.assignees.some((a) => a.id === ahmedId),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
// un OT non assigné à Ahmed lui est invisible (404, pas 403 — pas de fuite)
|
||||||
|
const autre = tous.workOrders.find(
|
||||||
|
(w: { assignees: { id: string }[] }) => !w.assignees.some((a) => a.id === ahmedId),
|
||||||
|
);
|
||||||
|
await http().get(`/work-orders/${autre.id}`).set(auth(ahmed)).expect(404);
|
||||||
|
|
||||||
|
const { body: demandes } = await http().get('/requests').set(auth(karim));
|
||||||
|
expect(demandes.requests.length).toBeGreaterThan(0);
|
||||||
|
// Karim (create seulement) ne peut pas approuver
|
||||||
|
await http()
|
||||||
|
.post(`/requests/${demandes.requests[0].id}/approve`)
|
||||||
|
.set(auth(karim))
|
||||||
|
.send({})
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1');
|
||||||
|
const demande = await http()
|
||||||
|
.post('/requests')
|
||||||
|
.set(auth(karim))
|
||||||
|
.send({ assetId: c1.id, description: `À rejeter (${suffix})` })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post(`/requests/${demande.body.id}/reject`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ reason: '' })
|
||||||
|
.expect(400);
|
||||||
|
const rejetee = await http()
|
||||||
|
.post(`/requests/${demande.body.id}/reject`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ reason: 'Doublon de la demande précédente.' })
|
||||||
|
.expect(200);
|
||||||
|
expect(rejetee.body.rejectionReason).toContain('Doublon');
|
||||||
|
|
||||||
|
// bilan : une valeur ACTION_TAKEN dans le champ DOOR_STATE → 400
|
||||||
|
const { body: refs } = await http().get('/reference-values').set(auth(salma));
|
||||||
|
const action = refs.referenceValues.find((v: { field: string }) => v.field === 'ACTION_TAKEN');
|
||||||
|
const { body: wos } = await http().get('/work-orders').set(auth(salma));
|
||||||
|
const enCours = wos.workOrders.find((w: { status: string }) => w.status === 'IN_PROGRESS');
|
||||||
|
await http()
|
||||||
|
.put(`/work-orders/${enCours.id}/report`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ doorStateId: action.id })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('« personne bloquée » saute en tête de liste', async () => {
|
||||||
|
const { body } = await http().get('/work-orders').set(auth(salma));
|
||||||
|
expect(body.workOrders[0].priority).toBe('PERSON_TRAPPED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OT manuel : cycle complet En attente ↔ En cours, puis annulation motivée', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const e2 = assets.assets.find((a: { reference: string }) => a.reference === 'E2');
|
||||||
|
const ot = await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({
|
||||||
|
title: `Travaux cabine (${suffix})`,
|
||||||
|
type: 'WORKS',
|
||||||
|
priority: 'LOW',
|
||||||
|
assetId: e2.id,
|
||||||
|
dueDate: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
|
||||||
|
assigneeIds: [ahmedId],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const id = ot.body.id;
|
||||||
|
await http().post(`/work-orders/${id}/transition`).set(auth(salma)).send({ to: 'IN_PROGRESS' }).expect(200);
|
||||||
|
const attente = await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ to: 'ON_HOLD', comment: 'Attente de pièce' })
|
||||||
|
.expect(200);
|
||||||
|
expect(attente.body.allowedTransitions).toEqual(['IN_PROGRESS', 'CANCELLED']);
|
||||||
|
await http().post(`/work-orders/${id}/transition`).set(auth(salma)).send({ to: 'IN_PROGRESS' }).expect(200);
|
||||||
|
const annule = await http()
|
||||||
|
.post(`/work-orders/${id}/transition`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ to: 'CANCELLED', comment: 'Travaux reportés au trimestre prochain' })
|
||||||
|
.expect(200);
|
||||||
|
expect(annule.body.cancelledAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refus propres : équipement inconnu, personne inconnue, tâche inconnue, OT invisible', async () => {
|
||||||
|
const GHOST = '00000000-0000-4000-8000-000000000000';
|
||||||
|
await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ title: 'X', type: 'CORRECTIVE', assetId: GHOST })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.post('/requests')
|
||||||
|
.set(auth(karim))
|
||||||
|
.send({ assetId: GHOST, description: 'X' })
|
||||||
|
.expect(400);
|
||||||
|
const { body: wos } = await http().get('/work-orders').set(auth(salma));
|
||||||
|
const un = wos.workOrders[0];
|
||||||
|
await http()
|
||||||
|
.put(`/work-orders/${un.id}/assignees`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ assigneeIds: [GHOST] })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.patch(`/work-orders/${un.id}/checklist/${GHOST}`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ state: 'DONE' })
|
||||||
|
.expect(404);
|
||||||
|
await http().get(`/work-orders/${GHOST}`).set(auth(salma)).expect(404);
|
||||||
|
await http()
|
||||||
|
.post(`/requests/${GHOST}/reject`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ reason: 'motif' })
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('référentiels du bilan : ajout (admin), doublon 409, renommage, 404', async () => {
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const adminCompte = body.accounts.find(
|
||||||
|
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||||
|
);
|
||||||
|
const admin = (
|
||||||
|
await http().post('/auth/demo-login').send({ userId: adminCompte.id })
|
||||||
|
).body.accessToken as string;
|
||||||
|
|
||||||
|
const creee = await http()
|
||||||
|
.post('/reference-values')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ field: 'ANOMALY', label: `Anomalie test ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/reference-values')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ field: 'ANOMALY', label: `Anomalie test ${suffix}` })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.patch(`/reference-values/${creee.body.id}`)
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ isActive: false })
|
||||||
|
.expect(200);
|
||||||
|
await http()
|
||||||
|
.patch(`/reference-values/00000000-0000-4000-8000-000000000000`)
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ label: 'X' })
|
||||||
|
.expect(404);
|
||||||
|
// Salma (Dispatcher, sans SETTINGS.create) → 403
|
||||||
|
await http()
|
||||||
|
.post('/reference-values')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ field: 'ANOMALY', label: 'Interdit' })
|
||||||
|
.expect(403);
|
||||||
|
await prisma.referenceValue.delete({ where: { id: creee.body.id } });
|
||||||
|
});
|
||||||
|
});
|
||||||
208
apps/api/test/preventif.e2e-spec.ts
Normal file
208
apps/api/test/preventif.e2e-spec.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* E2E R2.2 — préventif : génération IDEMPOTENTE (l'unicité est en base),
|
||||||
|
* premier contrôle (tout), périodicités ancrées sur la mise en service ;
|
||||||
|
* compteurs strictement croissants.
|
||||||
|
* Les générations de test utilisent des mois lointains (2031) puis sont purgées.
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Préventif & compteurs (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let admin: string;
|
||||||
|
let ahmed: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } });
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken as string;
|
||||||
|
};
|
||||||
|
admin = await login('Administrateur');
|
||||||
|
ahmed = await login('Technicien');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.workOrder.deleteMany({ where: { periodKey: { startsWith: '2031-' } } });
|
||||||
|
await prisma.taskTemplate.deleteMany({ where: { label: { contains: 'TEST-' } } });
|
||||||
|
await prisma.meterReading.deleteMany({ where: { value: { gte: 90000000 } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Les assertions portent sur les 8 appareils du SEED : d'autres specs Jest
|
||||||
|
// créent/détruisent des appareils en parallèle sur la même base.
|
||||||
|
const SEED_REFS = ['A1', 'A2', 'B1', 'B2', 'C1', 'D1', 'E2', 'M1'];
|
||||||
|
const grillesSeed = (period: string) =>
|
||||||
|
prisma.workOrder.count({
|
||||||
|
where: { periodKey: period, asset: { reference: { in: SEED_REFS } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('génère une grille par appareil sous contrat — et regénérer ne double RIEN', async () => {
|
||||||
|
const premiere = await http()
|
||||||
|
.post('/preventive/generate')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ month: '2031-01' })
|
||||||
|
.expect(200);
|
||||||
|
expect(premiere.body.created).toBeGreaterThanOrEqual(SEED_REFS.length);
|
||||||
|
expect(premiere.body.references.length).toBe(premiere.body.created);
|
||||||
|
expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length);
|
||||||
|
|
||||||
|
const seconde = await http()
|
||||||
|
.post('/preventive/generate')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ month: '2031-01' })
|
||||||
|
.expect(200);
|
||||||
|
expect(seconde.body.skipped).toBeGreaterThanOrEqual(SEED_REFS.length);
|
||||||
|
expect(await grillesSeed('2031-01')).toBe(SEED_REFS.length); // toujours 8, pas 16
|
||||||
|
});
|
||||||
|
|
||||||
|
it('premier contrôle (toutes les tâches) pour un appareil sans historique ; grille normale sinon', async () => {
|
||||||
|
const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } });
|
||||||
|
const mensuelles = await prisma.taskTemplate.count({
|
||||||
|
where: { isActive: true, periodMonths: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un appareil VIERGE créé par ce test (aucune génération n'a pu le toucher)
|
||||||
|
const categorie = await prisma.category.findFirstOrThrow({ where: { kind: 'EQUIPMENT' } });
|
||||||
|
const site = await prisma.location.findFirstOrThrow({ where: { parentId: null } });
|
||||||
|
const vierge = await prisma.asset.create({
|
||||||
|
data: {
|
||||||
|
reference: `PVT-${Date.now().toString(36)}`,
|
||||||
|
brand: 'Test',
|
||||||
|
categoryId: categorie.id,
|
||||||
|
locationId: site.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await http()
|
||||||
|
.post('/preventive/generate')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ month: '2031-09' })
|
||||||
|
.expect(200);
|
||||||
|
const grille = await prisma.workOrder.findUniqueOrThrow({
|
||||||
|
where: { assetId_periodKey: { assetId: vierge.id, periodKey: '2031-09' } },
|
||||||
|
include: { _count: { select: { checklist: true } } },
|
||||||
|
});
|
||||||
|
expect(grille.title).toContain('Premier contrôle');
|
||||||
|
expect(grille._count.checklist).toBe(templatesActifs);
|
||||||
|
} finally {
|
||||||
|
await prisma.workOrder.deleteMany({ where: { assetId: vierge.id } });
|
||||||
|
await prisma.asset.delete({ where: { id: vierge.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// A1 a toujours un historique préventif (seed) → grille normale.
|
||||||
|
// Mise en service mars 2020 : janvier 2031 (écart 130 mois) n'est le mois
|
||||||
|
// ni du 3, ni du 6, ni du 12 → seules les tâches MENSUELLES sont dues.
|
||||||
|
const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
|
||||||
|
const grilleA1 = await prisma.workOrder.findUniqueOrThrow({
|
||||||
|
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-01' } },
|
||||||
|
include: { _count: { select: { checklist: true } } },
|
||||||
|
});
|
||||||
|
expect(grilleA1.title).toContain('Grille du mois');
|
||||||
|
expect(grilleA1._count.checklist).toBe(mensuelles);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mois anniversaire de mise en service : les périodicités longues tombent (mars 2031 pour A1)', async () => {
|
||||||
|
await http()
|
||||||
|
.post('/preventive/generate')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ month: '2031-03' })
|
||||||
|
.expect(200);
|
||||||
|
// écart mars 2020 → mars 2031 = 132 mois : divisible par 3, 6 et 12
|
||||||
|
const templatesActifs = await prisma.taskTemplate.count({ where: { isActive: true } });
|
||||||
|
const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
|
||||||
|
const grille = await prisma.workOrder.findUniqueOrThrow({
|
||||||
|
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-03' } },
|
||||||
|
include: { checklist: true },
|
||||||
|
});
|
||||||
|
expect(grille.checklist.length).toBe(templatesActifs);
|
||||||
|
expect(grille.checklist.some((c) => c.label === 'Essai du parachute')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gabarits : ajout, doublon 409, désactivation (exclu des générations suivantes), 404', async () => {
|
||||||
|
const cree = await http()
|
||||||
|
.post('/preventive/templates')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ label: 'TEST-Contrôle éclairage gaine', periodMonths: 1 })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/preventive/templates')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ label: 'TEST-Contrôle éclairage gaine', periodMonths: 1 })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.patch(`/preventive/templates/${cree.body.id}`)
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ isActive: false })
|
||||||
|
.expect(200);
|
||||||
|
await http()
|
||||||
|
.patch('/preventive/templates/00000000-0000-4000-8000-000000000000')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ label: 'X' })
|
||||||
|
.expect(404);
|
||||||
|
// désactivé → absent de la génération d'un nouveau mois
|
||||||
|
await http()
|
||||||
|
.post('/preventive/generate')
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ month: '2031-05' })
|
||||||
|
.expect(200);
|
||||||
|
const labels = await prisma.checklistItem.findMany({
|
||||||
|
where: { workOrder: { periodKey: '2031-05' } },
|
||||||
|
select: { label: true },
|
||||||
|
});
|
||||||
|
expect(labels.some((l) => l.label.startsWith('TEST-'))).toBe(false);
|
||||||
|
// le statut du mois courant répond (structure)
|
||||||
|
const status = await http().get('/preventive/status').set(auth(ahmed)).expect(200);
|
||||||
|
expect(status.body).toHaveProperty('generated');
|
||||||
|
expect(status.body).toHaveProperty('late');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compteurs : relevé croissant accepté, régression refusée, appareil inconnu 404', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(ahmed));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
|
||||||
|
const liste = await http().get(`/assets/${a1.id}/meters`).set(auth(ahmed)).expect(200);
|
||||||
|
expect(liste.body.meters).toHaveLength(2); // les 2 compteurs, même vides
|
||||||
|
const heures = liste.body.meters.find((m: { kind: string }) => m.kind === 'RUNNING_HOURS');
|
||||||
|
const dernier = heures.readings[0]?.value ?? 0;
|
||||||
|
|
||||||
|
const apres = await http()
|
||||||
|
.post(`/assets/${a1.id}/meter-readings`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ kind: 'RUNNING_HOURS', value: 90000000 + dernier })
|
||||||
|
.expect(201);
|
||||||
|
const majHeures = apres.body.meters.find(
|
||||||
|
(m: { kind: string }) => m.kind === 'RUNNING_HOURS',
|
||||||
|
);
|
||||||
|
expect(majHeures.readings[0].value).toBe(90000000 + dernier);
|
||||||
|
expect(majHeures.readings[0].readBy.displayName).toBe('Ahmed Benali');
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.post(`/assets/${a1.id}/meter-readings`)
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ kind: 'RUNNING_HOURS', value: dernier })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.get('/assets/00000000-0000-4000-8000-000000000000/meters')
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
77
apps/web/e2e/parcours-portail.spec.ts
Normal file
77
apps/web/e2e/parcours-portail.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* R2.4 — LA boucle produit : le gardien scanne le QR (aucun compte), signale,
|
||||||
|
* l'équipe traite (via API), le gardien voit « Résolu » sur son téléphone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
const API = 'http://localhost:3000';
|
||||||
|
|
||||||
|
test('portail QR : signalement sans compte → traitement → suivi Résolu', async ({
|
||||||
|
page,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
// 1 · Le gardien scanne le QR de l'ascenseur A1 → formulaire prérempli
|
||||||
|
await page.goto('/q/A1');
|
||||||
|
await expect(page.getByText(/Ascenseur A1/)).toBeVisible();
|
||||||
|
await expect(page.getByText('détecté par le QR')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel('Décrivez le problème').fill(`E2E ${suffix} — voyant cabine éteint`);
|
||||||
|
await page.getByRole('button', { name: 'Envoyer le signalement' }).click();
|
||||||
|
await expect(page.getByText('l\'équipe SPELEV est prévenue')).toBeVisible();
|
||||||
|
|
||||||
|
// « Mes signalements » : Reçu, sans jargon
|
||||||
|
const ligne = page.locator('.item-liste', { hasText: suffix });
|
||||||
|
await expect(ligne).toBeVisible();
|
||||||
|
await expect(ligne.locator('.etape.fait')).toHaveText('✓ Reçu');
|
||||||
|
|
||||||
|
// 2 · Côté SPELEV (via API — le parcours UI est couvert par parcours-r2) :
|
||||||
|
// approbation → démarrage → bilan → clôture
|
||||||
|
const comptes = await (await request.get(`${API}/auth/demo-accounts`)).json();
|
||||||
|
const salmaId = comptes.accounts.find((a: { roleName: string }) => a.roleName === 'Dispatcher').id;
|
||||||
|
const jeton = (await (await request.post(`${API}/auth/demo-login`, { data: { userId: salmaId } })).json()).accessToken;
|
||||||
|
const auth = { Authorization: `Bearer ${jeton}` };
|
||||||
|
|
||||||
|
const demandes = await (await request.get(`${API}/requests`, { headers: auth })).json();
|
||||||
|
const demande = demandes.requests.find((r: { description: string }) => r.description.includes(suffix));
|
||||||
|
expect(demande).toBeTruthy();
|
||||||
|
|
||||||
|
const ot = await (
|
||||||
|
await request.post(`${API}/requests/${demande.id}/approve`, { headers: auth, data: {} })
|
||||||
|
).json();
|
||||||
|
await request.post(`${API}/work-orders/${ot.id}/transition`, {
|
||||||
|
headers: auth,
|
||||||
|
data: { to: 'IN_PROGRESS' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3 · Le gardien recharge : « Intervention » franchie
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Intervention')).toBeVisible();
|
||||||
|
|
||||||
|
// 4 · Clôture avec bilan → « Résolu »
|
||||||
|
const refs = await (await request.get(`${API}/reference-values`, { headers: auth })).json();
|
||||||
|
const valeur = (field: string) =>
|
||||||
|
refs.referenceValues.find((v: { field: string; isActive: boolean }) => v.field === field && v.isActive).id;
|
||||||
|
await request.put(`${API}/work-orders/${ot.id}/report`, {
|
||||||
|
headers: auth,
|
||||||
|
data: {
|
||||||
|
doorStateId: valeur('DOOR_STATE'),
|
||||||
|
actionTakenId: valeur('ACTION_TAKEN'),
|
||||||
|
componentConcernedId: valeur('COMPONENT_CONCERNED'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const cloture = await request.post(`${API}/work-orders/${ot.id}/transition`, {
|
||||||
|
headers: auth,
|
||||||
|
data: { to: 'DONE' },
|
||||||
|
});
|
||||||
|
expect(cloture.ok()).toBe(true);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.item-liste', { hasText: suffix }).getByText('✓ Résolu')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('QR inconnu : message clair, pas d’écran technique', async ({ page }) => {
|
||||||
|
await page.goto('/q/INTROUVABLE-99');
|
||||||
|
await expect(page.getByText(/aucun appareil connu/)).toBeVisible();
|
||||||
|
});
|
||||||
117
apps/web/e2e/parcours-r2.spec.ts
Normal file
117
apps/web/e2e/parcours-r2.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recette R2 (plan de releases) : demande gardien → approbation → OT →
|
||||||
|
* intervention → bilan codé → clôture → suivi demandeur ; génération du
|
||||||
|
* préventif du mois (idempotente) ; compteur croissant.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
|
test('recette R2 : demande → OT → bilan codé → clôture → suivi demandeur', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// 1 · Karim (demandeur) signale depuis l'app
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Karim Doukkali/ }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Mes signalements' })).toBeVisible();
|
||||||
|
await page.getByRole('link', { name: /Demandes/ }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Signaler' }).click();
|
||||||
|
await page.getByLabel('Équipement *').selectOption({ index: 1 });
|
||||||
|
await page.getByLabel('Décrivez le problème *').fill(`E2E ${suffix} — porte qui claque très fort`);
|
||||||
|
await page.getByRole('button', { name: 'Envoyer le signalement' }).click();
|
||||||
|
await expect(page.getByRole('cell', { name: new RegExp(suffix) })).toBeVisible();
|
||||||
|
|
||||||
|
// 2 · Salma (dispatcher) approuve → OT créé, assigné à Ahmed
|
||||||
|
await page.locator('.compte').click();
|
||||||
|
await page.getByRole('menuitem', { name: /Salma Idrissi/ }).click();
|
||||||
|
await page.getByRole('link', { name: /Demandes/ }).click();
|
||||||
|
await page.getByRole('cell', { name: new RegExp(suffix) }).click();
|
||||||
|
await page.getByLabel('Assigner à').selectOption({ index: 1 }); // Ahmed Benali
|
||||||
|
await page.getByRole('button', { name: "Approuver → créer l'OT" }).click();
|
||||||
|
|
||||||
|
// → fiche OT
|
||||||
|
await expect(page.getByRole('heading', { name: new RegExp(suffix) })).toBeVisible();
|
||||||
|
await expect(page.locator('.entete-page .st')).toHaveText('Ouvert');
|
||||||
|
|
||||||
|
// 3 · Démarrer, tenter de clôturer (bloqué : bilan), remplir le bilan, clôturer
|
||||||
|
await page.getByRole('button', { name: 'Démarrer' }).click();
|
||||||
|
await expect(page.locator('.entete-page .st')).toHaveText('En cours');
|
||||||
|
const boutonCloture = page.getByRole('button', { name: "Clôturer l'intervention" });
|
||||||
|
await expect(boutonCloture).toBeDisabled(); // garde visible dans l'UI
|
||||||
|
await expect(page.locator('.avertissement')).toContainText('bilan incomplet');
|
||||||
|
|
||||||
|
await page.getByLabel(/État des portes/).selectOption({ label: 'Fonctionnement normal' });
|
||||||
|
await page.getByLabel(/Action réalisée/).selectOption({ label: 'Réglage' });
|
||||||
|
await page.getByLabel(/Élément concerné/).selectOption({ label: 'Portes' });
|
||||||
|
await expect(boutonCloture).toBeEnabled();
|
||||||
|
await boutonCloture.click();
|
||||||
|
await expect(page.locator('.entete-page .st')).toHaveText('Terminé');
|
||||||
|
|
||||||
|
// 4 · Karim suit : sa demande est Résolue
|
||||||
|
await page.locator('.compte').click();
|
||||||
|
await page.getByRole('menuitem', { name: /Karim Doukkali/ }).click();
|
||||||
|
await page.getByRole('link', { name: /Demandes/ }).click();
|
||||||
|
const ligne = page.locator('tr', { hasText: suffix });
|
||||||
|
await expect(ligne.locator('.st')).toHaveText('Résolue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('préventif : générer le mois — puis regénérer ne double rien', async ({ page }) => {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Yasmine Alaoui/ }).click();
|
||||||
|
await page.getByRole('link', { name: 'Préventif' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Préventif' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Essai du parachute')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Générer la grille de/ }).click();
|
||||||
|
await expect(page.locator('.gen-info')).toContainText('regénérer ne double rien');
|
||||||
|
// Second appel : 0 créé
|
||||||
|
await page.getByRole('button', { name: /Générer la grille de/ }).click();
|
||||||
|
await expect(page.locator('.gen-info')).toContainText('0 OT créés');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('urgence « personne bloquée » : interrupteur → chip topbar + bandeau + tête de liste', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Salma Idrissi/ }).click();
|
||||||
|
await page.getByRole('link', { name: /Ordres de travail/ }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
|
||||||
|
await page.getByLabel('Objet *').fill(`E2E ${suffix} — personne bloquée cage B`);
|
||||||
|
await page.getByLabel('Équipement *').selectOption({ index: 1 });
|
||||||
|
await page.getByRole('switch', { name: 'Personne bloquée' }).click();
|
||||||
|
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: new RegExp(suffix) })).toBeVisible();
|
||||||
|
|
||||||
|
// chip topbar (pulse) présent et bandeau sur le tableau de bord
|
||||||
|
await expect(page.locator('.topbar .chip-urgence')).toContainText('personne');
|
||||||
|
await page.getByRole('link', { name: 'Tableau de bord' }).click();
|
||||||
|
await expect(page.locator('.bandeau-urgence').first()).toBeVisible();
|
||||||
|
// tête de liste OT
|
||||||
|
await page.getByRole('link', { name: /Ordres de travail/ }).click();
|
||||||
|
await expect(page.locator('tbody tr').first()).toHaveClass(/ligne-bloque/);
|
||||||
|
|
||||||
|
// nettoyage : annuler l'OT d'urgence créé (motif via prompt)
|
||||||
|
await page.locator('tbody tr').first().click();
|
||||||
|
page.once('dialog', (d) => d.accept('Test e2e — nettoyage'));
|
||||||
|
await page.getByRole('button', { name: 'Annuler', exact: true }).click();
|
||||||
|
await expect(page.locator('.entete-page .st')).toHaveText('Annulé');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compteurs : relevé croissant accepté, régression refusée', async ({ page }) => {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Ahmed Benali/ }).click();
|
||||||
|
await page.getByRole('link', { name: 'Ascenseurs' }).click();
|
||||||
|
await page.getByRole('cell', { name: 'A1', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Compteurs' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /Compteurs — Ascenseur A1/ })).toBeVisible();
|
||||||
|
|
||||||
|
const saisie = page.getByLabel('Nouveau relevé — Heures de marche');
|
||||||
|
await saisie.fill('99999999');
|
||||||
|
await page.getByRole('button', { name: 'Enregistrer le relevé' }).first().click();
|
||||||
|
await expect(page.locator('.chrono').first()).toContainText('99 999 999');
|
||||||
|
|
||||||
|
await saisie.fill('12');
|
||||||
|
await page.getByRole('button', { name: 'Enregistrer le relevé' }).first().click();
|
||||||
|
await expect(page.locator('.erreur-form')).toContainText('ne redescend pas');
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
235
apps/web/src/api/exploitation.ts
Normal file
235
apps/web/src/api/exploitation.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
AssigneesUpdate,
|
||||||
|
ChecklistPatch,
|
||||||
|
MeterReadingCreate,
|
||||||
|
PreventiveGenerate,
|
||||||
|
ReportUpsert,
|
||||||
|
RequestApprove,
|
||||||
|
RequestCreate,
|
||||||
|
RequestReject,
|
||||||
|
TaskTemplateCreate,
|
||||||
|
TaskTemplateUpdate,
|
||||||
|
TransitionRequest,
|
||||||
|
WorkOrderCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
/** Hooks R2 — client typé, invalidations ciblées. */
|
||||||
|
|
||||||
|
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.error || res.data === undefined) {
|
||||||
|
const message =
|
||||||
|
(res.error as { message?: string } | undefined)?.message ??
|
||||||
|
`Le serveur a répondu ${res.response.status}`;
|
||||||
|
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidate(...keys: string[]) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return () =>
|
||||||
|
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Ordres de travail —————
|
||||||
|
|
||||||
|
export function useWorkOrders(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['work-orders'],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkOrder(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['work-orders', id],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET('/work-orders/{id}', { params: { path: { id: id! } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateWorkOrder() {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: WorkOrderCreate) =>
|
||||||
|
unwrap(await api.POST('/work-orders', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransitionWorkOrder(id: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders', 'requests', 'preventive-status');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: TransitionRequest) =>
|
||||||
|
unwrap(await api.POST('/work-orders/{id}/transition', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCommentWorkOrder(id: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (message: string) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/work-orders/{id}/comments', {
|
||||||
|
params: { path: { id } },
|
||||||
|
body: { message },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetAssignees(id: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssigneesUpdate) =>
|
||||||
|
unwrap(await api.PUT('/work-orders/{id}/assignees', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertReport(id: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: ReportUpsert) =>
|
||||||
|
unwrap(await api.PUT('/work-orders/{id}/report', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchChecklist(id: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ itemId, ...body }: ChecklistPatch & { itemId: string }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH('/work-orders/{id}/checklist/{itemId}', {
|
||||||
|
params: { path: { id, itemId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Demandes —————
|
||||||
|
|
||||||
|
export function useRequests() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['requests'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/requests'))).requests,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateRequest() {
|
||||||
|
const invalidate = useInvalidate('requests');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: RequestCreate) => unwrap(await api.POST('/requests', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApproveRequest() {
|
||||||
|
const invalidate = useInvalidate('requests', 'work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: RequestApprove & { id: string }) =>
|
||||||
|
unwrap(await api.POST('/requests/{id}/approve', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRejectRequest() {
|
||||||
|
const invalidate = useInvalidate('requests');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: RequestReject & { id: string }) =>
|
||||||
|
unwrap(await api.POST('/requests/{id}/reject', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Référentiels du bilan —————
|
||||||
|
|
||||||
|
export function useReferenceValues() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['reference-values'],
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
queryFn: async () =>
|
||||||
|
(await unwrap(await api.GET('/reference-values'))).referenceValues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Préventif —————
|
||||||
|
|
||||||
|
export function useTaskTemplates() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['task-templates'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/preventive/templates'))).templates,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTaskTemplate() {
|
||||||
|
const invalidate = useInvalidate('task-templates');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: TaskTemplateCreate) =>
|
||||||
|
unwrap(await api.POST('/preventive/templates', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateTaskTemplate() {
|
||||||
|
const invalidate = useInvalidate('task-templates');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: TaskTemplateUpdate & { id: string }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PATCH('/preventive/templates/{id}', { params: { path: { id } }, body }),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePreventiveStatus(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['preventive-status'],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () => unwrap(await api.GET('/preventive/status')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGeneratePreventive() {
|
||||||
|
const invalidate = useInvalidate('preventive-status', 'work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: PreventiveGenerate) =>
|
||||||
|
unwrap(await api.POST('/preventive/generate', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Compteurs —————
|
||||||
|
|
||||||
|
export function useMeters(assetId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['meters', assetId],
|
||||||
|
enabled: !!assetId,
|
||||||
|
queryFn: async () =>
|
||||||
|
(await unwrap(await api.GET('/assets/{id}/meters', { params: { path: { id: assetId! } } })))
|
||||||
|
.meters,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddMeterReading(assetId: string) {
|
||||||
|
const invalidate = useInvalidate('meters');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: MeterReadingCreate) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/assets/{id}/meter-readings', {
|
||||||
|
params: { path: { id: assetId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
|||||||
1504
apps/web/src/api/schema.d.ts
vendored
1504
apps/web/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
66
apps/web/src/components/chips-ot.tsx
Normal file
66
apps/web/src/components/chips-ot.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
REQUEST_STATUS_LABELS,
|
||||||
|
WORK_ORDER_PRIORITY_LABELS,
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
type RequestStatus,
|
||||||
|
type WorkOrderPriority,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
const ST_CLASSES: Record<WorkOrderStatus, string> = {
|
||||||
|
OPEN: 'ouvert',
|
||||||
|
IN_PROGRESS: 'encours',
|
||||||
|
ON_HOLD: 'attente',
|
||||||
|
DONE: 'termine',
|
||||||
|
CANCELLED: 'annule',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Statut OT — les couleurs réservées de la charte. */
|
||||||
|
export function StatutOT({ statut }: { statut: WorkOrderStatus }) {
|
||||||
|
return (
|
||||||
|
<span className={cn('st', ST_CLASSES[statut])}>
|
||||||
|
{WORK_ORDER_STATUS_LABELS[statut]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIO_CLASSES: Record<WorkOrderPriority, string | null> = {
|
||||||
|
NONE: null,
|
||||||
|
LOW: 'basse',
|
||||||
|
MEDIUM: 'moyenne',
|
||||||
|
HIGH: 'haute',
|
||||||
|
PERSON_TRAPPED: 'bloque',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Priorité — pastille + libellé, jamais la couleur seule (charte). */
|
||||||
|
export function PrioriteOT({ priorite }: { priorite: WorkOrderPriority }) {
|
||||||
|
const classe = PRIO_CLASSES[priorite];
|
||||||
|
if (!classe) return <span className="text-encre-3">—</span>;
|
||||||
|
return (
|
||||||
|
<span className={cn('prio', classe)}>
|
||||||
|
<i />
|
||||||
|
{WORK_ORDER_PRIORITY_LABELS[priorite]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEM_CLASSES: Record<RequestStatus, string> = {
|
||||||
|
RECEIVED: 'recue',
|
||||||
|
APPROVED: 'approuvee',
|
||||||
|
REJECTED: 'rejetee',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Statut de demande — « Résolue » quand l'OT lié est terminé. */
|
||||||
|
export function StatutDemande({
|
||||||
|
statut,
|
||||||
|
otStatut,
|
||||||
|
}: {
|
||||||
|
statut: RequestStatus;
|
||||||
|
otStatut?: WorkOrderStatus | null;
|
||||||
|
}) {
|
||||||
|
if (statut === 'APPROVED' && otStatut === 'DONE') {
|
||||||
|
return <span className="st resolue">Résolue</span>;
|
||||||
|
}
|
||||||
|
return <span className={cn('st', DEM_CLASSES[statut])}>{REQUEST_STATUS_LABELS[statut]}</span>;
|
||||||
|
}
|
||||||
@@ -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… ⌘K
|
Rechercher un OT, un ascenseur, un site… ⌘K
|
||||||
</div>
|
</div>
|
||||||
|
{urgences.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chip-urgence"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/ot/${urgences[0]!.id}`)}
|
||||||
|
>
|
||||||
|
<span className="point-pulse" />
|
||||||
|
{urgences.length === 1
|
||||||
|
? '1 personne bloquée'
|
||||||
|
: `${urgences.length} personnes bloquées`}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn ghost"
|
className="btn ghost"
|
||||||
|
|||||||
89
apps/web/src/pages/compteurs.tsx
Normal file
89
apps/web/src/pages/compteurs.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { METER_KIND_LABELS, type MeterDto, type MeterKind } from '@siop/shared';
|
||||||
|
import { useAddMeterReading, useMeters } from '@/api/exploitation';
|
||||||
|
import { useAsset } from '@/api/referentiel';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const UNITES: Record<MeterKind, string> = { RUNNING_HOURS: 'h', STARTS: 'cycles' };
|
||||||
|
const fmt = new Intl.NumberFormat('fr-FR');
|
||||||
|
|
||||||
|
/** Écran 5 des maquettes R2 : relevés simples du carnet, strictement croissants. */
|
||||||
|
export default function PageCompteurs() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data: asset } = useAsset(id);
|
||||||
|
const { data: meters } = useMeters(id);
|
||||||
|
if (!asset) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/ascenseurs">Ascenseurs</Link> /{' '}
|
||||||
|
<Link to={`/ascenseurs/${asset.id}`}>Ascenseur {asset.reference}</Link> / Compteurs
|
||||||
|
</div>
|
||||||
|
<div className="entete-page"><h1>Compteurs — Ascenseur {asset.reference}</h1></div>
|
||||||
|
<div className="grille-2">
|
||||||
|
{(meters ?? []).map((meter) => (
|
||||||
|
<CarteCompteur key={meter.kind} assetId={asset.id} meter={meter} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||||
|
Un relevé <b>inférieur au précédent</b> est refusé (un compteur ne redescend pas).
|
||||||
|
Les seuils d'alerte (préventif conditionnel) sont au backlog — R2 stocke et trace.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarteCompteur({ assetId, meter }: { assetId: string; meter: MeterDto }) {
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const ajout = useAddMeterReading(assetId);
|
||||||
|
const [valeur, setValeur] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>{METER_KIND_LABELS[meter.kind]}</h2>
|
||||||
|
{can('METERS', 'create') ? (
|
||||||
|
<form
|
||||||
|
className="releve-saisie"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const v = Number(valeur.replace(/\s/g, ''));
|
||||||
|
if (!Number.isFinite(v)) return;
|
||||||
|
ajout.mutate({ kind: meter.kind, value: v }, { onSuccess: () => setValeur('') });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={`Nouveau relevé — ${METER_KIND_LABELS[meter.kind]}`}
|
||||||
|
className="num"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder={meter.readings[0] ? fmt.format(meter.readings[0].value) : '0'}
|
||||||
|
value={valeur}
|
||||||
|
onChange={(e) => setValeur(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span style={{ color: 'var(--encre-2)', fontSize: 13 }}>{UNITES[meter.kind]}</span>
|
||||||
|
<Button type="submit" variant="prim" disabled={ajout.isPending}>
|
||||||
|
Enregistrer le relevé
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
{ajout.isError ? <p className="erreur-form mt-2">{ajout.error.message}</p> : null}
|
||||||
|
<ul className="chrono" style={{ marginTop: 14 }}>
|
||||||
|
{meter.readings.map((r) => (
|
||||||
|
<li key={r.id} className="fait">
|
||||||
|
<span className="num">{fmt.format(r.value)} {UNITES[meter.kind]}</span>
|
||||||
|
<br />
|
||||||
|
<span className="quand">
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(r.createdAt))}
|
||||||
|
{r.readBy ? ` · ${r.readBy.displayName}` : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{meter.readings.length === 0 ? (
|
||||||
|
<li className="text-encre-3" style={{ padding: 0 }}>Aucun relevé pour l'instant.</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
301
apps/web/src/pages/demandes.tsx
Normal file
301
apps/web/src/pages/demandes.tsx
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
WORK_ORDER_PRIORITY_LABELS,
|
||||||
|
type RequestSummary,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
useApproveRequest,
|
||||||
|
useCreateRequest,
|
||||||
|
useRejectRequest,
|
||||||
|
useRequests,
|
||||||
|
} from '@/api/exploitation';
|
||||||
|
import { useAssetOptions, useUsers } from '@/api/referentiel';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { StatutDemande } from '@/components/chips-ot';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Modale } from '@/components/ui/modale';
|
||||||
|
|
||||||
|
/** Écran 1 des maquettes R2 : la file d'attente + le panneau d'approbation.
|
||||||
|
* Une demande approuvée devient UN OT (1-1) ; un rejet exige un motif. */
|
||||||
|
export default function PageDemandes() {
|
||||||
|
const { data: requests } = useRequests();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [selectionnee, setSelectionnee] = useState<string | null>(null);
|
||||||
|
const [modaleSignalement, setModaleSignalement] = useState(false);
|
||||||
|
|
||||||
|
const toutes = requests ?? [];
|
||||||
|
const aTraiter = toutes.filter((r) => r.status === 'RECEIVED').length;
|
||||||
|
const demande = toutes.find((r) => r.id === selectionnee) ??
|
||||||
|
toutes.find((r) => r.status === 'RECEIVED') ?? null;
|
||||||
|
const peutTraiter = can('REQUESTS', 'edit');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Demandes</h1>
|
||||||
|
<span className="filajout">
|
||||||
|
{aTraiter} à traiter · {toutes.length} au total
|
||||||
|
</span>
|
||||||
|
<div className="actions">
|
||||||
|
{can('REQUESTS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => setModaleSignalement(true)}>
|
||||||
|
+ Signaler
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2" style={{ gridTemplateColumns: peutTraiter ? '1.4fr 1fr' : '1fr' }}>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>N°</th>
|
||||||
|
<th>Signalement</th>
|
||||||
|
<th>Équipement · Site</th>
|
||||||
|
<th>Par</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{toutes.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
className={r.isPersonTrapped && r.status === 'RECEIVED' ? 'ligne-bloque' : undefined}
|
||||||
|
style={{
|
||||||
|
cursor: peutTraiter ? 'pointer' : undefined,
|
||||||
|
background: r.id === demande?.id && peutTraiter ? 'var(--primaire-doux)' : undefined,
|
||||||
|
}}
|
||||||
|
onClick={() => setSelectionnee(r.id)}
|
||||||
|
>
|
||||||
|
<td className="ref num">{r.reference}</td>
|
||||||
|
<td>{r.isPersonTrapped ? <b>{r.description}</b> : r.description}</td>
|
||||||
|
<td>
|
||||||
|
Asc. {r.assetReference}
|
||||||
|
<span className="sous">{r.siteName}</span>
|
||||||
|
</td>
|
||||||
|
<td>{r.requesterLabel}</td>
|
||||||
|
<td>
|
||||||
|
{r.workOrder ? (
|
||||||
|
<Link to={`/ot/${r.workOrder.id}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<StatutDemande statut={r.status} otStatut={r.workOrder.status} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<StatutDemande statut={r.status} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{peutTraiter && demande ? <PanneauApprobation demande={demande} /> : null}
|
||||||
|
</div>
|
||||||
|
<ModaleSignalement ouverte={modaleSignalement} surFermeture={setModaleSignalement} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanneauApprobation({ demande }: { demande: RequestSummary }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: users } = useUsers();
|
||||||
|
const approbation = useApproveRequest();
|
||||||
|
const rejet = useRejectRequest();
|
||||||
|
const [motif, setMotif] = useState<string | null>(null); // null = fermé
|
||||||
|
const [priorite, setPriorite] = useState('HIGH');
|
||||||
|
const [assigne, setAssigne] = useState('');
|
||||||
|
const techniciens = (users ?? []).filter(
|
||||||
|
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
|
||||||
|
);
|
||||||
|
const traitee = demande.status !== 'RECEIVED';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panneau">
|
||||||
|
<h2 style={{ fontSize: 13, fontWeight: 700, letterSpacing: '.04em', textTransform: 'uppercase', color: 'var(--encre-2)' }}>
|
||||||
|
{demande.reference} — {traitee ? 'déjà traitée' : 'approbation'}
|
||||||
|
</h2>
|
||||||
|
<dl className="infos" style={{ gridTemplateColumns: '110px 1fr' }}>
|
||||||
|
<dt>Équipement</dt>
|
||||||
|
<dd>
|
||||||
|
<Link to={`/ascenseurs/${demande.assetId}`} style={{ color: 'var(--primaire)', fontWeight: 600 }}>
|
||||||
|
Ascenseur {demande.assetReference}
|
||||||
|
</Link>{' '}
|
||||||
|
— {demande.siteName}
|
||||||
|
</dd>
|
||||||
|
<dt>Signalé par</dt>
|
||||||
|
<dd>{demande.requesterLabel}</dd>
|
||||||
|
<dt>Description</dt>
|
||||||
|
<dd>« {demande.description} »</dd>
|
||||||
|
{demande.rejectionReason ? (
|
||||||
|
<>
|
||||||
|
<dt>Motif du rejet</dt>
|
||||||
|
<dd>{demande.rejectionReason}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
{!traitee ? (
|
||||||
|
<>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="app-prio">Priorité de l'OT</label>
|
||||||
|
<select
|
||||||
|
id="app-prio"
|
||||||
|
value={demande.isPersonTrapped ? 'PERSON_TRAPPED' : priorite}
|
||||||
|
disabled={demande.isPersonTrapped}
|
||||||
|
onChange={(e) => setPriorite(e.target.value)}
|
||||||
|
>
|
||||||
|
{(['LOW', 'MEDIUM', 'HIGH', 'PERSON_TRAPPED'] as const).map((p) => (
|
||||||
|
<option key={p} value={p}>{WORK_ORDER_PRIORITY_LABELS[p]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="app-assigne">Assigner à</label>
|
||||||
|
<select id="app-assigne" value={assigne} onChange={(e) => setAssigne(e.target.value)}>
|
||||||
|
<option value="">— plus tard —</option>
|
||||||
|
{techniciens.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.displayName}
|
||||||
|
{t.teams.length ? ` — équipe ${t.teams[0]!.name}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{(approbation.isError || rejet.isError) ? (
|
||||||
|
<p className="erreur-form" role="alert">
|
||||||
|
{approbation.error?.message ?? rejet.error?.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="dangerLeger" onClick={() => setMotif('')}>
|
||||||
|
Rejeter (motif requis)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="prim"
|
||||||
|
disabled={approbation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
approbation.mutate(
|
||||||
|
{
|
||||||
|
id: demande.id,
|
||||||
|
priority: (demande.isPersonTrapped ? 'PERSON_TRAPPED' : priorite) as never,
|
||||||
|
assigneeIds: assigne ? [assigne] : undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: (ot) => navigate(`/ot/${ot.id}`) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Approuver → créer l'OT
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--encre-3)' }}>
|
||||||
|
L'OT reprend équipement et description — la demande suit ensuite l'OT
|
||||||
|
(Reçu → Intervention → Résolu côté demandeur).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Modale titre="Rejeter la demande" ouverte={motif !== null} surFermeture={() => setMotif(null)}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
rejet.mutate({ id: demande.id, reason: motif ?? '' }, { onSuccess: () => setMotif(null) });
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="rej-motif">Motif (lisible par le demandeur, sans jargon) *</label>
|
||||||
|
<input
|
||||||
|
id="rej-motif"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
value={motif ?? ''}
|
||||||
|
onChange={(e) => setMotif(e.target.value)}
|
||||||
|
placeholder="Ex. : fausse alerte confirmée sur place"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{rejet.isError ? <p className="erreur-form">{rejet.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => setMotif(null)}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="dangerLeger" disabled={rejet.isPending}>
|
||||||
|
Rejeter la demande
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signalement interne (le portail public QR arrive en R2.4). */
|
||||||
|
function ModaleSignalement({
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { data: options } = useAssetOptions();
|
||||||
|
const creation = useCreateRequest();
|
||||||
|
const [personneBloquee, setPersonneBloquee] = useState(false);
|
||||||
|
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
creation.mutate(
|
||||||
|
{
|
||||||
|
assetId: String(donnees.get('assetId')),
|
||||||
|
description: String(donnees.get('description')),
|
||||||
|
isPersonTrapped: personneBloquee,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
surFermeture(false);
|
||||||
|
setPersonneBloquee(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modale titre="Signaler un problème" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="sig-asset">Équipement *</label>
|
||||||
|
<select id="sig-asset" name="assetId" required defaultValue="">
|
||||||
|
<option value="" disabled>Choisir…</option>
|
||||||
|
{(options ?? []).map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.reference} — {a.siteName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="gros-toggle">
|
||||||
|
<b>Une personne est-elle bloquée ?</b>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="interrupteur"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={personneBloquee}
|
||||||
|
aria-label="Personne bloquée"
|
||||||
|
onClick={() => setPersonneBloquee((v) => !v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="sig-desc">Décrivez le problème *</label>
|
||||||
|
<input
|
||||||
|
id="sig-desc"
|
||||||
|
name="description"
|
||||||
|
required
|
||||||
|
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||||
|
Envoyer le signalement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 l’exploitation (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,
|
||||||
|
|||||||
354
apps/web/src/pages/fiche-ot.tsx
Normal file
354
apps/web/src/pages/fiche-ot.tsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
BILAN_FIELD_LABELS,
|
||||||
|
REQUIRED_BILAN_FIELDS,
|
||||||
|
WORK_ORDER_TYPE_LABELS,
|
||||||
|
type BilanField,
|
||||||
|
type WorkOrderDetail,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
useCommentWorkOrder,
|
||||||
|
usePatchChecklist,
|
||||||
|
useReferenceValues,
|
||||||
|
useSetAssignees,
|
||||||
|
useTransitionWorkOrder,
|
||||||
|
useUpsertReport,
|
||||||
|
useWorkOrder,
|
||||||
|
} from '@/api/exploitation';
|
||||||
|
import { useUsers } from '@/api/referentiel';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Avatar } from '@/components/avatar';
|
||||||
|
import { PrioriteOT, StatutOT } from '@/components/chips-ot';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { formatDateFr } from '@/lib/lieux';
|
||||||
|
|
||||||
|
const BOUTONS_TRANSITION: Record<WorkOrderStatus, { libelle: string; variant?: 'prim' | 'succes' | 'dangerLeger' | 'ghost' }> = {
|
||||||
|
IN_PROGRESS: { libelle: 'Démarrer', variant: 'prim' },
|
||||||
|
ON_HOLD: { libelle: 'En attente', variant: 'ghost' },
|
||||||
|
DONE: { libelle: "Clôturer l'intervention", variant: 'succes' },
|
||||||
|
CANCELLED: { libelle: 'Annuler', variant: 'dangerLeger' },
|
||||||
|
OPEN: { libelle: 'Rouvrir' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Écran 4 validé R0 (+ checklist maquette R2) : tout le carnet sur un écran. */
|
||||||
|
export default function PageFicheOT() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data: ot } = useWorkOrder(id);
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const transition = useTransitionWorkOrder(id ?? '');
|
||||||
|
|
||||||
|
if (!ot) return null;
|
||||||
|
const peutEditer = can('WORK_ORDERS', 'edit');
|
||||||
|
const cloturable = ot.closureBlockers.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/ot">Ordres de travail</Link> / {ot.reference}
|
||||||
|
</div>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>{ot.title}</h1>
|
||||||
|
<StatutOT statut={ot.status} />
|
||||||
|
<div className="actions">
|
||||||
|
{peutEditer
|
||||||
|
? ot.allowedTransitions.map((cible) => {
|
||||||
|
const bouton = BOUTONS_TRANSITION[cible];
|
||||||
|
const bloquee = cible === 'DONE' && !cloturable;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={cible}
|
||||||
|
variant={bouton.variant}
|
||||||
|
disabled={transition.isPending || bloquee}
|
||||||
|
title={bloquee ? ot.closureBlockers.join(' ; ') : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
const commentaire =
|
||||||
|
cible === 'CANCELLED'
|
||||||
|
? window.prompt('Motif d’annulation (visible dans l’activité) :') ?? undefined
|
||||||
|
: undefined;
|
||||||
|
if (cible === 'CANCELLED' && !commentaire) return;
|
||||||
|
transition.mutate({ to: cible, comment: commentaire });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bouton.libelle}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{transition.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{transition.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="grille-2">
|
||||||
|
<CarteIntervention ot={ot} peutEditer={peutEditer} />
|
||||||
|
<CarteActivite ot={ot} peutEditer={peutEditer} />
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{ot.checklist.length > 0 ? <CarteChecklist ot={ot} peutEditer={peutEditer} /> : null}
|
||||||
|
<CarteBilan ot={ot} peutEditer={peutEditer} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||||
|
<h2>Pièces & main-d'œuvre</h2>
|
||||||
|
<p className="text-encre-3">Consommations et coûts arrivent avec la gestion (R3).</p>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||||
|
<h2>Documents</h2>
|
||||||
|
<p className="text-encre-3">Photos et pièces jointes arrivent avec la bibliothèque (R3).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarteIntervention({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
|
||||||
|
const { data: users } = useUsers();
|
||||||
|
const assignation = useSetAssignees(ot.id);
|
||||||
|
const [enAssignation, setEnAssignation] = useState(false);
|
||||||
|
const techniciens = (users ?? []).filter(
|
||||||
|
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Intervention</h2>
|
||||||
|
<dl className="infos">
|
||||||
|
<dt>N°</dt>
|
||||||
|
<dd className="num">{ot.reference} · {WORK_ORDER_TYPE_LABELS[ot.type]}</dd>
|
||||||
|
<dt>Équipement</dt>
|
||||||
|
<dd>
|
||||||
|
<Link to={`/ascenseurs/${ot.assetId}`} style={{ color: 'var(--primaire)', fontWeight: 600 }}>
|
||||||
|
Ascenseur {ot.assetReference}
|
||||||
|
</Link>{' '}
|
||||||
|
— {ot.siteName}
|
||||||
|
</dd>
|
||||||
|
<dt>Priorité</dt>
|
||||||
|
<dd><PrioriteOT priorite={ot.priority} /></dd>
|
||||||
|
<dt>Échéance</dt>
|
||||||
|
<dd className="num">{formatDateFr(ot.dueDate)}</dd>
|
||||||
|
<dt>Assignés</dt>
|
||||||
|
<dd className="flex items-center gap-2">
|
||||||
|
{ot.assignees.length ? (
|
||||||
|
<>
|
||||||
|
<span className="avatars">
|
||||||
|
{ot.assignees.map((a) => (
|
||||||
|
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{ot.assignees.map((a) => a.displayName).join(', ')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
{peutEditer ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-[12px]"
|
||||||
|
onClick={() => setEnAssignation((v) => !v)}
|
||||||
|
>
|
||||||
|
+ Assigner
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</dd>
|
||||||
|
{ot.request ? (
|
||||||
|
<>
|
||||||
|
<dt>Demande liée</dt>
|
||||||
|
<dd>
|
||||||
|
<span className="num">{ot.request.reference}</span> — {ot.request.requesterLabel}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
{enAssignation ? (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="Choisir un technicien"
|
||||||
|
className="flex-1 rounded-lg border border-bordure-forte bg-surface p-2 text-encre"
|
||||||
|
defaultValue=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value) return;
|
||||||
|
assignation.mutate(
|
||||||
|
{ assigneeIds: [...new Set([...ot.assignees.map((a) => a.id), e.target.value])] },
|
||||||
|
{ onSuccess: () => setEnAssignation(false) },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Choisir un technicien…</option>
|
||||||
|
{techniciens.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.displayName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{ot.assignees.length ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-[12px]"
|
||||||
|
onClick={() =>
|
||||||
|
assignation.mutate({ assigneeIds: [] }, { onSuccess: () => setEnAssignation(false) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Tout retirer
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<string, string> = {
|
||||||
|
CREATED: 'OT créé',
|
||||||
|
GENERATED: 'Généré',
|
||||||
|
FROM_REQUEST: 'Créé depuis une demande',
|
||||||
|
STATUS_CHANGED: 'Changement d’état',
|
||||||
|
ASSIGNED: 'Assignation',
|
||||||
|
COMMENT: 'Commentaire',
|
||||||
|
};
|
||||||
|
|
||||||
|
function CarteActivite({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
|
||||||
|
const commentaire = useCommentWorkOrder(ot.id);
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const message = String(new FormData(form).get('message') ?? '').trim();
|
||||||
|
if (!message) return;
|
||||||
|
commentaire.mutate(message, { onSuccess: () => form.reset() });
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Activité</h2>
|
||||||
|
{peutEditer ? (
|
||||||
|
<form onSubmit={surEnvoi} className="mb-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
name="message"
|
||||||
|
aria-label="Commenter"
|
||||||
|
placeholder="Commenter…"
|
||||||
|
className="flex-1 rounded-lg border border-bordure-forte bg-surface px-3 py-2 text-encre"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={commentaire.isPending}>Publier</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
<ul className="chrono">
|
||||||
|
{ot.events.map((e) => (
|
||||||
|
<li key={e.id} className="fait">
|
||||||
|
{e.kind === 'COMMENT' ? (
|
||||||
|
<>Commentaire {e.by ? <b>{e.by.displayName}</b> : null} : « {e.message} »</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<b>{KIND_LABELS[e.kind] ?? e.kind}</b>
|
||||||
|
{e.message ? <> — {e.message}</> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<br />
|
||||||
|
<span className="quand">
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(e.createdAt))}
|
||||||
|
{e.by && e.kind !== 'COMMENT' ? ` · ${e.by.displayName}` : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarteChecklist({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
|
||||||
|
const patch = usePatchChecklist(ot.id);
|
||||||
|
const traitees = ot.checklist.filter((c) => c.state !== 'PENDING').length;
|
||||||
|
return (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>
|
||||||
|
Checklist du mois{' '}
|
||||||
|
<span style={{ color: 'var(--encre-3)', textTransform: 'none', letterSpacing: 0 }}>
|
||||||
|
— {traitees} / {ot.checklist.length} traitées
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{ot.checklist.map((item) => (
|
||||||
|
<div className="check" key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`coche ${item.state === 'DONE' ? 'ok' : item.state === 'NA' ? 'na' : ''}`}
|
||||||
|
aria-label={`${item.label} — ${item.state === 'PENDING' ? 'marquer Fait (clic) ou N-A (double clic)' : 'remettre à faire'}`}
|
||||||
|
disabled={!peutEditer || patch.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
patch.mutate({
|
||||||
|
itemId: item.id,
|
||||||
|
state: item.state === 'PENDING' ? 'DONE' : item.state === 'DONE' ? 'NA' : 'PENDING',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.state === 'DONE' ? '✓' : item.state === 'NA' ? 'N-A' : ''}
|
||||||
|
</button>
|
||||||
|
{item.label}
|
||||||
|
<span className="qui-fait">
|
||||||
|
{item.doneBy
|
||||||
|
? `${item.doneBy.displayName.split(' ')[0]} · ${new Intl.DateTimeFormat('fr-FR', { timeStyle: 'short' }).format(new Date(item.doneAt!))}`
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{ot.checklist.some((c) => c.state === 'PENDING') ? (
|
||||||
|
<p className="avertissement">
|
||||||
|
⚠ La clôture reste bloquée : chaque tâche doit être <b>Fait</b> ou <b>N-A</b>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail['report']> & string; dto: string }[] = [
|
||||||
|
{ champ: 'DOOR_STATE', cle: 'doorState', dto: 'doorStateId' },
|
||||||
|
{ champ: 'CABIN_POSITION', cle: 'cabinPosition', dto: 'cabinPositionId' },
|
||||||
|
{ champ: 'ANOMALY', cle: 'anomaly', dto: 'anomalyId' },
|
||||||
|
{ champ: 'EXTERNAL_CAUSE', cle: 'externalCause', dto: 'externalCauseId' },
|
||||||
|
{ champ: 'ACTION_TAKEN', cle: 'actionTaken', dto: 'actionTakenId' },
|
||||||
|
{ champ: 'COMPONENT_CONCERNED', cle: 'componentConcerned', dto: 'componentConcernedId' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
|
||||||
|
const { data: valeurs } = useReferenceValues();
|
||||||
|
const maj = useUpsertReport(ot.id);
|
||||||
|
const bloqueurBilan = ot.closureBlockers.find((b) => b.includes('bilan'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>
|
||||||
|
Bilan d'intervention{' '}
|
||||||
|
<span style={{ color: 'var(--encre-3)', textTransform: 'none', letterSpacing: 0 }}>
|
||||||
|
— requis pour clôturer
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="bilan">
|
||||||
|
{CHAMPS_BILAN.map(({ champ, cle, dto }) => {
|
||||||
|
const options = (valeurs ?? []).filter((v) => v.field === champ && v.isActive);
|
||||||
|
const valeur = ot.report?.[cle as 'doorState'] ?? null;
|
||||||
|
const requis = REQUIRED_BILAN_FIELDS.includes(champ);
|
||||||
|
return (
|
||||||
|
<div className="champ-b" key={champ}>
|
||||||
|
<label htmlFor={`bilan-${champ}`}>
|
||||||
|
{BILAN_FIELD_LABELS[champ]} {requis ? <em>*</em> : null}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`bilan-${champ}`}
|
||||||
|
disabled={!peutEditer || maj.isPending || ot.status === 'DONE' || ot.status === 'CANCELLED'}
|
||||||
|
value={valeur?.id ?? ''}
|
||||||
|
onChange={(e) => maj.mutate({ [dto]: e.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner…</option>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{bloqueurBilan && ot.status !== 'DONE' && ot.status !== 'CANCELLED' ? (
|
||||||
|
<p className="avertissement">⚠ {bloqueurBilan} — la clôture restera bloquée.</p>
|
||||||
|
) : null}
|
||||||
|
{maj.isError ? <p className="erreur-form" role="alert">{maj.error.message}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
apps/web/src/pages/nouvel-ot.tsx
Normal file
135
apps/web/src/pages/nouvel-ot.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
WORK_ORDER_PRIORITY_LABELS,
|
||||||
|
WORK_ORDER_TYPE_LABELS,
|
||||||
|
WORK_ORDER_TYPES,
|
||||||
|
type WorkOrderPriority,
|
||||||
|
type WorkOrderType,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { useCreateWorkOrder } from '@/api/exploitation';
|
||||||
|
import { useAssets } from '@/api/referentiel';
|
||||||
|
import { useUsers } from '@/api/referentiel';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
/** Écran 2 des maquettes R2 : l'OT manuel — « personne bloquée » est un
|
||||||
|
* choix EXPLICITE (interrupteur) qui déclenche l'urgence partout. */
|
||||||
|
export default function PageNouvelOT() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const { data: assets } = useAssets();
|
||||||
|
const { data: users } = useUsers();
|
||||||
|
const creation = useCreateWorkOrder();
|
||||||
|
const [personneBloquee, setPersonneBloquee] = useState(false);
|
||||||
|
|
||||||
|
const techniciens = (users ?? []).filter(
|
||||||
|
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
const champ = (n: string) => String(donnees.get(n) ?? '').trim();
|
||||||
|
const assigne = champ('assigneeId');
|
||||||
|
creation.mutate(
|
||||||
|
{
|
||||||
|
title: champ('title'),
|
||||||
|
description: champ('description') || undefined,
|
||||||
|
type: champ('type') as WorkOrderType,
|
||||||
|
priority: personneBloquee
|
||||||
|
? 'PERSON_TRAPPED'
|
||||||
|
: (champ('priority') as WorkOrderPriority),
|
||||||
|
assetId: champ('assetId'),
|
||||||
|
dueDate: champ('dueDate') ? new Date(champ('dueDate')).toISOString() : undefined,
|
||||||
|
assigneeIds: assigne ? [assigne] : undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: (ot) => navigate(`/ot/${ot.id}`) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 760 }} className="flex flex-col gap-4">
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/ot">Ordres de travail</Link> / Nouvel OT
|
||||||
|
</div>
|
||||||
|
<div className="entete-page"><h1>Nouvel ordre de travail</h1></div>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-4">
|
||||||
|
<div className="carte">
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ pleine-l">
|
||||||
|
<label htmlFor="not-titre">Objet *</label>
|
||||||
|
<input id="not-titre" name="title" required placeholder="Ex. : Remplacement contact de porte" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="not-type">Type *</label>
|
||||||
|
<select id="not-type" name="type" required defaultValue="CORRECTIVE">
|
||||||
|
{WORK_ORDER_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{WORK_ORDER_TYPE_LABELS[t]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="not-asset">Équipement *</label>
|
||||||
|
<select id="not-asset" name="assetId" required defaultValue={params.get('asset') ?? ''}>
|
||||||
|
<option value="" disabled>Choisir…</option>
|
||||||
|
{(assets ?? []).map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.reference} — {a.siteName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="not-prio">Priorité *</label>
|
||||||
|
<select id="not-prio" name="priority" defaultValue="MEDIUM" disabled={personneBloquee}>
|
||||||
|
{(['NONE', 'LOW', 'MEDIUM', 'HIGH'] as const).map((p) => (
|
||||||
|
<option key={p} value={p}>{WORK_ORDER_PRIORITY_LABELS[p]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="not-echeance">Échéance</label>
|
||||||
|
<input id="not-echeance" name="dueDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div className="champ pleine-l">
|
||||||
|
<label htmlFor="not-desc">Description</label>
|
||||||
|
<input id="not-desc" name="description" placeholder="Constat, contexte, consignes…" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="not-assigne">Assigner à</label>
|
||||||
|
<select id="not-assigne" name="assigneeId" defaultValue="">
|
||||||
|
<option value="">—</option>
|
||||||
|
{techniciens.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.displayName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="gros-toggle" style={{ marginTop: 14 }}>
|
||||||
|
<b>Personne bloquée dans la cabine ?</b>
|
||||||
|
<span style={{ marginLeft: 6, fontSize: 12, color: 'var(--encre-2)' }}>
|
||||||
|
passe l'OT en urgence absolue (bandeau + tête de liste)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="interrupteur"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={personneBloquee}
|
||||||
|
aria-label="Personne bloquée"
|
||||||
|
onClick={() => setPersonneBloquee((v) => !v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{creation.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button onClick={() => navigate('/ot')}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||||
|
Créer l'OT (statut Ouvert)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/web/src/pages/ordres-travail.tsx
Normal file
126
apps/web/src/pages/ordres-travail.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
WORK_ORDER_STATUSES,
|
||||||
|
WORK_ORDER_TYPE_LABELS,
|
||||||
|
WORK_ORDER_TYPES,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
type WorkOrderType,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { useWorkOrders } from '@/api/exploitation';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Avatar } from '@/components/avatar';
|
||||||
|
import { PrioriteOT, StatutOT } from '@/components/chips-ot';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { formatDateFr } from '@/lib/lieux';
|
||||||
|
|
||||||
|
/** Écran 3 validé R0 : table dense « bureau », strie rouge sur l'urgence. */
|
||||||
|
export default function PageOrdresTravail() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: workOrders } = useWorkOrders();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [statut, setStatut] = useState<'actifs' | 'tous' | WorkOrderStatus>('actifs');
|
||||||
|
const [type, setType] = useState<'' | WorkOrderType>('');
|
||||||
|
|
||||||
|
const tous = workOrders ?? [];
|
||||||
|
const visibles = tous.filter((w) => {
|
||||||
|
if (statut === 'actifs' && (w.status === 'DONE' || w.status === 'CANCELLED')) return false;
|
||||||
|
if (statut !== 'actifs' && statut !== 'tous' && w.status !== statut) return false;
|
||||||
|
if (type && w.type !== type) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Ordres de travail</h1>
|
||||||
|
<div className="actions">
|
||||||
|
{can('WORK_ORDERS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => navigate('/ot/nouveau')}>
|
||||||
|
+ Nouvel OT
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filtres">
|
||||||
|
<select
|
||||||
|
aria-label="Filtrer par statut"
|
||||||
|
value={statut}
|
||||||
|
onChange={(e) => setStatut(e.target.value as typeof statut)}
|
||||||
|
>
|
||||||
|
<option value="actifs">Statut : actifs</option>
|
||||||
|
<option value="tous">Statut : tous</option>
|
||||||
|
{WORK_ORDER_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>{WORK_ORDER_STATUS_LABELS[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="Filtrer par type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as '' | WorkOrderType)}
|
||||||
|
>
|
||||||
|
<option value="">Type : tous</option>
|
||||||
|
{WORK_ORDER_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{WORK_ORDER_TYPE_LABELS[t]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="num" style={{ color: 'var(--encre-3)', fontSize: 12.5, marginLeft: 'auto' }}>
|
||||||
|
{visibles.length} résultats
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>N°</th>
|
||||||
|
<th>Objet</th>
|
||||||
|
<th>Équipement · Site</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Priorité</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th>Assignés</th>
|
||||||
|
<th>Échéance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibles.map((w) => (
|
||||||
|
<tr
|
||||||
|
key={w.id}
|
||||||
|
className={w.priority === 'PERSON_TRAPPED' ? 'ligne-bloque' : undefined}
|
||||||
|
onClick={() => navigate(`/ot/${w.id}`)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<td className="ref num">{w.reference}</td>
|
||||||
|
<td>{w.priority === 'PERSON_TRAPPED' ? <b>{w.title}</b> : w.title}</td>
|
||||||
|
<td>
|
||||||
|
Asc. {w.assetReference}
|
||||||
|
<span className="sous">{w.siteName}</span>
|
||||||
|
</td>
|
||||||
|
<td>{WORK_ORDER_TYPE_LABELS[w.type]}</td>
|
||||||
|
<td><PrioriteOT priorite={w.priority} /></td>
|
||||||
|
<td><StatutOT statut={w.status} /></td>
|
||||||
|
<td>
|
||||||
|
{w.assignees.length ? (
|
||||||
|
<div className="avatars">
|
||||||
|
{w.assignees.map((a) => (
|
||||||
|
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{w.priority === 'PERSON_TRAPPED' ? <b>immédiat</b> : formatDateFr(w.dueDate)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
203
apps/web/src/pages/portail.tsx
Normal file
203
apps/web/src/pages/portail.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import type { PortalRequestStatus } from '@siop/shared';
|
||||||
|
import { api } from '@/api/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { IcoAscenseurs } from '@/components/nav-icons';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Écran 6 validé R0 : Karim (gardien, aucune formation, aucun compte) arrive
|
||||||
|
* ici en scannant le QR. Équipement prérempli, UN choix critique, suivi sans
|
||||||
|
* jargon. Ses signalements vivent sur SON téléphone (référence + jeton).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CLE_SUIVIS = 'siop.portail.suivis';
|
||||||
|
interface Suivi { reference: string; token: string; }
|
||||||
|
|
||||||
|
const lireSuivis = (): Suivi[] => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(CLE_SUIVIS) ?? '[]') as Suivi[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const ajouterSuivi = (s: Suivi) =>
|
||||||
|
localStorage.setItem(CLE_SUIVIS, JSON.stringify([s, ...lireSuivis()].slice(0, 10)));
|
||||||
|
|
||||||
|
export default function PagePortail() {
|
||||||
|
const { reference } = useParams<{ reference: string }>();
|
||||||
|
const [suivis, setSuivis] = useState<Suivi[]>(lireSuivis);
|
||||||
|
const [personneBloquee, setPersonneBloquee] = useState(false);
|
||||||
|
const [envoye, setEnvoye] = useState(false);
|
||||||
|
const [erreur, setErreur] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: asset, isError: refInconnue } = useQuery({
|
||||||
|
queryKey: ['portal-asset', reference],
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data, response } = await api.GET('/portal/assets/{reference}', {
|
||||||
|
params: { path: { reference: reference! } },
|
||||||
|
});
|
||||||
|
if (!data) throw new Error(String(response.status));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const surEnvoi = async (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErreur(null);
|
||||||
|
const form = e.currentTarget;
|
||||||
|
const description = String(new FormData(form).get('description') ?? '').trim();
|
||||||
|
const { data, response } = await api.POST('/portal/requests', {
|
||||||
|
body: {
|
||||||
|
assetReference: reference!,
|
||||||
|
description,
|
||||||
|
isPersonTrapped: personneBloquee,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!data) {
|
||||||
|
setErreur(
|
||||||
|
response.status === 429
|
||||||
|
? 'Trop de signalements envoyés — réessayez dans une minute.'
|
||||||
|
: 'Envoi impossible — réessayez ou appelez le numéro affiché en cabine.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ajouterSuivi({ reference: data.reference, token: data.publicToken });
|
||||||
|
setSuivis(lireSuivis());
|
||||||
|
setPersonneBloquee(false);
|
||||||
|
setEnvoye(true);
|
||||||
|
form.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: 'var(--fond)', minHeight: '100vh' }}>
|
||||||
|
<div className="portail">
|
||||||
|
<div className="logo" style={{ color: 'var(--encre)', padding: 0 }}>
|
||||||
|
<span className="marque" aria-hidden="true">▲<br />▼</span>
|
||||||
|
<div>
|
||||||
|
<b style={{ fontSize: 19 }}>SIOP</b>
|
||||||
|
<small style={{ color: 'var(--encre-2)' }}>
|
||||||
|
Signalement{asset ? ` — ${asset.siteName}` : ''}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{refInconnue ? (
|
||||||
|
<div className="carte">
|
||||||
|
<p className="erreur-form">
|
||||||
|
Ce QR ne correspond à aucun appareil connu. Vérifiez l'étiquette ou
|
||||||
|
appelez le numéro affiché en cabine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="carte flex flex-col gap-3" onSubmit={surEnvoi}>
|
||||||
|
<div className="bandeau-detecte">
|
||||||
|
<span style={{ color: 'var(--primaire)', width: 18, height: 18, flex: 'none' }}>
|
||||||
|
<IcoAscenseurs />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<b>Ascenseur {reference}{asset ? ` — ${asset.locationName}` : ''}</b>{' '}
|
||||||
|
<span style={{ color: 'var(--encre-2)' }}>(détecté par le QR)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="gros-toggle">
|
||||||
|
<b>Une personne est-elle bloquée dans la cabine ?</b>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="interrupteur"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={personneBloquee}
|
||||||
|
aria-label="Personne bloquée"
|
||||||
|
onClick={() => setPersonneBloquee((v) => !v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="po-desc">Décrivez le problème</label>
|
||||||
|
<input
|
||||||
|
id="po-desc"
|
||||||
|
name="description"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="zone-photo">📷 Ajouter une photo (bientôt disponible)</div>
|
||||||
|
{erreur ? <p className="erreur-form" role="alert">{erreur}</p> : null}
|
||||||
|
{envoye ? (
|
||||||
|
<p style={{ color: 'var(--succes)', fontWeight: 600, fontSize: 13 }} role="status">
|
||||||
|
✓ Signalement envoyé — l'équipe SPELEV est prévenue.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" variant="prim" className="justify-center" style={{ padding: 11 }}>
|
||||||
|
Envoyer le signalement
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{suivis.length > 0 ? (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Mes signalements</h2>
|
||||||
|
{suivis.map((s) => (
|
||||||
|
<LigneSuivi key={s.reference} suivi={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LigneSuivi({ suivi }: { suivi: Suivi }) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
queryKey: ['portal-status', suivi.reference],
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await api.GET('/portal/requests/{reference}/{token}', {
|
||||||
|
params: { path: { reference: suivi.reference, token: suivi.token } },
|
||||||
|
});
|
||||||
|
return data ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!data) return null;
|
||||||
|
return (
|
||||||
|
<div className="item-liste" style={{ alignItems: 'flex-start' }}>
|
||||||
|
<div>
|
||||||
|
<b>{data.description}</b>
|
||||||
|
<span className="sous num">
|
||||||
|
{data.reference} ·{' '}
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(data.createdAt))}
|
||||||
|
</span>
|
||||||
|
{data.status === 'REJECTED' ? (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--encre-2)', marginTop: 4 }}>
|
||||||
|
Sans suite : {data.rejectionReason ?? 'voir avec le gestionnaire'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Etapes status={data} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Etapes({ status }: { status: PortalRequestStatus }) {
|
||||||
|
const franchi = (etape: 'RECEIVED' | 'IN_PROGRESS' | 'RESOLVED') => {
|
||||||
|
const ordre = ['RECEIVED', 'IN_PROGRESS', 'RESOLVED'];
|
||||||
|
return ordre.indexOf(status.step) >= ordre.indexOf(etape);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="suivi-simple">
|
||||||
|
<span className={franchi('RECEIVED') ? 'etape fait' : 'etape'}>✓ Reçu</span>
|
||||||
|
<span className="lien-e" />
|
||||||
|
<span className={franchi('IN_PROGRESS') ? 'etape fait' : 'etape'}>
|
||||||
|
{franchi('IN_PROGRESS') ? '✓ ' : ''}Intervention
|
||||||
|
</span>
|
||||||
|
<span className="lien-e" />
|
||||||
|
<span className={franchi('RESOLVED') ? 'etape fait' : 'etape'}>
|
||||||
|
{franchi('RESOLVED') ? '✓ ' : ''}Résolu
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/web/src/pages/preventif.tsx
Normal file
166
apps/web/src/pages/preventif.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import type { PreventiveGenerationResult } from '@siop/shared';
|
||||||
|
import {
|
||||||
|
useCreateTaskTemplate,
|
||||||
|
useGeneratePreventive,
|
||||||
|
usePreventiveStatus,
|
||||||
|
useTaskTemplates,
|
||||||
|
useUpdateTaskTemplate,
|
||||||
|
} from '@/api/exploitation';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const MOIS_FR = [
|
||||||
|
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||||
|
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||||
|
];
|
||||||
|
const libelleMois = (period: string) => {
|
||||||
|
const [annee, mois] = period.split('-').map(Number);
|
||||||
|
return `${MOIS_FR[mois! - 1]} ${annee}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Écran 3 des maquettes R2 : gabarits à périodicité + génération idempotente. */
|
||||||
|
export default function PagePreventif() {
|
||||||
|
const { data: status } = usePreventiveStatus();
|
||||||
|
const { data: templates } = useTaskTemplates();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const generation = useGeneratePreventive();
|
||||||
|
const creation = useCreateTaskTemplate();
|
||||||
|
const maj = useUpdateTaskTemplate();
|
||||||
|
const [resultat, setResultat] = useState<PreventiveGenerationResult | null>(null);
|
||||||
|
const [nouveau, setNouveau] = useState({ label: '', periodMonths: 1 });
|
||||||
|
|
||||||
|
const peutGenerer = can('WORK_ORDERS', 'create');
|
||||||
|
const peutAdministrer = can('SETTINGS', 'edit');
|
||||||
|
|
||||||
|
const ajouterGabarit = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!nouveau.label.trim()) return;
|
||||||
|
creation.mutate(
|
||||||
|
{ label: nouveau.label.trim(), periodMonths: nouveau.periodMonths },
|
||||||
|
{ onSuccess: () => setNouveau({ label: '', periodMonths: 1 }) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Préventif</h1>
|
||||||
|
<div className="actions">
|
||||||
|
{peutGenerer && status ? (
|
||||||
|
<Button
|
||||||
|
variant="prim"
|
||||||
|
disabled={generation.isPending}
|
||||||
|
onClick={() => generation.mutate({}, { onSuccess: setResultat })}
|
||||||
|
>
|
||||||
|
Générer la grille de {libelleMois(status.month)}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status ? (
|
||||||
|
<div className="kpis">
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Grilles générées</div>
|
||||||
|
<div className="val num">{status.generated} / {status.assetsUnderContract}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Terminées</div>
|
||||||
|
<div className="val num">{status.done}</div>
|
||||||
|
</div>
|
||||||
|
<div className={status.late ? 'kpi alarme' : 'kpi'}>
|
||||||
|
<div className="lib">En retard</div>
|
||||||
|
<div className="val num">{status.late}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Premiers contrôles</div>
|
||||||
|
<div className="val num">{status.firstControls}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{resultat ? (
|
||||||
|
<div className="carte gen-info">
|
||||||
|
<span style={{ fontSize: 13 }}>
|
||||||
|
✓ Génération {libelleMois(resultat.month)} : <b className="num">{resultat.created}</b> OT créés
|
||||||
|
{resultat.firstControls ? <> (dont <b className="num">{resultat.firstControls}</b> premiers contrôles)</> : null},{' '}
|
||||||
|
<b className="num">{resultat.skipped}</b> déjà en place — <b>regénérer ne double rien</b>.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{generation.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{generation.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tâche du gabarit</th>
|
||||||
|
<th>Organe visé</th>
|
||||||
|
<th>Périodicité</th>
|
||||||
|
{peutAdministrer ? <th></th> : null}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(templates ?? []).map((t) => (
|
||||||
|
<tr key={t.id} style={!t.isActive ? { color: 'var(--encre-3)' } : undefined}>
|
||||||
|
<td>{t.isRegulatory ? <b>{t.label}</b> : t.label}{!t.isActive ? ' (désactivé)' : ''}</td>
|
||||||
|
<td>{t.componentTypeName ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={t.isRegulatory ? 'periode reglementaire' : 'periode'}>
|
||||||
|
{t.periodMonths === 1
|
||||||
|
? 'Tous les mois'
|
||||||
|
: `${t.periodMonths} mois${t.isRegulatory ? ' · réglementaire' : ''}`}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{peutAdministrer ? (
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-[12px]"
|
||||||
|
onClick={() => maj.mutate({ id: t.id, isActive: !t.isActive })}
|
||||||
|
>
|
||||||
|
{t.isActive ? 'Désactiver' : 'Réactiver'}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{peutAdministrer ? (
|
||||||
|
<form className="carte flex items-end gap-3" onSubmit={ajouterGabarit}>
|
||||||
|
<div className="champ" style={{ flex: 2 }}>
|
||||||
|
<label htmlFor="gab-label">Nouvelle tâche</label>
|
||||||
|
<input
|
||||||
|
id="gab-label"
|
||||||
|
placeholder="Ex. : Contrôle éclairage de gaine"
|
||||||
|
value={nouveau.label}
|
||||||
|
onChange={(e) => setNouveau((n) => ({ ...n, label: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="gab-periode">Périodicité (mois)</label>
|
||||||
|
<input
|
||||||
|
id="gab-periode"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
value={nouveau.periodMonths}
|
||||||
|
onChange={(e) => setNouveau((n) => ({ ...n, periodMonths: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>Ajouter</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||||
|
Le gabarit s'applique à tout appareil <b>sous contrat préventif</b>. Un appareil sans
|
||||||
|
historique reçoit un OT « <b>premier contrôle</b> » : toutes les tâches, quelle que soit
|
||||||
|
la périodicité. Les autres tâches tombent aux mois anniversaires de la mise en service.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,49 +1,206 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
WORK_ORDER_STATUS_LABELS,
|
||||||
|
WORK_ORDER_TYPE_LABELS,
|
||||||
|
type WorkOrderStatus,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation';
|
||||||
import { useMe } from '@/auth/use-auth';
|
import { useMe } from '@/auth/use-auth';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Avatar } from '@/components/avatar';
|
||||||
|
import { PrioriteOT, StatutOT } from '@/components/chips-ot';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
/** R0 : coquille seulement — le vrai tableau de bord (KPI, urgences,
|
const ST_COULEURS: Record<string, string> = {
|
||||||
* graphiques) arrive avec l'exploitation (R2), conformément au plan. */
|
OPEN: 'var(--st-ouvert)',
|
||||||
|
IN_PROGRESS: 'var(--st-encours)',
|
||||||
|
ON_HOLD: 'var(--st-attente)',
|
||||||
|
DONE: 'var(--st-termine)',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Écran 2 validé R0 — alimenté par les données réelles de R2.
|
||||||
|
* (Coûts → R3 ; pannes par organe → analytics R3.) */
|
||||||
export default function PageTableauDeBord() {
|
export default function PageTableauDeBord() {
|
||||||
const { data: me } = useMe();
|
const navigate = useNavigate();
|
||||||
|
const { can, chargé } = usePermissions();
|
||||||
|
const peutVoirOT = can('WORK_ORDERS', 'view');
|
||||||
|
const { data: workOrders } = useWorkOrders(peutVoirOT);
|
||||||
|
const { data: requests } = useRequests();
|
||||||
|
const { data: preventif } = usePreventiveStatus(peutVoirOT);
|
||||||
|
|
||||||
|
if (!chargé) return null;
|
||||||
|
if (!peutVoirOT) return <AccueilSansExploitation />;
|
||||||
|
|
||||||
|
const tous = workOrders ?? [];
|
||||||
|
const urgences = tous.filter(
|
||||||
|
(w) => w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
|
||||||
|
);
|
||||||
|
const ouverts = tous.filter((w) => w.status !== 'DONE' && w.status !== 'CANCELLED');
|
||||||
|
const enRetard = ouverts.filter((w) => w.dueDate && new Date(w.dueDate) < new Date());
|
||||||
|
const aTraiter = (requests ?? []).filter((r) => r.status === 'RECEIVED');
|
||||||
|
const parStatut = (['OPEN', 'IN_PROGRESS', 'ON_HOLD', 'DONE'] as WorkOrderStatus[]).map(
|
||||||
|
(statut) => ({
|
||||||
|
statut,
|
||||||
|
n: tous.filter((w) => w.status === statut).length,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const max = Math.max(1, ...parStatut.map((s) => s.n));
|
||||||
|
const recents = tous.slice(0, 5);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="entete-page">
|
<div className="entete-page"><h1>Tableau de bord</h1></div>
|
||||||
<h1>Tableau de bord</h1>
|
{urgences.map((u) => (
|
||||||
</div>
|
<div className="bandeau-urgence" key={u.id}>
|
||||||
<div className="carte">
|
<span className="point-pulse" />
|
||||||
<h2>Fondations (R0)</h2>
|
<div>
|
||||||
<p>
|
<b>{u.title} — Ascenseur {u.assetReference}</b> ({u.siteName})
|
||||||
Bienvenue{me ? ` ${me.displayName}` : ''} — vous êtes connecté avec le
|
<br />
|
||||||
rôle <b>{me?.role.name}</b>. Cette coquille valide l'authentification,
|
<span className="depuis">
|
||||||
la bascule de rôle en un clic et le bi-thème ; les indicateurs
|
{u.reference} · signalé{' '}
|
||||||
d'exploitation arriveront avec la release R2.
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(u.createdAt))}
|
||||||
</p>
|
{u.assignees.length === 0 ? ' · aucune intervention en cours' : ''}
|
||||||
<p className="mt-2 text-encre-2">
|
|
||||||
La référence vivante du design est consultable sur la page{' '}
|
|
||||||
<Link to="/design" className="font-semibold text-primaire">
|
|
||||||
/design
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{me ? (
|
|
||||||
<div className="carte">
|
|
||||||
<h2>Vos droits (matrice en base)</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{me.permissions
|
|
||||||
.filter((p) => p.canView)
|
|
||||||
.map((p) => (
|
|
||||||
<span key={p.objectCategory} className="st ouvert">
|
|
||||||
{p.objectCategory}
|
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
|
<Button variant="prim" onClick={() => navigate(`/ot/${u.id}`)}>
|
||||||
|
Ouvrir l'OT d'urgence
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{me.permissions.every((p) => !p.canView) ? (
|
<div className="kpis">
|
||||||
<p className="text-encre-2">Ce rôle ne voit aucune catégorie d'objets.</p>
|
<div className="kpi">
|
||||||
) : null}
|
<div className="lib">OT ouverts</div>
|
||||||
|
<div className="val num">{ouverts.length}</div>
|
||||||
|
<div className="delta neutre">
|
||||||
|
dont {tous.filter((w) => w.status === 'IN_PROGRESS').length} en cours
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={enRetard.length ? 'kpi alarme' : 'kpi'}>
|
||||||
|
<div className="lib">En retard</div>
|
||||||
|
<div className="val num">{enRetard.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Préventif · {preventif ? mois(preventif.month) : '…'}</div>
|
||||||
|
<div className="val num">
|
||||||
|
{preventif && preventif.generated > 0
|
||||||
|
? `${Math.round((preventif.done / preventif.generated) * 100)} %`
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
<div className="delta bon">
|
||||||
|
{preventif ? `${preventif.done} / ${preventif.generated} grilles` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Demandes à traiter</div>
|
||||||
|
<div className="val num">{aTraiter.length}</div>
|
||||||
|
<div className="delta neutre">
|
||||||
|
{aTraiter[0]
|
||||||
|
? `la + ancienne : ${new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(aTraiter[aTraiter.length - 1]!.createdAt))}`
|
||||||
|
: 'file vide'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="carte">
|
||||||
|
<h2>OT par statut</h2>
|
||||||
|
<div className="barres-h flex flex-col gap-2">
|
||||||
|
{parStatut.map(({ statut, n }) => (
|
||||||
|
<div className="ligne-b" key={statut}>
|
||||||
|
<span>{WORK_ORDER_STATUS_LABELS[statut]}</span>
|
||||||
|
<div className="fond-b">
|
||||||
|
<div
|
||||||
|
className="rempli"
|
||||||
|
style={{ width: `${(n / max) * 100}%`, background: ST_COULEURS[statut] }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="valeur num">{n}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||||
|
<h2>Pannes par organe · 12 mois</h2>
|
||||||
|
<p className="text-encre-3">
|
||||||
|
Ce graphique s'alimentera des bilans codés — il arrive avec les analytics (R3).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Interventions récentes</h2>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>N°</th><th>Objet</th><th>Équipement</th><th>Type</th><th>Priorité</th><th>Statut</th><th>Assignés</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recents.map((w) => (
|
||||||
|
<tr
|
||||||
|
key={w.id}
|
||||||
|
className={w.priority === 'PERSON_TRAPPED' ? 'ligne-bloque' : undefined}
|
||||||
|
onClick={() => navigate(`/ot/${w.id}`)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<td className="ref num">{w.reference}</td>
|
||||||
|
<td>{w.title}</td>
|
||||||
|
<td>
|
||||||
|
Asc. {w.assetReference}
|
||||||
|
<span className="sous">{w.siteName}</span>
|
||||||
|
</td>
|
||||||
|
<td>{WORK_ORDER_TYPE_LABELS[w.type]}</td>
|
||||||
|
<td><PrioriteOT priorite={w.priority} /></td>
|
||||||
|
<td><StatutOT statut={w.status} /></td>
|
||||||
|
<td>
|
||||||
|
{w.assignees.length ? (
|
||||||
|
<div className="avatars">
|
||||||
|
{w.assignees.map((a) => (
|
||||||
|
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOIS_FR = [
|
||||||
|
'janvier', 'février', 'mars', 'avril', 'mai', 'juin',
|
||||||
|
'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre',
|
||||||
|
];
|
||||||
|
const mois = (period: string) => MOIS_FR[Number(period.split('-')[1]) - 1];
|
||||||
|
|
||||||
|
/** Accueil des rôles sans exploitation (Demandeur) : ses demandes. */
|
||||||
|
function AccueilSansExploitation() {
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const { data: requests } = useRequests();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page"><h1>Mes signalements</h1></div>
|
||||||
|
<div className="carte">
|
||||||
|
<p>
|
||||||
|
Bienvenue{me ? ` ${me.displayName}` : ''} — retrouvez ici vos signalements
|
||||||
|
et leur avancement.
|
||||||
|
</p>
|
||||||
|
{can('REQUESTS', 'view') ? (
|
||||||
|
<p className="mt-2">
|
||||||
|
<Link to="/demandes" className="font-semibold text-primaire">
|
||||||
|
Voir mes demandes ({(requests ?? []).length})
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
755
docs/02-design/maquettes/maquette-r2.html
Normal file
755
docs/02-design/maquettes/maquette-r2.html
Normal file
@@ -0,0 +1,755 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SIOP V2 — Maquettes HD · R2 Exploitation</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
SIOP V2 — Maquettes HD de la release R2 « Exploitation » (design-first).
|
||||||
|
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE APPLICATIF R2 (principe n°1).
|
||||||
|
Complète les écrans DÉJÀ VALIDÉS en R0 (maquette-web.html : tableau de bord,
|
||||||
|
liste OT, fiche OT avec bilan codé, portail demandeur) — non redessinés ici.
|
||||||
|
CSS répliquée des maquettes validées ; ajouts R2 dans le dernier bloc <style>.
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
/* ═══ Tokens (répliques de ../tokens.css) ═══ */
|
||||||
|
:root{
|
||||||
|
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
|
||||||
|
--safran:#dd8a0b;--safran-doux:#fdf3e3;
|
||||||
|
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
|
||||||
|
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
|
||||||
|
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
|
||||||
|
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
|
||||||
|
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
|
||||||
|
--st-annule:#68788f;--st-annule-fond:#edf0f4;
|
||||||
|
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
|
||||||
|
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
|
||||||
|
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
|
||||||
|
--graf-1:#3b69d6;--graf-2:#dd8a0b;--graf-3:#1f9e8e;--graf-4:#8a63d2;--graf-grille:#e6ebf2;
|
||||||
|
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
|
||||||
|
--police-ui:"Manrope","Avenir Next","Segoe UI Variable",system-ui,sans-serif;
|
||||||
|
--rayon:10px;--rayon-controle:8px;--ombre-menu:0 8px 24px rgb(16 30 54/.14);
|
||||||
|
--transition:140ms ease-out;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme:dark){:root{
|
||||||
|
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
|
||||||
|
--safran:#e89a1f;--safran-doux:#33270f;
|
||||||
|
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
|
||||||
|
--bordure:#2b3850;--bordure-forte:#3d4d6b;
|
||||||
|
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
|
||||||
|
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
|
||||||
|
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
|
||||||
|
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
|
||||||
|
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
|
||||||
|
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
|
||||||
|
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
|
||||||
|
--graf-1:#5580e4;--graf-2:#c7820a;--graf-3:#26a896;--graf-4:#9673de;--graf-grille:#24304a;
|
||||||
|
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
|
||||||
|
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
|
||||||
|
}}
|
||||||
|
:root[data-theme="light"]{
|
||||||
|
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
|
||||||
|
--safran:#dd8a0b;--safran-doux:#fdf3e3;
|
||||||
|
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
|
||||||
|
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
|
||||||
|
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
|
||||||
|
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
|
||||||
|
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
|
||||||
|
--st-annule:#68788f;--st-annule-fond:#edf0f4;
|
||||||
|
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
|
||||||
|
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
|
||||||
|
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
|
||||||
|
--graf-1:#3b69d6;--graf-2:#dd8a0b;--graf-3:#1f9e8e;--graf-4:#8a63d2;--graf-grille:#e6ebf2;
|
||||||
|
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
|
||||||
|
--ombre-menu:0 8px 24px rgb(16 30 54/.14);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"]{
|
||||||
|
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
|
||||||
|
--safran:#e89a1f;--safran-doux:#33270f;
|
||||||
|
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
|
||||||
|
--bordure:#2b3850;--bordure-forte:#3d4d6b;
|
||||||
|
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
|
||||||
|
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
|
||||||
|
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
|
||||||
|
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
|
||||||
|
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
|
||||||
|
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
|
||||||
|
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
|
||||||
|
--graf-1:#5580e4;--graf-2:#c7820a;--graf-3:#26a896;--graf-4:#9673de;--graf-grille:#24304a;
|
||||||
|
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
|
||||||
|
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Base ═══ */
|
||||||
|
*{box-sizing:border-box;margin:0}
|
||||||
|
html,body{background:var(--fond)}
|
||||||
|
body{font-family:var(--police-ui);color:var(--encre);font-size:14px;line-height:1.5}
|
||||||
|
button{font:inherit;cursor:pointer;border:0;background:none;color:inherit}
|
||||||
|
button:focus-visible,a:focus-visible{outline:2px solid var(--primaire);outline-offset:2px;border-radius:4px}
|
||||||
|
table{border-collapse:collapse;font-variant-numeric:tabular-nums}
|
||||||
|
.num{font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
/* ═══ Barre de revue (outil maquette, pas l'app) ═══ */
|
||||||
|
.revue{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:12px;flex-wrap:wrap;
|
||||||
|
padding:10px 16px;background:var(--nav-fond);color:var(--nav-encre);font-size:13px}
|
||||||
|
.revue b{color:var(--nav-encre-vif);letter-spacing:.02em}
|
||||||
|
.revue .onglets{display:flex;gap:4px;flex-wrap:wrap}
|
||||||
|
.revue .onglets button{padding:6px 10px;border-radius:6px;color:var(--nav-encre)}
|
||||||
|
.revue .onglets button:hover{background:rgb(255 255 255/.08);color:var(--nav-encre-vif)}
|
||||||
|
.revue .onglets button[aria-selected="true"]{background:rgb(255 255 255/.12);color:var(--nav-encre-vif);
|
||||||
|
box-shadow:inset 3px 0 0 var(--safran)}
|
||||||
|
.revue .droite{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||||
|
.revue .theme{padding:6px 10px;border:1px solid var(--nav-bordure);border-radius:6px}
|
||||||
|
.note-ecran{max-width:1280px;margin:14px auto 0;padding:0 20px;color:var(--encre-2);font-size:13px}
|
||||||
|
.note-ecran b{color:var(--encre)}
|
||||||
|
|
||||||
|
/* ═══ Coquille applicative ═══ */
|
||||||
|
.ecran{display:none;max-width:1280px;margin:14px auto 40px;padding:0 20px}
|
||||||
|
.ecran.actif{display:block}
|
||||||
|
.app{display:grid;grid-template-columns:232px 1fr;min-height:720px;border:1px solid var(--bordure);
|
||||||
|
border-radius:14px;overflow:hidden;background:var(--fond)}
|
||||||
|
.app.pleine{grid-template-columns:1fr}
|
||||||
|
|
||||||
|
/* Sidebar (toujours sombre — identité) */
|
||||||
|
.nav{background:var(--nav-fond);color:var(--nav-encre);display:flex;flex-direction:column;padding:16px 0}
|
||||||
|
.logo{display:flex;align-items:center;gap:10px;padding:2px 18px 16px;color:var(--nav-encre-vif)}
|
||||||
|
.logo .marque{display:flex;flex-direction:column;line-height:.55;font-size:11px;color:var(--safran)}
|
||||||
|
.logo b{font-size:19px;letter-spacing:.04em}
|
||||||
|
.logo small{color:var(--nav-encre);font-weight:400;font-size:11px;display:block;margin-top:2px}
|
||||||
|
.nav .groupe{padding:12px 18px 4px;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:#6b7f9e}
|
||||||
|
.nav a{display:flex;align-items:center;gap:10px;padding:8px 18px;color:var(--nav-encre);
|
||||||
|
text-decoration:none;font-size:13.5px;border-left:3px solid transparent;transition:background var(--transition)}
|
||||||
|
.nav a:hover{background:rgb(255 255 255/.06);color:var(--nav-encre-vif)}
|
||||||
|
.nav a.actif{background:rgb(255 255 255/.09);color:var(--nav-encre-vif);border-left-color:var(--safran)}
|
||||||
|
.nav a .badge-nav{margin-left:auto;background:var(--prio-bloque);color:#fff;font-size:11px;
|
||||||
|
padding:1px 7px;border-radius:999px;font-weight:700}
|
||||||
|
.nav svg{width:17px;height:17px;stroke-width:1.75;flex:none}
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
.contenu{display:flex;flex-direction:column;min-width:0}
|
||||||
|
.topbar{display:flex;align-items:center;gap:12px;padding:12px 24px;background:var(--surface);
|
||||||
|
border-bottom:1px solid var(--bordure)}
|
||||||
|
.recherche{flex:1;max-width:420px;display:flex;align-items:center;gap:8px;background:var(--surface-2);
|
||||||
|
border:1px solid var(--bordure);border-radius:8px;padding:7px 12px;color:var(--encre-3);font-size:13px}
|
||||||
|
.recherche svg{width:15px;height:15px}
|
||||||
|
.chip-urgence{display:flex;align-items:center;gap:7px;background:var(--prio-bloque-fond);
|
||||||
|
color:var(--prio-bloque);border:1px solid color-mix(in srgb,var(--prio-bloque) 30%,transparent);
|
||||||
|
padding:6px 12px;border-radius:999px;font-weight:700;font-size:12.5px}
|
||||||
|
.point-pulse{width:8px;height:8px;border-radius:50%;background:var(--prio-bloque);animation:pulse 1.4s ease-out infinite}
|
||||||
|
@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--prio-bloque) 45%,transparent)}
|
||||||
|
70%{box-shadow:0 0 0 8px transparent}100%{box-shadow:0 0 0 0 transparent}}
|
||||||
|
@media (prefers-reduced-motion:reduce){.point-pulse{animation:none}}
|
||||||
|
.compte{margin-left:auto;display:flex;align-items:center;gap:10px}
|
||||||
|
.chip-demo{font-size:11px;font-weight:700;letter-spacing:.05em;color:var(--safran);
|
||||||
|
background:var(--safran-doux);padding:3px 9px;border-radius:999px;border:1px dashed var(--safran)}
|
||||||
|
.avatar{width:30px;height:30px;border-radius:50%;background:var(--primaire);color:#fff;display:flex;
|
||||||
|
align-items:center;justify-content:center;font-size:12px;font-weight:700;flex:none}
|
||||||
|
.compte .qui{line-height:1.15;font-size:12.5px}
|
||||||
|
.compte .qui small{color:var(--encre-2)}
|
||||||
|
|
||||||
|
/* Corps de page */
|
||||||
|
.page{padding:20px 24px;display:flex;flex-direction:column;gap:16px}
|
||||||
|
.entete-page{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||||
|
.entete-page h1{font-size:21px;font-weight:700;letter-spacing:-.01em;text-wrap:balance}
|
||||||
|
.entete-page .actions{margin-left:auto;display:flex;gap:8px}
|
||||||
|
.filajout{color:var(--encre-3);font-size:12.5px}
|
||||||
|
.filajout a{color:var(--encre-2);text-decoration:none}
|
||||||
|
|
||||||
|
/* Boutons */
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:7px;padding:8px 14px;border-radius:8px;font-size:13.5px;
|
||||||
|
font-weight:600;border:1px solid var(--bordure-forte);background:var(--surface);color:var(--encre);
|
||||||
|
transition:filter var(--transition)}
|
||||||
|
.btn:hover{filter:brightness(.97)}
|
||||||
|
.btn.prim{background:var(--primaire);border-color:var(--primaire);color:#fff}
|
||||||
|
.btn.succes{background:var(--succes);border-color:var(--succes);color:#fff}
|
||||||
|
.btn.danger-l{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 40%,transparent);background:transparent}
|
||||||
|
.btn.ghost{border-color:transparent;background:transparent;color:var(--encre-2)}
|
||||||
|
.btn:disabled{opacity:.45;cursor:not-allowed}
|
||||||
|
.btn svg{width:15px;height:15px;stroke-width:2}
|
||||||
|
|
||||||
|
/* Cartes */
|
||||||
|
.carte{background:var(--surface);border:1px solid var(--bordure);border-radius:var(--rayon);padding:16px 18px}
|
||||||
|
.carte h2{font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--encre-2);margin-bottom:12px}
|
||||||
|
.grille-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
|
.grille-3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||||||
|
@media(max-width:900px){.grille-2,.grille-3{grid-template-columns:1fr}}
|
||||||
|
|
||||||
|
/* Tuiles KPI */
|
||||||
|
.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}
|
||||||
|
@media(max-width:1100px){.kpis{grid-template-columns:repeat(2,1fr)}}
|
||||||
|
.kpi{background:var(--surface);border:1px solid var(--bordure);border-radius:var(--rayon);padding:14px 16px}
|
||||||
|
.kpi .lib{font-size:11.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--encre-2);font-weight:600}
|
||||||
|
.kpi .val{font-size:27px;font-weight:700;letter-spacing:-.02em;margin-top:4px}
|
||||||
|
.kpi .delta{font-size:12px;font-weight:600;margin-top:2px}
|
||||||
|
.delta.bon{color:var(--succes)}.delta.mauvais{color:var(--danger)}.delta.neutre{color:var(--encre-3)}
|
||||||
|
.kpi.alarme{border-color:color-mix(in srgb,var(--danger) 45%,transparent)}
|
||||||
|
.kpi.alarme .val{color:var(--danger)}
|
||||||
|
.anneau{display:flex;align-items:center;gap:12px}
|
||||||
|
.anneau svg{flex:none}
|
||||||
|
|
||||||
|
/* Pastilles statut / priorité */
|
||||||
|
.st{display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:700}
|
||||||
|
.st::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
|
||||||
|
.st.ouvert{color:var(--st-ouvert);background:var(--st-ouvert-fond)}
|
||||||
|
.st.encours{color:var(--st-encours);background:var(--st-encours-fond)}
|
||||||
|
.st.attente{color:var(--st-attente);background:var(--st-attente-fond)}
|
||||||
|
.st.termine{color:var(--st-termine);background:var(--st-termine-fond)}
|
||||||
|
.st.annule{color:var(--st-annule);background:var(--st-annule-fond)}
|
||||||
|
.prio{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600}
|
||||||
|
.prio i{width:8px;height:8px;border-radius:50%;flex:none}
|
||||||
|
.prio.bloque{color:var(--prio-bloque)}.prio.bloque i{background:var(--prio-bloque);animation:pulse 1.4s ease-out infinite}
|
||||||
|
.prio.haute{color:var(--prio-haute)}.prio.haute i{background:var(--prio-haute)}
|
||||||
|
.prio.moyenne{color:var(--prio-moyenne)}.prio.moyenne i{background:var(--prio-moyenne)}
|
||||||
|
.prio.basse{color:var(--prio-basse)}.prio.basse i{background:var(--prio-basse)}
|
||||||
|
|
||||||
|
/* Bandeau urgence */
|
||||||
|
.bandeau-urgence{display:flex;align-items:center;gap:14px;background:var(--prio-bloque-fond);
|
||||||
|
border:1px solid color-mix(in srgb,var(--prio-bloque) 35%,transparent);border-left:4px solid var(--prio-bloque);
|
||||||
|
border-radius:var(--rayon);padding:12px 16px}
|
||||||
|
.bandeau-urgence b{color:var(--prio-bloque)}
|
||||||
|
.bandeau-urgence .depuis{color:var(--encre-2);font-size:12.5px}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table{width:100%;overflow-x:auto}
|
||||||
|
.table table{width:100%;font-size:13.5px}
|
||||||
|
.table th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);text-align:left;
|
||||||
|
padding:9px 12px;border-bottom:1px solid var(--bordure);background:var(--surface-2);white-space:nowrap}
|
||||||
|
.table td{padding:9px 12px;border-bottom:1px solid var(--bordure);vertical-align:middle}
|
||||||
|
.table tbody tr:hover{background:var(--primaire-doux)}
|
||||||
|
.table .ref{font-weight:700;color:var(--primaire)}
|
||||||
|
.table .sous{color:var(--encre-2);font-size:12px;display:block}
|
||||||
|
.ligne-bloque td:first-child{box-shadow:inset 3px 0 0 var(--prio-bloque)}
|
||||||
|
.avatars{display:flex}
|
||||||
|
.avatars .avatar{width:24px;height:24px;font-size:10px;border:2px solid var(--surface);margin-left:-6px}
|
||||||
|
.avatars .avatar:first-child{margin-left:0}
|
||||||
|
|
||||||
|
/* Filtres */
|
||||||
|
.filtres{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||||
|
.select{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--bordure-forte);
|
||||||
|
border-radius:8px;padding:7px 11px;font-size:13px;background:var(--surface);color:var(--encre-2)}
|
||||||
|
.select b{color:var(--encre);font-weight:600}
|
||||||
|
|
||||||
|
/* Graphiques */
|
||||||
|
.graf{display:flex;flex-direction:column;gap:10px}
|
||||||
|
.barres-h .ligne-b{display:grid;grid-template-columns:110px 1fr 40px;align-items:center;gap:10px;font-size:13px}
|
||||||
|
.barres-h .fond-b{background:var(--graf-grille);border-radius:4px;height:14px;overflow:hidden}
|
||||||
|
.barres-h .rempli{height:100%;border-radius:4px 0 0 4px}
|
||||||
|
.barres-h .valeur{text-align:right;font-weight:600}
|
||||||
|
.legende{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--encre-2)}
|
||||||
|
.legende span{display:inline-flex;align-items:center;gap:6px}
|
||||||
|
.legende i{width:9px;height:9px;border-radius:3px}
|
||||||
|
|
||||||
|
/* Connexion */
|
||||||
|
.conn{min-height:640px;display:flex;align-items:center;justify-content:center;background:
|
||||||
|
linear-gradient(160deg,var(--fond) 55%,color-mix(in srgb,var(--primaire) 7%,var(--fond)))}
|
||||||
|
.carte-conn{width:400px;max-width:94%;background:var(--surface);border:1px solid var(--bordure);
|
||||||
|
border-radius:14px;padding:32px;display:flex;flex-direction:column;gap:14px}
|
||||||
|
.carte-conn .logo{padding:0 0 4px;color:var(--encre)}
|
||||||
|
.carte-conn .logo small{color:var(--encre-2)}
|
||||||
|
.champ label{display:block;font-size:12.5px;font-weight:600;color:var(--encre-2);margin-bottom:5px}
|
||||||
|
.champ input{width:100%;padding:9px 12px;border:1px solid var(--bordure-forte);border-radius:8px;
|
||||||
|
font:inherit;background:var(--surface);color:var(--encre)}
|
||||||
|
.champ input::placeholder{color:var(--encre-3)}
|
||||||
|
.sep{display:flex;align-items:center;gap:10px;color:var(--encre-3);font-size:11.5px;
|
||||||
|
letter-spacing:.06em;text-transform:uppercase;margin-top:6px}
|
||||||
|
.sep::before,.sep::after{content:"";flex:1;height:1px;background:var(--bordure)}
|
||||||
|
.demo-liste{display:flex;flex-direction:column;gap:6px}
|
||||||
|
.demo-liste button{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--bordure);
|
||||||
|
border-radius:8px;background:var(--surface);text-align:left;transition:border-color var(--transition)}
|
||||||
|
.demo-liste button:hover{border-color:var(--primaire);background:var(--primaire-doux)}
|
||||||
|
.demo-liste .role{margin-left:auto;font-size:11.5px;color:var(--encre-2)}
|
||||||
|
|
||||||
|
/* Fiche : listes de définitions */
|
||||||
|
.infos{display:grid;grid-template-columns:130px 1fr;row-gap:9px;font-size:13.5px}
|
||||||
|
.infos dt{color:var(--encre-2)}
|
||||||
|
.infos dd{font-weight:500}
|
||||||
|
.infos a{color:var(--primaire);text-decoration:none;font-weight:600}
|
||||||
|
.chrono{list-style:none;display:flex;flex-direction:column;gap:0;font-size:13px}
|
||||||
|
.chrono li{position:relative;padding:0 0 16px 22px}
|
||||||
|
.chrono li::before{content:"";position:absolute;left:5px;top:5px;width:8px;height:8px;border-radius:50%;
|
||||||
|
background:var(--bordure-forte)}
|
||||||
|
.chrono li.fait::before{background:var(--primaire)}
|
||||||
|
.chrono li::after{content:"";position:absolute;left:8.5px;top:15px;bottom:-2px;width:1px;background:var(--bordure)}
|
||||||
|
.chrono li:last-child::after{display:none}
|
||||||
|
.chrono .quand{color:var(--encre-3);font-size:11.5px}
|
||||||
|
.check{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--bordure);font-size:13.5px}
|
||||||
|
.check:last-of-type{border-bottom:0}
|
||||||
|
.coche{width:19px;height:19px;border-radius:5px;border:1.5px solid var(--bordure-forte);flex:none;
|
||||||
|
display:flex;align-items:center;justify-content:center;color:#fff}
|
||||||
|
.coche.ok{background:var(--succes);border-color:var(--succes)}
|
||||||
|
.coche.na{background:var(--encre-3);border-color:var(--encre-3)}
|
||||||
|
.check .qui-fait{margin-left:auto;color:var(--encre-3);font-size:12px}
|
||||||
|
.total{display:flex;justify-content:space-between;font-weight:700;padding-top:10px;font-size:14px}
|
||||||
|
|
||||||
|
/* Bilan codé */
|
||||||
|
.bilan{display:grid;grid-template-columns:1fr 1fr;gap:10px 14px}
|
||||||
|
@media(max-width:900px){.bilan{grid-template-columns:1fr}}
|
||||||
|
.bilan .champ-b{font-size:12.5px}
|
||||||
|
.bilan .champ-b label{color:var(--encre-2);font-weight:600;display:block;margin-bottom:4px}
|
||||||
|
.bilan .champ-b label em{color:var(--danger);font-style:normal}
|
||||||
|
.faux-select{display:flex;justify-content:space-between;align-items:center;border:1px solid var(--bordure-forte);
|
||||||
|
border-radius:8px;padding:8px 11px;background:var(--surface);color:var(--encre)}
|
||||||
|
.faux-select.vide{color:var(--encre-3)}
|
||||||
|
|
||||||
|
/* Organes / documents */
|
||||||
|
.item-liste{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--bordure);font-size:13.5px}
|
||||||
|
.item-liste:last-child{border-bottom:0}
|
||||||
|
.item-liste .ico{width:34px;height:34px;border-radius:8px;background:var(--surface-2);display:flex;
|
||||||
|
align-items:center;justify-content:center;color:var(--encre-2);flex:none}
|
||||||
|
.item-liste .ico svg{width:16px;height:16px;stroke-width:1.75}
|
||||||
|
.item-liste .fin{margin-left:auto;display:flex;align-items:center;gap:10px;color:var(--encre-2);font-size:12.5px}
|
||||||
|
.qr{display:flex;gap:16px;align-items:center}
|
||||||
|
.qr svg{border:1px solid var(--bordure);border-radius:8px;background:#fff;padding:8px;flex:none}
|
||||||
|
|
||||||
|
/* Portail demandeur */
|
||||||
|
.portail{max-width:430px;margin:28px auto;display:flex;flex-direction:column;gap:14px}
|
||||||
|
.gros-toggle{display:flex;align-items:center;gap:12px;background:var(--prio-bloque-fond);
|
||||||
|
border:1px solid color-mix(in srgb,var(--prio-bloque) 30%,transparent);border-radius:10px;padding:12px 14px}
|
||||||
|
.gros-toggle b{color:var(--prio-bloque)}
|
||||||
|
.interrupteur{margin-left:auto;width:44px;height:24px;border-radius:999px;background:var(--bordure-forte);position:relative;flex:none}
|
||||||
|
.interrupteur::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff}
|
||||||
|
.zone-photo{border:1.5px dashed var(--bordure-forte);border-radius:10px;padding:18px;text-align:center;color:var(--encre-2);font-size:13px}
|
||||||
|
.suivi-simple{display:flex;align-items:center;gap:0;font-size:11.5px;color:var(--encre-3);margin-top:6px}
|
||||||
|
.suivi-simple .etape{display:flex;align-items:center;gap:6px}
|
||||||
|
.suivi-simple .etape.fait{color:var(--succes);font-weight:700}
|
||||||
|
.suivi-simple .lien-e{width:26px;height:1px;background:var(--bordure-forte);margin:0 6px}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
.tels{display:flex;gap:24px;justify-content:center;flex-wrap:wrap}
|
||||||
|
.tel{width:300px;border:1px solid var(--bordure-forte);border-radius:28px;overflow:hidden;background:var(--fond);
|
||||||
|
box-shadow:var(--ombre-menu)}
|
||||||
|
.tel .encoche{height:26px;background:var(--nav-fond);display:flex;justify-content:center;align-items:center}
|
||||||
|
.tel .encoche i{width:70px;height:7px;border-radius:999px;background:rgb(255 255 255/.18)}
|
||||||
|
.tel .ecran-tel{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:520px}
|
||||||
|
.tel h3{font-size:16px;font-weight:700;display:flex;align-items:center;gap:8px}
|
||||||
|
.tel h3 .sync{margin-left:auto;font-size:10.5px;font-weight:700;color:var(--st-termine);
|
||||||
|
background:var(--st-termine-fond);padding:2px 8px;border-radius:999px}
|
||||||
|
.tel h3 .sync.off{color:var(--st-attente);background:var(--st-attente-fond)}
|
||||||
|
.carte-ot-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:12px;padding:12px}
|
||||||
|
.carte-ot-tel .type-t{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);
|
||||||
|
font-weight:700;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
.carte-ot-tel b{display:block;margin:3px 0 2px;font-size:14.5px}
|
||||||
|
.carte-ot-tel .ou{color:var(--encre-2);font-size:12.5px}
|
||||||
|
.btn-tel{display:flex;justify-content:center;align-items:center;gap:8px;padding:12px;border-radius:10px;
|
||||||
|
font-weight:700;font-size:14.5px;background:var(--primaire);color:#fff;width:100%}
|
||||||
|
.btn-tel.vert{background:var(--succes)}
|
||||||
|
.btn-tel.gris{background:var(--bordure-forte);color:var(--surface)}
|
||||||
|
.paire-boutons{display:flex;gap:8px}
|
||||||
|
.btn-choix{flex:1;text-align:center;padding:9px;border:1.5px solid var(--bordure-forte);border-radius:9px;font-weight:600;font-size:13px}
|
||||||
|
.btn-choix.actif{border-color:var(--primaire);background:var(--primaire-doux);color:var(--primaire)}
|
||||||
|
.viseur{flex:1;min-height:330px;border-radius:12px;background:
|
||||||
|
radial-gradient(circle at 50% 45%, #2c3a52 0%, #131c2c 70%);position:relative;display:flex;
|
||||||
|
align-items:center;justify-content:center}
|
||||||
|
.viseur .cadre-qr{width:170px;height:170px;border-radius:14px;border:2.5px solid var(--safran);
|
||||||
|
box-shadow:0 0 0 2000px rgb(9 14 22/.45)}
|
||||||
|
.viseur p{position:absolute;bottom:14px;color:#dfe7f2;font-size:12.5px}
|
||||||
|
|
||||||
|
/* Design system : nuanciers */
|
||||||
|
.nuancier{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.puce-c{width:84px;font-size:10.5px;color:var(--encre-2)}
|
||||||
|
.puce-c i{display:block;height:40px;border-radius:8px;border:1px solid var(--bordure);margin-bottom:4px}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
/* ═══ Ajouts R1 (à reverser dans l'app après validation) ═══ */
|
||||||
|
/* Statuts d'ÉQUIPEMENT (distincts des statuts OT) */
|
||||||
|
.st.arret{color:var(--danger);background:var(--prio-bloque-fond)}
|
||||||
|
.st.maintenance{color:var(--st-encours);background:var(--st-encours-fond)}
|
||||||
|
|
||||||
|
/* Carte géographique (placeholder — Leaflet/OSM + PostGIS dans l'app) */
|
||||||
|
.geo{position:relative;border:1px solid var(--bordure);border-radius:var(--rayon);overflow:hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--graf-grille) 1px,transparent 1px),
|
||||||
|
linear-gradient(90deg,var(--graf-grille) 1px,transparent 1px),
|
||||||
|
linear-gradient(160deg,var(--surface-2),var(--surface));
|
||||||
|
background-size:34px 34px,34px 34px,100% 100%;min-height:320px}
|
||||||
|
.geo .voie{position:absolute;background:var(--bordure);border-radius:2px}
|
||||||
|
.geo .pin{position:absolute;display:flex;flex-direction:column;align-items:center;transform:translate(-50%,-100%)}
|
||||||
|
.geo .pin i{width:26px;height:26px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);
|
||||||
|
background:var(--primaire);border:2px solid var(--surface);box-shadow:var(--ombre-menu)}
|
||||||
|
.geo .pin i::after{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--surface)}
|
||||||
|
.geo .pin.alerte i{background:var(--prio-bloque)}
|
||||||
|
.geo .pin b{margin-top:5px;font-size:11px;background:var(--surface);border:1px solid var(--bordure);
|
||||||
|
padding:2px 7px;border-radius:999px;white-space:nowrap}
|
||||||
|
.geo .attribution{position:absolute;right:8px;bottom:6px;font-size:10px;color:var(--encre-3)}
|
||||||
|
|
||||||
|
/* Arborescence des emplacements */
|
||||||
|
.arbre{list-style:none;padding:0;font-size:13.5px}
|
||||||
|
.arbre li{position:relative;padding:7px 0 7px 26px;border-bottom:1px solid var(--bordure)}
|
||||||
|
.arbre li:last-child{border-bottom:0}
|
||||||
|
.arbre li::before{content:"";position:absolute;left:8px;top:0;bottom:0;width:1px;background:var(--bordure)}
|
||||||
|
.arbre li::after{content:"";position:absolute;left:8px;top:50%;width:12px;height:1px;background:var(--bordure)}
|
||||||
|
.arbre li.racine{padding-left:0}
|
||||||
|
.arbre li.racine::before,.arbre li.racine::after{display:none}
|
||||||
|
.arbre .n2{margin-left:22px}
|
||||||
|
.arbre .meta{color:var(--encre-2);font-size:12px;margin-left:8px}
|
||||||
|
.arbre .fin{float:right;color:var(--encre-2);font-size:12px}
|
||||||
|
|
||||||
|
/* Formulaires R1 */
|
||||||
|
.form-grille{display:grid;grid-template-columns:1fr 1fr;gap:12px 14px}
|
||||||
|
@media(max-width:900px){.form-grille{grid-template-columns:1fr}}
|
||||||
|
.form-grille .pleine-l{grid-column:1/-1}
|
||||||
|
.ligne-organe{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid var(--bordure)}
|
||||||
|
.ligne-organe:last-of-type{border-bottom:0}
|
||||||
|
.ligne-organe .faux-select{flex:1}
|
||||||
|
.btn-suppr{color:var(--encre-3);padding:6px;border-radius:6px}
|
||||||
|
.btn-suppr:hover{color:var(--danger);background:var(--prio-bloque-fond)}
|
||||||
|
|
||||||
|
/* Étiquette QR imprimable (A6) */
|
||||||
|
.zone-impression{display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap}
|
||||||
|
.etiquette{width:340px;background:#fff;color:#1b2534;border:1px solid var(--bordure-forte);
|
||||||
|
border-radius:12px;padding:22px;display:flex;flex-direction:column;gap:12px;
|
||||||
|
box-shadow:var(--ombre-menu)}
|
||||||
|
.etiquette .tete{display:flex;align-items:center;gap:10px;border-bottom:2px solid #1b2534;padding-bottom:10px}
|
||||||
|
.etiquette .tete .marque{display:flex;flex-direction:column;line-height:.55;font-size:12px;color:#dd8a0b}
|
||||||
|
.etiquette .tete b{font-size:20px;letter-spacing:.04em}
|
||||||
|
.etiquette .corps{display:flex;gap:16px;align-items:center}
|
||||||
|
.etiquette .qr-grand{flex:none;border:1px solid #dce3ec;border-radius:8px;padding:6px;background:#fff}
|
||||||
|
.etiquette h3{font-size:15px;font-weight:700;margin:0}
|
||||||
|
.etiquette .ou{font-size:12px;color:#55647a}
|
||||||
|
.etiquette .consigne{font-size:12.5px;background:#fdf3e3;border:1px dashed #dd8a0b;color:#8a5a06;
|
||||||
|
border-radius:8px;padding:8px 10px;text-align:center;font-weight:600}
|
||||||
|
.etiquette .ref{font-size:11px;color:#8494ab;text-align:center;font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
/* Modale (invitation) — présentée ouverte dans la maquette */
|
||||||
|
.voile{background:color-mix(in srgb,var(--encre) 32%,transparent);border-radius:var(--rayon);
|
||||||
|
padding:34px;display:flex;align-items:flex-start;justify-content:center}
|
||||||
|
.modale{width:430px;max-width:100%;background:var(--surface);border:1px solid var(--bordure);
|
||||||
|
border-radius:12px;box-shadow:var(--ombre-menu);padding:20px;display:flex;flex-direction:column;gap:12px}
|
||||||
|
.modale h3{font-size:15px;font-weight:700}
|
||||||
|
.modale .pied{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}
|
||||||
|
|
||||||
|
/* Équipes */
|
||||||
|
.equipes{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
||||||
|
@media(max-width:1000px){.equipes{grid-template-columns:1fr}}
|
||||||
|
.equipe{border:1px solid var(--bordure);border-radius:var(--rayon);padding:12px 14px;display:flex;
|
||||||
|
flex-direction:column;gap:8px;background:var(--surface)}
|
||||||
|
.equipe b{font-size:13.5px}
|
||||||
|
.equipe .zone{font-size:12px;color:var(--encre-2)}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
/* ═══ Ajouts R2 (à reverser dans l'app après validation) ═══ */
|
||||||
|
/* Statuts de DEMANDE (parcours sans jargon : Reçue → Approuvée → Résolue) */
|
||||||
|
.st.recue{color:var(--st-ouvert);background:var(--st-ouvert-fond)}
|
||||||
|
.st.approuvee{color:var(--st-encours);background:var(--st-encours-fond)}
|
||||||
|
.st.rejetee{color:var(--st-annule);background:var(--st-annule-fond)}
|
||||||
|
.st.resolue{color:var(--st-termine);background:var(--st-termine-fond)}
|
||||||
|
|
||||||
|
/* Pastille de périodicité (gabarits préventif) */
|
||||||
|
.periode{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;font-weight:700;
|
||||||
|
color:var(--primaire);background:var(--primaire-doux);padding:2px 9px;border-radius:999px}
|
||||||
|
.periode.reglementaire{color:var(--safran);background:var(--safran-doux)}
|
||||||
|
|
||||||
|
/* Panneau d'approbation (drawer statique dans la maquette) */
|
||||||
|
.panneau{border:1px solid var(--bordure);border-radius:var(--rayon);background:var(--surface);
|
||||||
|
padding:16px 18px;display:flex;flex-direction:column;gap:12px}
|
||||||
|
.panneau .photo{height:120px;border-radius:8px;background:
|
||||||
|
linear-gradient(160deg,var(--surface-2),var(--bordure));display:flex;align-items:center;
|
||||||
|
justify-content:center;color:var(--encre-3);font-size:12.5px}
|
||||||
|
|
||||||
|
/* Résumé de génération mensuelle */
|
||||||
|
.gen-resume{display:flex;gap:14px;flex-wrap:wrap}
|
||||||
|
.gen-resume .bloc{flex:1;min-width:130px;border:1px solid var(--bordure);border-radius:var(--rayon);
|
||||||
|
padding:12px 14px;text-align:center}
|
||||||
|
.gen-resume .bloc b{display:block;font-size:24px;letter-spacing:-.02em}
|
||||||
|
.gen-resume .bloc span{font-size:11.5px;color:var(--encre-2);text-transform:uppercase;letter-spacing:.05em}
|
||||||
|
|
||||||
|
/* Relevés de compteurs */
|
||||||
|
.releve-saisie{display:flex;gap:8px;align-items:center}
|
||||||
|
.releve-saisie input{width:130px;padding:8px 11px;border:1px solid var(--bordure-forte);
|
||||||
|
border-radius:8px;font:inherit;background:var(--surface);color:var(--encre);text-align:right}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- ═══════════ BARRE DE REVUE ═══════════ -->
|
||||||
|
<header class="revue">
|
||||||
|
<b>SIOP V2 · Maquettes R2 — Exploitation</b>
|
||||||
|
<nav class="onglets" role="tablist" aria-label="Écrans">
|
||||||
|
<button role="tab" aria-selected="true" data-cible="demandes">1 · Demandes</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="nouvel-ot">2 · Nouvel OT</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="preventif">3 · Préventif</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="ot-preventif">4 · OT préventif (checklist)</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="compteurs">5 · Compteurs</button>
|
||||||
|
</nav>
|
||||||
|
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ═══════════ 1 · DEMANDES ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="demandes"><b>Intention :</b> la file d'attente de Salma. Une demande <b>approuvée devient un OT</b> (lien 1-1, jamais de doublon) ; un rejet exige un motif — le demandeur le lit sur son portail, sans jargon. « Personne bloquée » saute en tête, partout.</p>
|
||||||
|
<section class="ecran actif" id="demandes">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Exploitation</div>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>Tableau de bord</a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M14.7 6.3a4.5 4.5 0 0 0-6 6L3 18v3h3l5.7-5.7a4.5 4.5 0 0 0 6-6l-2.6 2.6-2.4-2.4z"/></svg>Ordres de travail</a>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M8 10h8M8 14h5M21 12a9 9 0 1 1-4-7.5"/></svg>Demandes<span class="badge-nav">3</span></a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Préventif</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<span class="chip-urgence"><span class="point-pulse"></span>1 personne bloquée</span>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
|
||||||
|
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Demandes</h1><span class="filajout">3 à traiter · 12 ce mois</span>
|
||||||
|
</div>
|
||||||
|
<div class="grille-2" style="grid-template-columns:1.4fr 1fr">
|
||||||
|
<div class="carte" style="padding:0"><div class="table"><table>
|
||||||
|
<thead><tr><th>N°</th><th>Signalement</th><th>Équipement · Site</th><th>Par</th><th>Statut</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="ligne-bloque"><td class="ref num">DEM-2026-0112</td><td><b>Personne bloquée en cabine !</b></td><td>Asc. B2<span class="sous">Tour Atlas</span></td><td>Karim (QR)<span class="sous">il y a 4 min</span></td><td><span class="prio bloque"><i></i>Urgence</span></td></tr>
|
||||||
|
<tr style="background:var(--primaire-doux)"><td class="ref num">DEM-2026-0111</td><td>Porte du 3ᵉ ne se ferme plus</td><td>Asc. A1<span class="sous">Résidence Al Manar</span></td><td>Karim (QR)<span class="sous">il y a 2 h</span></td><td><span class="st recue">Reçue</span></td></tr>
|
||||||
|
<tr><td class="ref num">DEM-2026-0110</td><td>Voyant étage éteint</td><td>Asc. C1<span class="sous">Anfa Place</span></td><td>H. Alami (QR)<span class="sous">hier</span></td><td><span class="st recue">Reçue</span></td></tr>
|
||||||
|
<tr><td class="ref num">DEM-2026-0107</td><td>Bruit anormal en gaine</td><td>Asc. A1<span class="sous">Résidence Al Manar</span></td><td>Karim (QR)<span class="sous">hier</span></td><td><span class="st approuvee">OT-2026-0341</span></td></tr>
|
||||||
|
<tr><td class="ref num">DEM-2026-0104</td><td>Odeur de brûlé (fausse alerte)</td><td>Asc. M1<span class="sous">Tour Atlas</span></td><td>Karim (QR)<span class="sous">11 juil.</span></td><td><span class="st rejetee">Rejetée</span></td></tr>
|
||||||
|
<tr><td class="ref num">DEM-2026-0093</td><td>Voyant cabine éteint</td><td>Asc. A1<span class="sous">Résidence Al Manar</span></td><td>Karim (QR)<span class="sous">2 juil.</span></td><td><span class="st resolue">Résolue</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div></div>
|
||||||
|
<div class="panneau">
|
||||||
|
<h2 style="font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--encre-2)">DEM-2026-0111 — approbation</h2>
|
||||||
|
<dl class="infos" style="grid-template-columns:110px 1fr">
|
||||||
|
<dt>Équipement</dt><dd><a href="#">Ascenseur A1</a> — Résidence Al Manar</dd>
|
||||||
|
<dt>Signalé par</dt><dd>Karim Doukkali (gardien) · via QR cabine</dd>
|
||||||
|
<dt>Description</dt><dd>« La porte ne se ferme plus au 3ᵉ étage, il faut la retenir à la main. »</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="photo">📷 photo-porte-3e.jpg (fournie par le gardien)</div>
|
||||||
|
<div class="champ"><label>Priorité de l'OT</label><div class="faux-select">Haute <span>▾</span></div></div>
|
||||||
|
<div class="champ"><label>Assigner à</label><div class="faux-select">Ahmed Benali — équipe Mohammedia <span>▾</span></div></div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button class="btn danger-l">Rejeter (motif requis)</button>
|
||||||
|
<button class="btn prim">Approuver → créer l'OT</button>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:var(--encre-3)">L'OT reprend titre, équipement, photo et description — la demande suit ensuite l'OT (Reçu → Intervention → Résolu côté portail).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 2 · NOUVEL OT ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="nouvel-ot" hidden><b>Intention :</b> l'OT manuel (sans demande) en une carte : type, équipement, priorité — « personne bloquée » est un choix explicite qui déclenche l'urgence partout. Échéance par défaut selon la priorité.</p>
|
||||||
|
<section class="ecran" id="nouvel-ot">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Exploitation</div>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>Tableau de bord</a>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M14.7 6.3a4.5 4.5 0 0 0-6 6L3 18v3h3l5.7-5.7a4.5 4.5 0 0 0 6-6l-2.6 2.6-2.4-2.4z"/></svg>Ordres de travail</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
|
||||||
|
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page" style="max-width:760px">
|
||||||
|
<div class="filajout"><a href="#">Ordres de travail</a> / Nouvel OT</div>
|
||||||
|
<div class="entete-page"><h1>Nouvel ordre de travail</h1></div>
|
||||||
|
<div class="carte">
|
||||||
|
<div class="form-grille">
|
||||||
|
<div class="champ pleine-l"><label for="not-titre">Objet <em style="color:var(--danger)">*</em></label><input id="not-titre" placeholder="Ex. : Remplacement contact de porte"></div>
|
||||||
|
<div class="champ"><label>Type <em style="color:var(--danger)">*</em></label><div class="faux-select">Dépannage <span>▾</span></div></div>
|
||||||
|
<div class="champ"><label>Équipement <em style="color:var(--danger)">*</em></label><div class="faux-select">B2 — Tour Atlas <span>▾</span></div></div>
|
||||||
|
<div class="champ"><label>Priorité <em style="color:var(--danger)">*</em></label><div class="faux-select"><span class="prio haute" style="font-size:12.5px"><i></i>Haute</span> <span>▾</span></div></div>
|
||||||
|
<div class="champ"><label for="not-echeance">Échéance</label><input id="not-echeance" value="18/07/2026" class="num"></div>
|
||||||
|
<div class="champ pleine-l"><label for="not-desc">Description</label><input id="not-desc" placeholder="Constat, contexte, consignes…"></div>
|
||||||
|
<div class="champ"><label>Assigner à</label><div class="faux-select">Ahmed Benali <span>▾</span></div></div>
|
||||||
|
<div class="champ"><label>Équipe</label><div class="faux-select vide">— <span>▾</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="gros-toggle" style="margin-top:14px"><b>Personne bloquée dans la cabine ?</b><span style="margin-left:6px;font-size:12px;color:var(--encre-2)">passe l'OT en urgence absolue (bandeau + tête de liste)</span><span class="interrupteur" role="switch" aria-checked="false" aria-label="Personne bloquée"></span></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:8px">
|
||||||
|
<button class="btn">Annuler</button>
|
||||||
|
<button class="btn prim">Créer l'OT (statut Ouvert)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 3 · PRÉVENTIF ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="preventif" hidden><b>Intention :</b> le réglementaire sans tableur — des <b>gabarits de tâches à périodicité</b> (l'essai parachute est marqué réglementaire), et la <b>génération du mois</b> : un OT « grille du mois » par appareil sous contrat, avec les tâches dues. <b>Regénérer ne double rien</b> ; un appareil jamais contrôlé reçoit le premier contrôle (tout).</p>
|
||||||
|
<section class="ecran" id="preventif">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Exploitation</div>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>Tableau de bord</a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M14.7 6.3a4.5 4.5 0 0 0-6 6L3 18v3h3l5.7-5.7a4.5 4.5 0 0 0 6-6l-2.6 2.6-2.4-2.4z"/></svg>Ordres de travail</a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M8 10h8M8 14h5M21 12a9 9 0 1 1-4-7.5"/></svg>Demandes</a>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>Préventif</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-1)">YA</span>
|
||||||
|
<div class="qui"><b>Yasmine Alaoui</b><br><small>Administratrice ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Préventif</h1>
|
||||||
|
<div class="actions"><button class="btn prim">Générer la grille de juillet</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="gen-resume">
|
||||||
|
<div class="bloc"><b class="num">32 / 41</b><span>grilles générées</span></div>
|
||||||
|
<div class="bloc"><b class="num">25</b><span>terminées</span></div>
|
||||||
|
<div class="bloc"><b class="num" style="color:var(--danger)">3</b><span>en retard</span></div>
|
||||||
|
<div class="bloc"><b class="num">2</b><span>premiers contrôles</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="carte" style="border-left:3px solid var(--succes)">
|
||||||
|
<span style="font-size:13px">✓ Dernière génération : <b>1ᵉʳ juillet, 06 h 00</b> (automatique) — 32 OT créés, 0 doublon. Regénérer maintenant ne créerait que les grilles des <b>9 appareils ajoutés depuis</b>.</span>
|
||||||
|
</div>
|
||||||
|
<div class="carte" style="padding:0">
|
||||||
|
<div class="table"><table>
|
||||||
|
<thead><tr><th>Tâche du gabarit</th><th>Organe visé</th><th>Périodicité</th><th>Juillet</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Contrôle fermeture / verrouillage des portes</td><td>Portes cabine / palières</td><td><span class="periode">Tous les mois</span></td><td><span class="st termine">Due</span></td></tr>
|
||||||
|
<tr><td>Nettoyage cuvette et toit de cabine</td><td>—</td><td><span class="periode">Tous les mois</span></td><td><span class="st termine">Due</span></td></tr>
|
||||||
|
<tr><td>Contrôle niveau d'huile réducteur</td><td>Treuil / machinerie</td><td><span class="periode">Tous les 3 mois</span></td><td><span class="st termine">Due</span></td></tr>
|
||||||
|
<tr><td>Vérification jeu des coulisseaux</td><td>—</td><td><span class="periode">Tous les 6 mois</span></td><td><span class="st annule">Non due</span></td></tr>
|
||||||
|
<tr><td><b>Essai du parachute</b></td><td>Parachute</td><td><span class="periode reglementaire">12 mois · réglementaire</span></td><td><span class="st termine">Due</span></td></tr>
|
||||||
|
<tr><td>Contrôle câbles de traction (usure, tension)</td><td>Treuil / machinerie</td><td><span class="periode">Tous les 6 mois</span></td><td><span class="st annule">Non due</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
</div>
|
||||||
|
<div class="carte" style="border-style:dashed;color:var(--encre-2);font-size:13px">
|
||||||
|
Le gabarit s'applique à tout appareil <b>sous contrat préventif</b>. Un appareil sans historique reçoit
|
||||||
|
un OT « <b>premier contrôle</b> » : toutes les tâches, quelle que soit la périodicité.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 4 · OT PRÉVENTIF (CHECKLIST) ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="ot-preventif" hidden><b>Intention :</b> la grille du mois d'Ahmed — chaque tâche se règle en <b>Fait / N-A</b> (avec qui et quand) ; la <b>clôture est bloquée</b> tant qu'une tâche reste sans réponse. Le bilan codé reste requis (écran fiche OT validé en R0).</p>
|
||||||
|
<section class="ecran" id="ot-preventif">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Exploitation</div>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M14.7 6.3a4.5 4.5 0 0 0-6 6L3 18v3h3l5.7-5.7a4.5 4.5 0 0 0 6-6l-2.6 2.6-2.4-2.4z"/></svg>Ordres de travail</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-3)">AB</span>
|
||||||
|
<div class="qui"><b>Ahmed Benali</b><br><small>Technicien ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="filajout"><a href="#">Ordres de travail</a> / OT-2026-0338</div>
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Grille du mois — juillet · Ascenseur A1</h1>
|
||||||
|
<span class="st encours">En cours</span>
|
||||||
|
<div class="actions"><button class="btn succes" disabled>Clôturer (2 tâches sans réponse)</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="grille-2">
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Checklist du mois <span style="color:var(--encre-3);text-transform:none;letter-spacing:0">— 4 / 6 traitées</span></h2>
|
||||||
|
<div class="check"><span class="coche ok">✓</span>Contrôle fermeture / verrouillage des portes<span class="qui-fait">Ahmed · 09 h 12</span></div>
|
||||||
|
<div class="check"><span class="coche ok">✓</span>Nettoyage cuvette et toit de cabine<span class="qui-fait">Ahmed · 09 h 40</span></div>
|
||||||
|
<div class="check"><span class="coche na">N-A</span>Contrôle niveau d'huile réducteur <span style="color:var(--encre-3);font-size:12px">(gearless — sans objet)</span><span class="qui-fait">Ahmed · 09 h 41</span></div>
|
||||||
|
<div class="check"><span class="coche ok">✓</span>Essai du parachute<span class="qui-fait">Ahmed · 10 h 25</span></div>
|
||||||
|
<div class="check"><span class="coche"></span>Contrôle boutons cabine & paliers<span class="qui-fait">—</span></div>
|
||||||
|
<div class="check"><span class="coche"></span>Vérification éclairage de secours<span class="qui-fait">—</span></div>
|
||||||
|
<p style="margin-top:10px;font-size:12.5px;color:var(--alerte)">⚠ La clôture reste bloquée : chaque tâche doit être <b>Fait</b> ou <b>N-A</b>.</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:16px">
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Intervention</h2>
|
||||||
|
<dl class="infos">
|
||||||
|
<dt>N°</dt><dd class="num">OT-2026-0338 · Maintenance</dd>
|
||||||
|
<dt>Équipement</dt><dd><a href="#">Ascenseur A1 — Otis Gen2</a></dd>
|
||||||
|
<dt>Généré le</dt><dd class="num">1ᵉʳ juillet (grille mensuelle)</dd>
|
||||||
|
<dt>Échéance</dt><dd class="num">31 juillet 2026</dd>
|
||||||
|
<dt>Assigné</dt><dd>Ahmed Benali</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Bilan d'intervention</h2>
|
||||||
|
<p style="font-size:12.5px;color:var(--encre-2)">Requis aussi pour la grille (écran « fiche OT » validé en R0) — prérempli : Action réalisée = <b>Visite d'entretien</b>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 5 · COMPTEURS ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="compteurs" hidden><b>Intention :</b> les relevés simples du carnet (heures de marche, démarrages) — saisis en fiche appareil ou en tournée. R2 les stocke et trace ; les seuils d'alerte viendront nourrir le préventif conditionnel (backlog).</p>
|
||||||
|
<section class="ecran" id="compteurs">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Parc</div>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-3)">AB</span>
|
||||||
|
<div class="qui"><b>Ahmed Benali</b><br><small>Technicien ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page" style="max-width:860px">
|
||||||
|
<div class="filajout"><a href="#">Ascenseurs</a> / <a href="#">Ascenseur A1</a> / Compteurs</div>
|
||||||
|
<div class="entete-page"><h1>Compteurs — Ascenseur A1</h1></div>
|
||||||
|
<div class="grille-2">
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Heures de marche</h2>
|
||||||
|
<div class="releve-saisie">
|
||||||
|
<input value="12 480" class="num" aria-label="Nouveau relevé heures de marche"><span style="color:var(--encre-2);font-size:13px">h</span>
|
||||||
|
<button class="btn prim">Enregistrer le relevé</button>
|
||||||
|
</div>
|
||||||
|
<ul class="chrono" style="margin-top:14px">
|
||||||
|
<li class="fait"><span class="num">12 411 h</span> — relevé mensuel<br><span class="quand">1ᵉʳ juillet · Ahmed (grille)</span></li>
|
||||||
|
<li class="fait"><span class="num">12 322 h</span> — relevé mensuel<br><span class="quand">2 juin · Fatima-Zahra</span></li>
|
||||||
|
<li class="fait"><span class="num">12 246 h</span> — relevé mensuel<br><span class="quand">3 mai · Ahmed</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Démarrages</h2>
|
||||||
|
<div class="releve-saisie">
|
||||||
|
<input value="1 844 210" class="num" aria-label="Nouveau relevé démarrages"><span style="color:var(--encre-2);font-size:13px">cycles</span>
|
||||||
|
<button class="btn prim">Enregistrer le relevé</button>
|
||||||
|
</div>
|
||||||
|
<ul class="chrono" style="margin-top:14px">
|
||||||
|
<li class="fait"><span class="num">1 831 970</span><br><span class="quand">1ᵉʳ juillet · Ahmed (grille)</span></li>
|
||||||
|
<li class="fait"><span class="num">1 815 400</span><br><span class="quand">2 juin · Fatima-Zahra</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte" style="border-style:dashed;color:var(--encre-2);font-size:13px">
|
||||||
|
Un relevé <b>inférieur au précédent</b> est refusé (compteur croissant). Les seuils d'alerte
|
||||||
|
(préventif conditionnel) sont au backlog v2 — R2 stocke et trace.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var onglets=document.querySelectorAll('.revue .onglets button');
|
||||||
|
var ecrans=document.querySelectorAll('.ecran');
|
||||||
|
var notes=document.querySelectorAll('.note-ecran');
|
||||||
|
onglets.forEach(function(b){b.addEventListener('click',function(){
|
||||||
|
onglets.forEach(function(x){x.setAttribute('aria-selected','false')});
|
||||||
|
b.setAttribute('aria-selected','true');
|
||||||
|
var cible=b.dataset.cible;
|
||||||
|
ecrans.forEach(function(e){e.classList.toggle('actif',e.id===cible)});
|
||||||
|
notes.forEach(function(n){n.hidden=(n.dataset.note!==cible)});
|
||||||
|
window.scrollTo({top:0});
|
||||||
|
})});
|
||||||
|
var ordre=['auto','light','dark'];var i=0;
|
||||||
|
var bt=document.getElementById('btn-theme');
|
||||||
|
bt.addEventListener('click',function(){
|
||||||
|
i=(i+1)%3;var m=ordre[i];
|
||||||
|
if(m==='auto'){document.documentElement.removeAttribute('data-theme')}
|
||||||
|
else{document.documentElement.setAttribute('data-theme',m)}
|
||||||
|
bt.textContent='Thème : '+(m==='auto'?'auto':(m==='light'?'clair':'sombre'));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
3051
docs/openapi.json
3051
docs/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -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' },
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -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 d’appareils 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 l’appareil (relevés récents d’abord)',
|
||||||
|
tags: ['meters'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Compteurs', name: 'MetersResponse', schema: MetersResponseSchema },
|
||||||
|
404: { description: 'Appareil inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'addMeterReading',
|
||||||
|
method: 'post',
|
||||||
|
path: '/assets/{id}/meter-readings',
|
||||||
|
summary: 'Enregistrer un relevé — strictement croissant',
|
||||||
|
tags: ['meters'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'MeterReadingCreate', schema: MeterReadingCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Relevé enregistré', name: 'MetersResponse', schema: MetersResponseSchema },
|
||||||
|
400: { description: 'Relevé inférieur ou égal au précédent' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ————— R2 · Exploitation —————
|
||||||
|
{
|
||||||
|
operationId: 'listWorkOrders',
|
||||||
|
method: 'get',
|
||||||
|
path: '/work-orders',
|
||||||
|
summary: 'Ordres de travail (sans « voir autre » : seulement les siens)',
|
||||||
|
tags: ['work-orders'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'WorkOrdersResponse', schema: WorkOrdersResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'getWorkOrder',
|
||||||
|
method: 'get',
|
||||||
|
path: '/work-orders/{id}',
|
||||||
|
summary: 'Fiche OT (activité, checklist, bilan, transitions autorisées, blocages de clôture)',
|
||||||
|
tags: ['work-orders'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Fiche', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||||
|
404: { description: 'Inconnu (ou hors de son périmètre)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'createWorkOrder',
|
||||||
|
method: 'post',
|
||||||
|
path: '/work-orders',
|
||||||
|
summary: 'Créer un OT (statut Ouvert)',
|
||||||
|
tags: ['work-orders'],
|
||||||
|
request: { name: 'WorkOrderCreate', schema: WorkOrderCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Créé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'transitionWorkOrder',
|
||||||
|
method: 'post',
|
||||||
|
path: '/work-orders/{id}/transition',
|
||||||
|
summary: 'Changer l’état (machine à états stricte ; DONE exige bilan + checklist)',
|
||||||
|
tags: ['work-orders'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'TransitionRequest', schema: TransitionRequestSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'État changé', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||||
|
409: { description: 'Transition interdite ou clôture bloquée (garde)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'commentWorkOrder',
|
||||||
|
method: 'post',
|
||||||
|
path: '/work-orders/{id}/comments',
|
||||||
|
summary: 'Commenter (activité chronologique)',
|
||||||
|
tags: ['work-orders'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'CommentCreate', schema: CommentCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Commentaire ajouté', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'setWorkOrderAssignees',
|
||||||
|
method: 'put',
|
||||||
|
path: '/work-orders/{id}/assignees',
|
||||||
|
summary: 'Assigner (remplace la liste)',
|
||||||
|
tags: ['work-orders'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'AssigneesUpdate', schema: AssigneesUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Assignés', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'upsertWorkOrderReport',
|
||||||
|
method: 'put',
|
||||||
|
path: '/work-orders/{id}/report',
|
||||||
|
summary: 'Renseigner le bilan codé (null efface un champ)',
|
||||||
|
tags: ['work-orders'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'ReportUpsert', schema: ReportUpsertSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Bilan enregistré', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||||
|
400: { description: 'Valeur hors référentiel du champ' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'patchChecklistItem',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/work-orders/{id}/checklist/{itemId}',
|
||||||
|
summary: 'Régler une tâche de la grille (Fait / N-A / à faire)',
|
||||||
|
tags: ['work-orders'],
|
||||||
|
pathParams: ['id', 'itemId'],
|
||||||
|
request: { name: 'ChecklistPatch', schema: ChecklistPatchSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Tâche réglée', name: 'ChecklistItem', schema: ChecklistItemSchema },
|
||||||
|
404: { description: 'Tâche inconnue' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listRequests',
|
||||||
|
method: 'get',
|
||||||
|
path: '/requests',
|
||||||
|
summary: 'Demandes (sans « voir autre » : seulement les siennes)',
|
||||||
|
tags: ['requests'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'RequestsResponse', schema: RequestsResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'createRequest',
|
||||||
|
method: 'post',
|
||||||
|
path: '/requests',
|
||||||
|
summary: 'Signaler (interne — le portail public QR arrive en R2.4)',
|
||||||
|
tags: ['requests'],
|
||||||
|
request: { name: 'RequestCreate', schema: RequestCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Demande créée', name: 'RequestSummary', schema: RequestSummarySchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'approveRequest',
|
||||||
|
method: 'post',
|
||||||
|
path: '/requests/{id}/approve',
|
||||||
|
summary: 'Approuver → crée l’OT lié (1-1, jamais de doublon)',
|
||||||
|
tags: ['requests'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'RequestApprove', schema: RequestApproveSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'OT créé et lié', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
|
||||||
|
409: { description: 'Demande déjà traitée' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'rejectRequest',
|
||||||
|
method: 'post',
|
||||||
|
path: '/requests/{id}/reject',
|
||||||
|
summary: 'Rejeter — motif obligatoire, lisible côté demandeur',
|
||||||
|
tags: ['requests'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'RequestReject', schema: RequestRejectSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Rejetée', name: 'RequestSummary', schema: RequestSummarySchema },
|
||||||
|
409: { description: 'Demande déjà traitée' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listReferenceValues',
|
||||||
|
method: 'get',
|
||||||
|
path: '/reference-values',
|
||||||
|
summary: 'Référentiels du bilan codé (6 champs)',
|
||||||
|
tags: ['reference-values'],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Liste',
|
||||||
|
name: 'ReferenceValuesResponse',
|
||||||
|
schema: ReferenceValuesResponseSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'createReferenceValue',
|
||||||
|
method: 'post',
|
||||||
|
path: '/reference-values',
|
||||||
|
summary: 'Ajouter une valeur de référentiel',
|
||||||
|
tags: ['reference-values'],
|
||||||
|
request: { name: 'ReferenceValueCreate', schema: ReferenceValueCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Créée', name: 'ReferenceValue', schema: ReferenceValueSchema },
|
||||||
|
409: { description: 'Libellé déjà présent pour ce champ' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateReferenceValue',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/reference-values/{id}',
|
||||||
|
summary: 'Renommer ou (dés)activer — jamais de suppression si utilisée',
|
||||||
|
tags: ['reference-values'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'ReferenceValueUpdate', schema: ReferenceValueUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Mise à jour', name: 'ReferenceValue', schema: ReferenceValueSchema },
|
||||||
|
404: { description: 'Inconnue' },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
operationId: 'getHealth',
|
operationId: 'getHealth',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|||||||
93
packages/shared/src/exploitation.ts
Normal file
93
packages/shared/src/exploitation.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/** Vocabulaires de l'exploitation (R2) — partagés API / web / seed. */
|
||||||
|
|
||||||
|
export const WORK_ORDER_TYPES = ['CORRECTIVE', 'PREVENTIVE', 'WORKS'] as const;
|
||||||
|
export type WorkOrderType = (typeof WORK_ORDER_TYPES)[number];
|
||||||
|
export const WORK_ORDER_TYPE_LABELS: Record<WorkOrderType, string> = {
|
||||||
|
CORRECTIVE: 'Dépannage',
|
||||||
|
PREVENTIVE: 'Maintenance',
|
||||||
|
WORKS: 'Travaux',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORK_ORDER_STATUSES = [
|
||||||
|
'OPEN',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'ON_HOLD',
|
||||||
|
'DONE',
|
||||||
|
'CANCELLED',
|
||||||
|
] as const;
|
||||||
|
export type WorkOrderStatus = (typeof WORK_ORDER_STATUSES)[number];
|
||||||
|
export const WORK_ORDER_STATUS_LABELS: Record<WorkOrderStatus, string> = {
|
||||||
|
OPEN: 'Ouvert',
|
||||||
|
IN_PROGRESS: 'En cours',
|
||||||
|
ON_HOLD: 'En attente',
|
||||||
|
DONE: 'Terminé',
|
||||||
|
CANCELLED: 'Annulé',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Machine à états STRICTE — la table est la loi, côté API comme côté UI. */
|
||||||
|
export const WORK_ORDER_TRANSITIONS: Record<WorkOrderStatus, WorkOrderStatus[]> = {
|
||||||
|
OPEN: ['IN_PROGRESS', 'CANCELLED'],
|
||||||
|
IN_PROGRESS: ['ON_HOLD', 'DONE', 'CANCELLED'],
|
||||||
|
ON_HOLD: ['IN_PROGRESS', 'CANCELLED'],
|
||||||
|
DONE: [],
|
||||||
|
CANCELLED: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORK_ORDER_PRIORITIES = [
|
||||||
|
'NONE',
|
||||||
|
'LOW',
|
||||||
|
'MEDIUM',
|
||||||
|
'HIGH',
|
||||||
|
'PERSON_TRAPPED',
|
||||||
|
] as const;
|
||||||
|
export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITIES)[number];
|
||||||
|
export const WORK_ORDER_PRIORITY_LABELS: Record<WorkOrderPriority, string> = {
|
||||||
|
NONE: 'Aucune',
|
||||||
|
LOW: 'Basse',
|
||||||
|
MEDIUM: 'Moyenne',
|
||||||
|
HIGH: 'Haute',
|
||||||
|
PERSON_TRAPPED: 'Personne bloquée',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REQUEST_STATUSES = ['RECEIVED', 'APPROVED', 'REJECTED'] as const;
|
||||||
|
export type RequestStatus = (typeof REQUEST_STATUSES)[number];
|
||||||
|
export const REQUEST_STATUS_LABELS: Record<RequestStatus, string> = {
|
||||||
|
RECEIVED: 'Reçue',
|
||||||
|
APPROVED: 'Approuvée',
|
||||||
|
REJECTED: 'Rejetée',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHECKLIST_STATES = ['PENDING', 'DONE', 'NA'] as const;
|
||||||
|
export type ChecklistState = (typeof CHECKLIST_STATES)[number];
|
||||||
|
|
||||||
|
export const BILAN_FIELDS = [
|
||||||
|
'DOOR_STATE',
|
||||||
|
'CABIN_POSITION',
|
||||||
|
'ANOMALY',
|
||||||
|
'EXTERNAL_CAUSE',
|
||||||
|
'ACTION_TAKEN',
|
||||||
|
'COMPONENT_CONCERNED',
|
||||||
|
] as const;
|
||||||
|
export type BilanField = (typeof BILAN_FIELDS)[number];
|
||||||
|
export const BILAN_FIELD_LABELS: Record<BilanField, string> = {
|
||||||
|
DOOR_STATE: 'État des portes',
|
||||||
|
CABIN_POSITION: 'Position cabine',
|
||||||
|
ANOMALY: 'Anomalie constatée',
|
||||||
|
EXTERNAL_CAUSE: 'Cause extérieure',
|
||||||
|
ACTION_TAKEN: 'Action réalisée',
|
||||||
|
COMPONENT_CONCERNED: 'Élément concerné',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Champs du bilan REQUIS pour clôturer (maquette fiche OT validée R0). */
|
||||||
|
export const REQUIRED_BILAN_FIELDS: readonly BilanField[] = [
|
||||||
|
'DOOR_STATE',
|
||||||
|
'ACTION_TAKEN',
|
||||||
|
'COMPONENT_CONCERNED',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const METER_KINDS = ['RUNNING_HOURS', 'STARTS'] as const;
|
||||||
|
export type MeterKind = (typeof METER_KINDS)[number];
|
||||||
|
export const METER_KIND_LABELS: Record<MeterKind, string> = {
|
||||||
|
RUNNING_HOURS: 'Heures de marche',
|
||||||
|
STARTS: 'Démarrages',
|
||||||
|
};
|
||||||
@@ -1,5 +1,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';
|
||||||
|
|||||||
209
packages/shared/src/schemas/exploitation.ts
Normal file
209
packages/shared/src/schemas/exploitation.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
BILAN_FIELDS,
|
||||||
|
CHECKLIST_STATES,
|
||||||
|
REQUEST_STATUSES,
|
||||||
|
WORK_ORDER_PRIORITIES,
|
||||||
|
WORK_ORDER_STATUSES,
|
||||||
|
WORK_ORDER_TYPES,
|
||||||
|
} from '../exploitation';
|
||||||
|
|
||||||
|
const PersonneSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
displayName: z.string(),
|
||||||
|
initials: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ————— Ordres de travail —————
|
||||||
|
|
||||||
|
export const WorkOrderSummarySchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
reference: z.string(),
|
||||||
|
title: z.string(),
|
||||||
|
type: z.enum(WORK_ORDER_TYPES),
|
||||||
|
status: z.enum(WORK_ORDER_STATUSES),
|
||||||
|
priority: z.enum(WORK_ORDER_PRIORITIES),
|
||||||
|
assetId: z.uuid(),
|
||||||
|
assetReference: z.string(),
|
||||||
|
siteName: z.string(),
|
||||||
|
dueDate: z.iso.datetime().nullable(),
|
||||||
|
assignees: z.array(PersonneSchema),
|
||||||
|
createdAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
export type WorkOrderSummary = z.infer<typeof WorkOrderSummarySchema>;
|
||||||
|
|
||||||
|
export const WorkOrdersResponseSchema = z.object({
|
||||||
|
workOrders: z.array(WorkOrderSummarySchema),
|
||||||
|
});
|
||||||
|
export type WorkOrdersResponse = z.infer<typeof WorkOrdersResponseSchema>;
|
||||||
|
|
||||||
|
export const WorkOrderEventSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
kind: z.string(),
|
||||||
|
message: z.string().nullable(),
|
||||||
|
by: PersonneSchema.nullable(),
|
||||||
|
createdAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ChecklistItemSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
label: z.string(),
|
||||||
|
state: z.enum(CHECKLIST_STATES),
|
||||||
|
doneBy: PersonneSchema.nullable(),
|
||||||
|
doneAt: z.iso.datetime().nullable(),
|
||||||
|
});
|
||||||
|
export type ChecklistItemDto = z.infer<typeof ChecklistItemSchema>;
|
||||||
|
|
||||||
|
const BilanValueSchema = z.object({ id: z.uuid(), label: z.string() }).nullable();
|
||||||
|
|
||||||
|
export const InterventionReportSchema = z.object({
|
||||||
|
note: z.string().nullable(),
|
||||||
|
doorState: BilanValueSchema,
|
||||||
|
cabinPosition: BilanValueSchema,
|
||||||
|
anomaly: BilanValueSchema,
|
||||||
|
externalCause: BilanValueSchema,
|
||||||
|
actionTaken: BilanValueSchema,
|
||||||
|
componentConcerned: BilanValueSchema,
|
||||||
|
});
|
||||||
|
export type InterventionReportDto = z.infer<typeof InterventionReportSchema>;
|
||||||
|
|
||||||
|
export const WorkOrderDetailSchema = WorkOrderSummarySchema.extend({
|
||||||
|
description: z.string().nullable(),
|
||||||
|
locationName: z.string(),
|
||||||
|
startedAt: z.iso.datetime().nullable(),
|
||||||
|
completedAt: z.iso.datetime().nullable(),
|
||||||
|
cancelledAt: z.iso.datetime().nullable(),
|
||||||
|
createdBy: PersonneSchema.nullable(),
|
||||||
|
request: z
|
||||||
|
.object({ id: z.uuid(), reference: z.string(), requesterLabel: z.string() })
|
||||||
|
.nullable(),
|
||||||
|
events: z.array(WorkOrderEventSchema),
|
||||||
|
checklist: z.array(ChecklistItemSchema),
|
||||||
|
report: InterventionReportSchema.nullable(),
|
||||||
|
/** Ce que la machine à états autorise depuis l'état courant. */
|
||||||
|
allowedTransitions: z.array(z.enum(WORK_ORDER_STATUSES)),
|
||||||
|
/** Ce qui bloque la clôture (vide = clôturable) — messages métier. */
|
||||||
|
closureBlockers: z.array(z.string()),
|
||||||
|
});
|
||||||
|
export type WorkOrderDetail = z.infer<typeof WorkOrderDetailSchema>;
|
||||||
|
|
||||||
|
export const WorkOrderCreateSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200),
|
||||||
|
description: z.string().max(2000).optional(),
|
||||||
|
type: z.enum(WORK_ORDER_TYPES),
|
||||||
|
priority: z.enum(WORK_ORDER_PRIORITIES).optional(),
|
||||||
|
assetId: z.uuid(),
|
||||||
|
dueDate: z.iso.datetime().optional(),
|
||||||
|
assigneeIds: z.array(z.uuid()).optional(),
|
||||||
|
});
|
||||||
|
export type WorkOrderCreate = z.infer<typeof WorkOrderCreateSchema>;
|
||||||
|
|
||||||
|
export const TransitionRequestSchema = z.object({
|
||||||
|
to: z.enum(WORK_ORDER_STATUSES),
|
||||||
|
comment: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
export type TransitionRequest = z.infer<typeof TransitionRequestSchema>;
|
||||||
|
|
||||||
|
export const CommentCreateSchema = z.object({
|
||||||
|
message: z.string().min(1).max(1000),
|
||||||
|
});
|
||||||
|
export type CommentCreate = z.infer<typeof CommentCreateSchema>;
|
||||||
|
|
||||||
|
export const AssigneesUpdateSchema = z.object({
|
||||||
|
assigneeIds: z.array(z.uuid()).max(10),
|
||||||
|
});
|
||||||
|
export type AssigneesUpdate = z.infer<typeof AssigneesUpdateSchema>;
|
||||||
|
|
||||||
|
/** Upsert du bilan — null efface un champ. */
|
||||||
|
export const ReportUpsertSchema = z.object({
|
||||||
|
note: z.string().max(2000).nullable().optional(),
|
||||||
|
doorStateId: z.uuid().nullable().optional(),
|
||||||
|
cabinPositionId: z.uuid().nullable().optional(),
|
||||||
|
anomalyId: z.uuid().nullable().optional(),
|
||||||
|
externalCauseId: z.uuid().nullable().optional(),
|
||||||
|
actionTakenId: z.uuid().nullable().optional(),
|
||||||
|
componentConcernedId: z.uuid().nullable().optional(),
|
||||||
|
});
|
||||||
|
export type ReportUpsert = z.infer<typeof ReportUpsertSchema>;
|
||||||
|
|
||||||
|
export const ChecklistPatchSchema = z.object({
|
||||||
|
state: z.enum(CHECKLIST_STATES),
|
||||||
|
});
|
||||||
|
export type ChecklistPatch = z.infer<typeof ChecklistPatchSchema>;
|
||||||
|
|
||||||
|
// ————— Demandes —————
|
||||||
|
|
||||||
|
export const RequestSummarySchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
reference: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
isPersonTrapped: z.boolean(),
|
||||||
|
status: z.enum(REQUEST_STATUSES),
|
||||||
|
rejectionReason: z.string().nullable(),
|
||||||
|
assetId: z.uuid(),
|
||||||
|
assetReference: z.string(),
|
||||||
|
siteName: z.string(),
|
||||||
|
requesterLabel: z.string(),
|
||||||
|
workOrder: z
|
||||||
|
.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
reference: z.string(),
|
||||||
|
status: z.enum(WORK_ORDER_STATUSES),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
createdAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
export type RequestSummary = z.infer<typeof RequestSummarySchema>;
|
||||||
|
|
||||||
|
export const RequestsResponseSchema = z.object({
|
||||||
|
requests: z.array(RequestSummarySchema),
|
||||||
|
});
|
||||||
|
export type RequestsResponse = z.infer<typeof RequestsResponseSchema>;
|
||||||
|
|
||||||
|
export const RequestCreateSchema = z.object({
|
||||||
|
assetId: z.uuid(),
|
||||||
|
description: z.string().min(1).max(2000),
|
||||||
|
isPersonTrapped: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type RequestCreate = z.infer<typeof RequestCreateSchema>;
|
||||||
|
|
||||||
|
export const RequestApproveSchema = z.object({
|
||||||
|
title: z.string().min(1).max(200).optional(), // défaut : description tronquée
|
||||||
|
priority: z.enum(WORK_ORDER_PRIORITIES).optional(),
|
||||||
|
assigneeIds: z.array(z.uuid()).optional(),
|
||||||
|
dueDate: z.iso.datetime().optional(),
|
||||||
|
});
|
||||||
|
export type RequestApprove = z.infer<typeof RequestApproveSchema>;
|
||||||
|
|
||||||
|
export const RequestRejectSchema = z.object({
|
||||||
|
reason: z.string().min(3, 'Le motif est requis').max(500),
|
||||||
|
});
|
||||||
|
export type RequestReject = z.infer<typeof RequestRejectSchema>;
|
||||||
|
|
||||||
|
// ————— Référentiels du bilan codé —————
|
||||||
|
|
||||||
|
export const ReferenceValueSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
field: z.enum(BILAN_FIELDS),
|
||||||
|
label: z.string(),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
usageCount: z.number().int(),
|
||||||
|
});
|
||||||
|
export type ReferenceValueDto = z.infer<typeof ReferenceValueSchema>;
|
||||||
|
|
||||||
|
export const ReferenceValuesResponseSchema = z.object({
|
||||||
|
referenceValues: z.array(ReferenceValueSchema),
|
||||||
|
});
|
||||||
|
export type ReferenceValuesResponse = z.infer<typeof ReferenceValuesResponseSchema>;
|
||||||
|
|
||||||
|
export const ReferenceValueCreateSchema = z.object({
|
||||||
|
field: z.enum(BILAN_FIELDS),
|
||||||
|
label: z.string().min(1).max(120),
|
||||||
|
});
|
||||||
|
export type ReferenceValueCreate = z.infer<typeof ReferenceValueCreateSchema>;
|
||||||
|
|
||||||
|
export const ReferenceValueUpdateSchema = z.object({
|
||||||
|
label: z.string().min(1).max(120).optional(),
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type ReferenceValueUpdate = z.infer<typeof ReferenceValueUpdateSchema>;
|
||||||
42
packages/shared/src/schemas/portail.ts
Normal file
42
packages/shared/src/schemas/portail.ts
Normal 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>;
|
||||||
92
packages/shared/src/schemas/preventif.ts
Normal file
92
packages/shared/src/schemas/preventif.ts
Normal 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>;
|
||||||
@@ -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
16
pnpm-lock.yaml
generated
@@ -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': {}
|
||||||
|
|||||||
Reference in New Issue
Block a user