mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-09 21:21:54 +00:00
Compare commits
16 Commits
release/r1
...
release/r3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d787a2d39 | ||
|
|
460ef4a80e | ||
|
|
c46a97ed08 | ||
|
|
c5382a5538 | ||
|
|
a4e98c2000 | ||
|
|
2ffdd13cf4 | ||
|
|
32eb20b5a0 | ||
|
|
dfdf8f7c18 | ||
|
|
d5041c6f05 | ||
|
|
3aa7189e96 | ||
|
|
f7c0da1b0f | ||
|
|
50254daea4 | ||
|
|
6a2dae1710 | ||
|
|
902efa6192 | ||
|
|
f7702e4252 | ||
|
|
54d926e9f4 |
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -81,8 +81,16 @@ jobs:
|
||||
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
||||
REDIS_URL: redis://localhost:6379
|
||||
JWT_SECRET: ci-only-secret-0123456789abcdef
|
||||
MINIO_ENDPOINT: localhost
|
||||
MINIO_ACCESS_KEY: siop
|
||||
MINIO_SECRET_KEY: siop-minio
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||
run: |
|
||||
docker run -d --name minio -p 9000:9000 \
|
||||
-e MINIO_ROOT_USER=siop -e MINIO_ROOT_PASSWORD=siop-minio \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
@@ -134,8 +142,16 @@ jobs:
|
||||
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
||||
REDIS_URL: redis://localhost:6379
|
||||
JWT_SECRET: ci-only-secret-0123456789abcdef
|
||||
MINIO_ENDPOINT: localhost
|
||||
MINIO_ACCESS_KEY: siop
|
||||
MINIO_SECRET_KEY: siop-minio
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Démarrer MinIO (l'image officielle exige une commande — pas un service)
|
||||
run: |
|
||||
docker run -d --name minio -p 9000:9000 \
|
||||
-e MINIO_ROOT_USER=siop -e MINIO_ROOT_PASSWORD=siop-minio \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z server /data
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
|
||||
12
CLAUDE.md
12
CLAUDE.md
@@ -44,5 +44,15 @@ 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.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` → source TS (leçon CJS/workspace).
|
||||
- 🏁 **R1 CLOSE (16/07/2026, tag `release/r1`)** : recettée par le référent, déployée et vérifiée en ligne (migration + seed au boot, 5 sites / 8 appareils sur l'instance).
|
||||
- 🔄 **R2 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 CLOSE (16/07/2026, tag `release/r2`)** : recettée (1 anomalie corrigée en recette : tri « Interventions récentes »), déployée et vérifiée en ligne (portail `/q/A1`, urgence en tête, préventif de juillet généré).
|
||||
- ✅ **R3 — maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC → RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1).
|
||||
- ✅ **R3.2 — bibliothèque + analytics** : `FileStorage` complet (put/stream/remove, bucket auto), upload multipart typé (20 Mo, rattachement requis, permission sur la cible), download **streamé par l'API**, octets vérifiés à l'identique en e2e ; `GET /analytics/summary` dérivé du réel (pannes par organe via bilans codés, coûts figés, taux préventif, top équipements) ; MinIO en CI ; 71 opérations, 58 tests (6 runs consécutifs verts).
|
||||
- ✅ **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright).
|
||||
- ✅ **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) → 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndic→site via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright.
|
||||
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
|
||||
- 🔄 **Reprise ici** : redéployer sur Dokploy et vérifier en ligne (migration `r3_recette_fixes` + seed au boot) → ouverture R4 Mobile (Expo, design d'abord — maquettes avant tout code).
|
||||
- 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/jwt": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.1.0",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@prisma/client": "^6.8.0",
|
||||
"@siop/shared": "workspace:*",
|
||||
"argon2": "^0.43.0",
|
||||
@@ -39,6 +40,7 @@
|
||||
"@nestjs/testing": "^11.1.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/multer": "^2.2.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
|
||||
@@ -6,6 +6,46 @@ import { PrismaClient } from '@prisma/client';
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// R3 — d'abord les traces de gestion (les mouvements liés à un OT E2E
|
||||
// seraient orphelins après sa suppression et fausseraient les stocks seedés)
|
||||
await prisma.stockMovement.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ part: { designation: { contains: 'E2E' } } },
|
||||
{ workOrder: { title: { contains: 'E2E' } } },
|
||||
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.document.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fileName: { contains: 'e2e' } },
|
||||
{ workOrder: { title: { contains: 'E2E' } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.purchaseOrderLine.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ part: { designation: { contains: 'E2E' } } },
|
||||
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.purchaseOrder.deleteMany({
|
||||
where: { supplier: { name: { contains: 'E2E' } } },
|
||||
});
|
||||
await prisma.part.deleteMany({ where: { designation: { contains: 'E2E' } } });
|
||||
await prisma.partner.deleteMany({ where: { name: { contains: 'E2E' } } });
|
||||
|
||||
await prisma.request.deleteMany({ where: { description: { contains: 'E2E' } } });
|
||||
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
|
||||
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });
|
||||
// Un appareil de test peut avoir reçu grilles/demandes (génération du mois) :
|
||||
// on purge ses dépendances avant lui (FK RESTRICT).
|
||||
await prisma.request.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
|
||||
await prisma.workOrder.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
|
||||
await prisma.asset.deleteMany({ where: { reference: { startsWith: 'E2E-' } } });
|
||||
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' }, parentId: { not: null } } });
|
||||
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PartnerKind" AS ENUM ('SUPPLIER', 'CLIENT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PurchaseOrderStatus" AS ENUM ('DRAFT', 'SENT', 'RECEIVED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StockMovementKind" AS ENUM ('RECEIPT', 'ENTRY', 'CONSUMPTION', 'ADJUSTMENT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DocumentKind" AS ENUM ('NOTICE', 'CERTIFICATE', 'PHOTO', 'OTHER');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "hourlyRate" DECIMAL(8,2);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Partner" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"kind" "PartnerKind" NOT NULL,
|
||||
"contactName" TEXT,
|
||||
"phone" TEXT,
|
||||
"email" TEXT,
|
||||
"city" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Partner_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Part" (
|
||||
"id" UUID NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"designation" TEXT NOT NULL,
|
||||
"threshold" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastUnitPrice" DECIMAL(10,2),
|
||||
"compatible" TEXT,
|
||||
"supplierId" UUID,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Part_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "StockMovement" (
|
||||
"id" UUID NOT NULL,
|
||||
"partId" UUID NOT NULL,
|
||||
"kind" "StockMovementKind" NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"unitPrice" DECIMAL(10,2),
|
||||
"reason" TEXT,
|
||||
"workOrderId" UUID,
|
||||
"purchaseOrderId" UUID,
|
||||
"byId" UUID,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PurchaseOrder" (
|
||||
"id" UUID NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"status" "PurchaseOrderStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"supplierId" UUID NOT NULL,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"receivedAt" TIMESTAMP(3),
|
||||
"cancelledAt" TIMESTAMP(3),
|
||||
"createdById" UUID,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PurchaseOrder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PurchaseOrderLine" (
|
||||
"id" UUID NOT NULL,
|
||||
"purchaseOrderId" UUID NOT NULL,
|
||||
"partId" UUID NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"unitPrice" DECIMAL(10,2) NOT NULL,
|
||||
|
||||
CONSTRAINT "PurchaseOrderLine_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LaborTime" (
|
||||
"id" UUID NOT NULL,
|
||||
"workOrderId" UUID NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"minutes" INTEGER NOT NULL,
|
||||
"hourlyRate" DECIMAL(8,2) NOT NULL,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LaborTime_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Document" (
|
||||
"id" UUID NOT NULL,
|
||||
"kind" "DocumentKind" NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"contentType" TEXT NOT NULL,
|
||||
"assetId" UUID,
|
||||
"workOrderId" UUID,
|
||||
"uploadedById" UUID,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Partner_name_key" ON "Partner"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Part_reference_key" ON "Part"("reference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StockMovement_partId_idx" ON "StockMovement"("partId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StockMovement_workOrderId_idx" ON "StockMovement"("workOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PurchaseOrder_reference_key" ON "PurchaseOrder"("reference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PurchaseOrderLine_purchaseOrderId_idx" ON "PurchaseOrderLine"("purchaseOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LaborTime_workOrderId_idx" ON "LaborTime"("workOrderId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Document_storageKey_key" ON "Document"("storageKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Document_assetId_idx" ON "Document"("assetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Document_workOrderId_idx" ON "Document"("workOrderId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Part" ADD CONSTRAINT "Part_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_byId_fkey" FOREIGN KEY ("byId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Références OT/DEM/BC/P : séquences Postgres — la concurrence ne peut plus
|
||||
-- produire de collision (leçon des générations parallèles).
|
||||
-- La numérotation ne se remet pas à zéro chaque année : l'unicité prime.
|
||||
CREATE SEQUENCE IF NOT EXISTS "work_order_ref_seq";
|
||||
CREATE SEQUENCE IF NOT EXISTS "request_ref_seq";
|
||||
CREATE SEQUENCE IF NOT EXISTS "purchase_order_ref_seq";
|
||||
CREATE SEQUENCE IF NOT EXISTS "part_ref_seq";
|
||||
|
||||
SELECT setval('work_order_ref_seq',
|
||||
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "WorkOrder"), 1000));
|
||||
SELECT setval('request_ref_seq',
|
||||
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Request"), 1000));
|
||||
SELECT setval('purchase_order_ref_seq',
|
||||
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "PurchaseOrder"), 1000));
|
||||
SELECT setval('part_ref_seq',
|
||||
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Part"), 1000));
|
||||
@@ -0,0 +1,8 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Location" ADD COLUMN "partnerId" UUID;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Location_partnerId_idx" ON "Location"("partnerId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Location" ADD CONSTRAINT "Location_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -49,6 +49,19 @@ model User {
|
||||
activationToken String? @unique
|
||||
activationExpiresAt DateTime?
|
||||
teams Team[]
|
||||
// R2 — exploitation
|
||||
workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees")
|
||||
workOrdersCreated WorkOrder[] @relation("WorkOrderCreator")
|
||||
workOrderEvents WorkOrderEvent[]
|
||||
requests Request[]
|
||||
checklistDone ChecklistItem[]
|
||||
meterReadings MeterReading[]
|
||||
// R3 — gestion : taux horaire COURANT (le taux d'une saisie est figé dans LaborTime)
|
||||
hourlyRate Decimal? @db.Decimal(8, 2)
|
||||
laborTimes LaborTime[]
|
||||
stockMovements StockMovement[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
documents Document[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -75,6 +88,7 @@ model Category {
|
||||
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
||||
assets Asset[]
|
||||
components AssetComponent[]
|
||||
taskTemplates TaskTemplate[]
|
||||
|
||||
@@unique([kind, name])
|
||||
}
|
||||
@@ -93,11 +107,15 @@ model Location {
|
||||
longitude Float?
|
||||
// + colonne PostGIS générée (voir migration r1_referentiel) :
|
||||
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
|
||||
// Client/syndic gérant le site (R3 — colonne « Rattachements » des Tiers)
|
||||
partnerId String? @db.Uuid
|
||||
partner Partner? @relation(fields: [partnerId], references: [id])
|
||||
assets Asset[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([parentId])
|
||||
@@index([partnerId])
|
||||
}
|
||||
|
||||
model Asset {
|
||||
@@ -110,11 +128,16 @@ model Asset {
|
||||
loadKg Int?
|
||||
floors Int?
|
||||
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
||||
underContract Boolean @default(true) // contrat préventif (grille mensuelle)
|
||||
categoryId String @db.Uuid
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
locationId String @db.Uuid
|
||||
location Location @relation(fields: [locationId], references: [id])
|
||||
components AssetComponent[]
|
||||
workOrders WorkOrder[]
|
||||
requests Request[]
|
||||
meters Meter[]
|
||||
documents Document[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -141,3 +164,356 @@ model Team {
|
||||
description String?
|
||||
members User[]
|
||||
}
|
||||
|
||||
// ————— R2 — Exploitation (docs/03-architecture/modele-donnees.md §R2) —————
|
||||
|
||||
enum WorkOrderType {
|
||||
CORRECTIVE // Dépannage
|
||||
PREVENTIVE // Maintenance (grille du mois)
|
||||
WORKS // Travaux
|
||||
}
|
||||
|
||||
enum WorkOrderStatus {
|
||||
OPEN
|
||||
IN_PROGRESS
|
||||
ON_HOLD
|
||||
DONE
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum WorkOrderPriority {
|
||||
NONE
|
||||
LOW
|
||||
MEDIUM
|
||||
HIGH
|
||||
PERSON_TRAPPED // personne bloquée — urgence absolue
|
||||
}
|
||||
|
||||
enum RequestStatus {
|
||||
RECEIVED
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum ChecklistState {
|
||||
PENDING
|
||||
DONE
|
||||
NA
|
||||
}
|
||||
|
||||
enum BilanField {
|
||||
DOOR_STATE
|
||||
CABIN_POSITION
|
||||
ANOMALY
|
||||
EXTERNAL_CAUSE
|
||||
ACTION_TAKEN
|
||||
COMPONENT_CONCERNED
|
||||
}
|
||||
|
||||
enum MeterKind {
|
||||
RUNNING_HOURS
|
||||
STARTS
|
||||
}
|
||||
|
||||
model WorkOrder {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
reference String @unique // OT-2026-0341
|
||||
title String
|
||||
description String?
|
||||
type WorkOrderType
|
||||
status WorkOrderStatus @default(OPEN) // machine à états stricte (service)
|
||||
priority WorkOrderPriority @default(NONE)
|
||||
assetId String @db.Uuid
|
||||
asset Asset @relation(fields: [assetId], references: [id])
|
||||
dueDate DateTime?
|
||||
assignees User[] @relation("WorkOrderAssignees")
|
||||
createdById String? @db.Uuid
|
||||
createdBy User? @relation("WorkOrderCreator", fields: [createdById], references: [id])
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
events WorkOrderEvent[]
|
||||
checklist ChecklistItem[]
|
||||
report InterventionReport?
|
||||
request Request?
|
||||
stockMovements StockMovement[]
|
||||
laborTimes LaborTime[]
|
||||
documents Document[]
|
||||
// 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])
|
||||
}
|
||||
|
||||
// ————— R3 — Gestion (docs/03-architecture/modele-donnees.md §R3) —————
|
||||
|
||||
enum PartnerKind {
|
||||
SUPPLIER
|
||||
CLIENT // syndic / propriétaire
|
||||
}
|
||||
|
||||
enum PurchaseOrderStatus {
|
||||
DRAFT
|
||||
SENT
|
||||
RECEIVED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum StockMovementKind {
|
||||
RECEIPT // réception de BC (+, PU figé)
|
||||
ENTRY // entrée manuelle (+)
|
||||
CONSUMPTION // consommation d'OT (−, PU figé)
|
||||
ADJUSTMENT // inventaire (±, motif REQUIS)
|
||||
}
|
||||
|
||||
enum DocumentKind {
|
||||
NOTICE
|
||||
CERTIFICATE
|
||||
PHOTO
|
||||
OTHER
|
||||
}
|
||||
|
||||
model Partner {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @unique
|
||||
kind PartnerKind
|
||||
contactName String?
|
||||
phone String?
|
||||
email String?
|
||||
city String?
|
||||
isActive Boolean @default(true)
|
||||
parts Part[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
sites Location[] // sites gérés (kind CLIENT)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Part {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
reference String @unique // P-0113
|
||||
designation String
|
||||
threshold Int @default(0) // seuil d'alerte
|
||||
// Dernier prix d'achat — figé sur chaque mouvement au moment T.
|
||||
lastUnitPrice Decimal? @db.Decimal(10, 2)
|
||||
compatible String?
|
||||
supplierId String? @db.Uuid
|
||||
supplier Partner? @relation(fields: [supplierId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
movements StockMovement[]
|
||||
orderLines PurchaseOrderLine[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Le stock EST la somme des mouvements — aucune colonne de quantité.
|
||||
model StockMovement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
partId String @db.Uuid
|
||||
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||||
kind StockMovementKind
|
||||
quantity Int // signé : + entrée, − sortie
|
||||
unitPrice Decimal? @db.Decimal(10, 2) // figé (réception, consommation)
|
||||
reason String? // ajustement : motif requis (service)
|
||||
workOrderId String? @db.Uuid
|
||||
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||
purchaseOrderId String? @db.Uuid
|
||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||
byId String? @db.Uuid
|
||||
by User? @relation(fields: [byId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([partId])
|
||||
@@index([workOrderId])
|
||||
}
|
||||
|
||||
model PurchaseOrder {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
reference String @unique // BC-2026-0024
|
||||
status PurchaseOrderStatus @default(DRAFT)
|
||||
supplierId String @db.Uuid
|
||||
supplier Partner @relation(fields: [supplierId], references: [id])
|
||||
lines PurchaseOrderLine[]
|
||||
movements StockMovement[]
|
||||
sentAt DateTime?
|
||||
receivedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
createdById String? @db.Uuid
|
||||
createdBy User? @relation(fields: [createdById], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model PurchaseOrderLine {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
purchaseOrderId String @db.Uuid
|
||||
purchaseOrder PurchaseOrder @relation(fields: [purchaseOrderId], references: [id], onDelete: Cascade)
|
||||
partId String @db.Uuid
|
||||
part Part @relation(fields: [partId], references: [id])
|
||||
quantity Int
|
||||
unitPrice Decimal @db.Decimal(10, 2)
|
||||
|
||||
@@index([purchaseOrderId])
|
||||
}
|
||||
|
||||
// Main-d'œuvre : le taux est FIGÉ à la saisie (le coût d'un OT ne bouge plus).
|
||||
model LaborTime {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workOrderId String @db.Uuid
|
||||
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
|
||||
userId String @db.Uuid
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
minutes Int
|
||||
hourlyRate Decimal @db.Decimal(8, 2)
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([workOrderId])
|
||||
}
|
||||
|
||||
model Document {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
kind DocumentKind
|
||||
fileName String
|
||||
storageKey String @unique // clé MinIO (FileStorage)
|
||||
size Int
|
||||
contentType String
|
||||
assetId String? @db.Uuid
|
||||
asset Asset? @relation(fields: [assetId], references: [id])
|
||||
workOrderId String? @db.Uuid
|
||||
workOrder WorkOrder? @relation(fields: [workOrderId], references: [id])
|
||||
uploadedById String? @db.Uuid
|
||||
uploadedBy User? @relation(fields: [uploadedById], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([assetId])
|
||||
@@index([workOrderId])
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
);
|
||||
await seedUsers(prisma, roleIds, passwordHash);
|
||||
await seedReferentiel(prisma);
|
||||
await seedExploitation(prisma);
|
||||
await seedGestion(prisma);
|
||||
}
|
||||
|
||||
async function seedUsers(
|
||||
@@ -358,6 +360,409 @@ 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 })),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ————— R3 — Gestion (tiers, pièces, mouvements, BC, taux — données maquette) —————
|
||||
|
||||
const PARTNERS: {
|
||||
name: string; kind: 'SUPPLIER' | 'CLIENT'; contactName?: string;
|
||||
phone?: string; email?: string; city?: string;
|
||||
}[] = [
|
||||
{ name: 'Ascentech Maroc', kind: 'SUPPLIER', contactName: 'M. Berrini', phone: '05 22 34 56 78', city: 'Casablanca' },
|
||||
{ name: 'SchindlerParts', kind: 'SUPPLIER', email: 'commandes@schindlerparts.ma' },
|
||||
{ name: 'Lubmaroc', kind: 'SUPPLIER', phone: '05 22 11 22 33' },
|
||||
{ name: 'Atlas Property Management', kind: 'CLIENT', contactName: 'Mme Zerhouni', phone: '06 61 98 76 54' },
|
||||
{ name: 'Syndic Al Manar', kind: 'CLIENT', contactName: 'M. Alami (gardien référent)' },
|
||||
];
|
||||
|
||||
const PARTS: {
|
||||
reference: string; designation: string; threshold: number;
|
||||
lastUnitPrice?: number; supplier?: string; compatible?: string;
|
||||
}[] = [
|
||||
{ reference: 'P-0019', designation: 'Cellule barrière porte (paire)', threshold: 4, lastUnitPrice: 640, supplier: 'SchindlerParts' },
|
||||
{ reference: 'P-0031', designation: 'Bouton palier lumineux Ø22', threshold: 10, lastUnitPrice: 45, supplier: 'Ascentech Maroc' },
|
||||
{ reference: 'P-0042', designation: 'Graisse guide (cartouche 400 g)', threshold: 8, lastUnitPrice: 85, supplier: 'Lubmaroc' },
|
||||
{ reference: 'P-0087', designation: 'Coulisseau de guide 16 mm', threshold: 6, lastUnitPrice: 120, supplier: 'Ascentech Maroc' },
|
||||
{ reference: 'P-0113', designation: 'Contact de porte NC-31', threshold: 5, lastUnitPrice: 85, supplier: 'Ascentech Maroc', compatible: 'Otis Gen2 · Schindler 3300' },
|
||||
];
|
||||
|
||||
async function seedGestion(prisma: PrismaClient): Promise<void> {
|
||||
const partnerIds = new Map<string, string>();
|
||||
for (const p of PARTNERS) {
|
||||
const partner = await prisma.partner.upsert({
|
||||
where: { name: p.name },
|
||||
update: {},
|
||||
create: p,
|
||||
});
|
||||
partnerIds.set(p.name, partner.id);
|
||||
}
|
||||
|
||||
// Rattachements de la maquette Tiers : les syndics gèrent leur site.
|
||||
for (const [site, partner] of [
|
||||
['Tour Atlas', 'Atlas Property Management'],
|
||||
['Résidence Al Manar', 'Syndic Al Manar'],
|
||||
] as const) {
|
||||
await prisma.location.updateMany({
|
||||
where: { name: site, parentId: null, partnerId: null },
|
||||
data: { partnerId: partnerIds.get(partner) },
|
||||
});
|
||||
}
|
||||
|
||||
const partIds = new Map<string, string>();
|
||||
for (const p of PARTS) {
|
||||
const part = await prisma.part.upsert({
|
||||
where: { reference: p.reference },
|
||||
update: {},
|
||||
create: {
|
||||
reference: p.reference,
|
||||
designation: p.designation,
|
||||
threshold: p.threshold,
|
||||
lastUnitPrice: p.lastUnitPrice,
|
||||
compatible: p.compatible,
|
||||
supplierId: p.supplier ? partnerIds.get(p.supplier) : undefined,
|
||||
},
|
||||
});
|
||||
partIds.set(p.reference, part.id);
|
||||
}
|
||||
|
||||
// Taux horaires courants (les saisies figent leur propre taux)
|
||||
for (const [email, taux] of [
|
||||
['technicien@demo.siop.ma', 120],
|
||||
['technicien-limite@demo.siop.ma', 90],
|
||||
] as const) {
|
||||
await prisma.user.update({ where: { email }, data: { hourlyRate: taux } });
|
||||
}
|
||||
|
||||
// Idempotence : si des mouvements existent déjà, l'histoire est en place
|
||||
if ((await prisma.stockMovement.count()) > 0) return;
|
||||
|
||||
const annee = new Date().getFullYear();
|
||||
const ahmed = await prisma.user.findUniqueOrThrow({ where: { email: 'technicien@demo.siop.ma' } });
|
||||
const nadia = await prisma.user.findUniqueOrThrow({ where: { email: 'gestionnaire@demo.siop.ma' } });
|
||||
|
||||
// BC reçus (historique) et BC en cours — comme la maquette
|
||||
const bc21 = await prisma.purchaseOrder.create({
|
||||
data: {
|
||||
reference: `BC-${annee}-0021`,
|
||||
status: 'RECEIVED',
|
||||
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||||
createdById: nadia.id,
|
||||
sentAt: new Date(Date.now() - 40 * 86400e3),
|
||||
receivedAt: new Date(Date.now() - 34 * 86400e3),
|
||||
lines: {
|
||||
create: [
|
||||
{ partId: partIds.get('P-0113')!, quantity: 5, unitPrice: 85 },
|
||||
{ partId: partIds.get('P-0087')!, quantity: 6, unitPrice: 120 },
|
||||
{ partId: partIds.get('P-0031')!, quantity: 20, unitPrice: 45 },
|
||||
],
|
||||
},
|
||||
},
|
||||
include: { lines: true },
|
||||
});
|
||||
await prisma.purchaseOrder.create({
|
||||
data: {
|
||||
reference: `BC-${annee}-0024`,
|
||||
status: 'SENT',
|
||||
supplierId: partnerIds.get('Ascentech Maroc')!,
|
||||
createdById: nadia.id,
|
||||
sentAt: new Date(),
|
||||
lines: {
|
||||
create: [
|
||||
{ partId: partIds.get('P-0113')!, quantity: 10, unitPrice: 85 },
|
||||
{ partId: partIds.get('P-0087')!, quantity: 2, unitPrice: 127.5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Mouvements : réceptions du BC-0021 + entrées initiales + consommations
|
||||
for (const line of bc21.lines) {
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: line.partId,
|
||||
kind: 'RECEIPT',
|
||||
quantity: line.quantity,
|
||||
unitPrice: line.unitPrice,
|
||||
purchaseOrderId: bc21.id,
|
||||
byId: nadia.id,
|
||||
createdAt: new Date(Date.now() - 34 * 86400e3),
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const [ref, qte] of [
|
||||
['P-0042', 19],
|
||||
['P-0019', 7],
|
||||
['P-0031', 6],
|
||||
] as const) {
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: partIds.get(ref)!,
|
||||
kind: 'ENTRY',
|
||||
quantity: qte,
|
||||
reason: 'Reprise de l’inventaire initial',
|
||||
byId: nadia.id,
|
||||
createdAt: new Date(Date.now() - 60 * 86400e3),
|
||||
},
|
||||
});
|
||||
}
|
||||
// P-0113 : consommations + ajustement → stock 2 (sous le seuil, maquette)
|
||||
const ot341 = await prisma.workOrder.findUnique({
|
||||
where: { reference: `OT-${annee}-0341` },
|
||||
});
|
||||
await prisma.stockMovement.createMany({
|
||||
data: [
|
||||
{ partId: partIds.get('P-0113')!, kind: 'ADJUSTMENT', quantity: -1, reason: 'Inventaire — pièce endommagée', byId: nadia.id },
|
||||
{ partId: partIds.get('P-0113')!, kind: 'CONSUMPTION', quantity: -2, unitPrice: 85, byId: ahmed.id },
|
||||
],
|
||||
});
|
||||
// P-0087 : consommation sur OT-0341 (carte maquette : 2 × 120 = 240 MAD)
|
||||
if (ot341) {
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: partIds.get('P-0087')!,
|
||||
kind: 'CONSUMPTION',
|
||||
quantity: -2,
|
||||
unitPrice: 120,
|
||||
workOrderId: ot341.id,
|
||||
byId: ahmed.id,
|
||||
},
|
||||
});
|
||||
await prisma.stockMovement.create({
|
||||
data: {
|
||||
partId: partIds.get('P-0042')!,
|
||||
kind: 'CONSUMPTION',
|
||||
quantity: -1,
|
||||
unitPrice: 85,
|
||||
workOrderId: ot341.id,
|
||||
byId: ahmed.id,
|
||||
},
|
||||
});
|
||||
// main-d'œuvre : 1 h 30 × 120 MAD/h = 180 (total maquette : 505 MAD)
|
||||
await prisma.laborTime.create({
|
||||
data: { workOrderId: ot341.id, userId: ahmed.id, minutes: 90, hourlyRate: 120 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start — wrapper CLI */
|
||||
if (require.main === module) {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
14
apps/api/src/analytics/analytics.controller.ts
Normal file
14
apps/api/src/analytics/analytics.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly analytics: AnalyticsService) {}
|
||||
|
||||
@Get('summary')
|
||||
@RequirePermission('ANALYTICS', 'view')
|
||||
summary(@Query('months') months?: string) {
|
||||
return this.analytics.summary(months);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/analytics/analytics.module.ts
Normal file
9
apps/api/src/analytics/analytics.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AnalyticsController],
|
||||
providers: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule {}
|
||||
201
apps/api/src/analytics/analytics.service.ts
Normal file
201
apps/api/src/analytics/analytics.service.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ANALYTICS_PERIODS, type AnalyticsPeriod, type AnalyticsSummary } from '@siop/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const debutMois = (decalage: number): Date => {
|
||||
const d = new Date();
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - decalage, 1));
|
||||
};
|
||||
|
||||
/** Le tableau de la direction — tout est DÉRIVÉ des données réelles :
|
||||
* mouvements (prix figés), main-d'œuvre (taux figés), bilans codés. */
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async summary(monthsRaw?: string): Promise<AnalyticsSummary> {
|
||||
// Période demandée par l'écran (3, 6 ou 12 mois) — 12 par défaut.
|
||||
const months: AnalyticsPeriod = ANALYTICS_PERIODS.find(
|
||||
(p) => p === Number(monthsRaw),
|
||||
) ?? 12;
|
||||
const depuisPeriode = debutMois(months - 1);
|
||||
|
||||
const [consommations, mainOeuvre, clos12m, grilles12m, correctifsClos, pannes, topDonnees] =
|
||||
await Promise.all([
|
||||
this.prisma.stockMovement.findMany({
|
||||
where: { kind: 'CONSUMPTION', createdAt: { gte: depuisPeriode } },
|
||||
select: { quantity: true, unitPrice: true, createdAt: true },
|
||||
}),
|
||||
this.prisma.laborTime.findMany({
|
||||
where: { createdAt: { gte: depuisPeriode } },
|
||||
select: { minutes: true, hourlyRate: true, createdAt: true },
|
||||
}),
|
||||
this.prisma.workOrder.groupBy({
|
||||
by: ['type'],
|
||||
where: { status: 'DONE', completedAt: { gte: depuisPeriode } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.workOrder.findMany({
|
||||
where: { periodKey: { not: null }, createdAt: { gte: depuisPeriode } },
|
||||
select: { status: true },
|
||||
}),
|
||||
this.prisma.workOrder.findMany({
|
||||
where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuisPeriode } },
|
||||
select: { createdAt: true, completedAt: true },
|
||||
}),
|
||||
this.prisma.interventionReport.groupBy({
|
||||
by: ['componentConcernedId'],
|
||||
where: {
|
||||
componentConcernedId: { not: null },
|
||||
workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuisPeriode } },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.stockMovement.findMany({
|
||||
where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuisPeriode } },
|
||||
select: {
|
||||
quantity: true,
|
||||
unitPrice: true,
|
||||
workOrder: {
|
||||
select: {
|
||||
type: true,
|
||||
asset: {
|
||||
select: {
|
||||
reference: true,
|
||||
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Coûts par mois (6 derniers) — pièces + main-d'œuvre, prix/taux figés
|
||||
const cleMois = (d: Date) => d.toISOString().slice(0, 7);
|
||||
const parMois = new Map<string, number>();
|
||||
for (let i = months - 1; i >= 0; i--) parMois.set(cleMois(debutMois(i)), 0);
|
||||
for (const c of consommations) {
|
||||
const cle = cleMois(c.createdAt);
|
||||
if (parMois.has(cle)) {
|
||||
parMois.set(cle, parMois.get(cle)! + -c.quantity * Number(c.unitPrice ?? 0));
|
||||
}
|
||||
}
|
||||
for (const l of mainOeuvre) {
|
||||
const cle = cleMois(l.createdAt);
|
||||
if (parMois.has(cle)) {
|
||||
parMois.set(cle, parMois.get(cle)! + (l.minutes / 60) * Number(l.hourlyRate));
|
||||
}
|
||||
}
|
||||
const costsByMonth = [...parMois.entries()].map(([month, total]) => ({
|
||||
month,
|
||||
total: Math.round(total * 100) / 100,
|
||||
}));
|
||||
const moisCourant = cleMois(new Date());
|
||||
const moisPrecedent = cleMois(debutMois(1));
|
||||
|
||||
// Pannes par organe : depuis les bilans codés
|
||||
const labels = await this.prisma.referenceValue.findMany({
|
||||
where: { id: { in: pannes.map((p) => p.componentConcernedId!).filter(Boolean) } },
|
||||
});
|
||||
const labelDe = new Map(labels.map((l) => [l.id, l.label]));
|
||||
const failuresByComponent = pannes
|
||||
.map((p) => ({
|
||||
label: labelDe.get(p.componentConcernedId!) ?? '—',
|
||||
count: p._count._all,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 6);
|
||||
|
||||
// Top équipements en coût (pièces via OT + main-d'œuvre)
|
||||
const laborParOT = await this.prisma.laborTime.findMany({
|
||||
where: { createdAt: { gte: depuisPeriode } },
|
||||
select: {
|
||||
minutes: true,
|
||||
hourlyRate: true,
|
||||
workOrder: {
|
||||
select: {
|
||||
type: true,
|
||||
asset: {
|
||||
select: {
|
||||
reference: true,
|
||||
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
interface Cumul { siteName: string; correctives: number; partsCost: number; laborCost: number }
|
||||
const parAppareil = new Map<string, Cumul>();
|
||||
const cumul = (ref: string, siteName: string): Cumul => {
|
||||
if (!parAppareil.has(ref)) {
|
||||
parAppareil.set(ref, { siteName, correctives: 0, partsCost: 0, laborCost: 0 });
|
||||
}
|
||||
return parAppareil.get(ref)!;
|
||||
};
|
||||
for (const m of topDonnees) {
|
||||
const asset = m.workOrder!.asset;
|
||||
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||
c.partsCost += -m.quantity * Number(m.unitPrice ?? 0);
|
||||
}
|
||||
for (const l of laborParOT) {
|
||||
const asset = l.workOrder.asset;
|
||||
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||
c.laborCost += (l.minutes / 60) * Number(l.hourlyRate);
|
||||
}
|
||||
const correctivesParAppareil = await this.prisma.workOrder.groupBy({
|
||||
by: ['assetId'],
|
||||
where: { type: 'CORRECTIVE', createdAt: { gte: depuisPeriode } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const assetsRefs = await this.prisma.asset.findMany({
|
||||
where: { id: { in: correctivesParAppareil.map((c) => c.assetId) } },
|
||||
select: { id: true, reference: true },
|
||||
});
|
||||
const refDe = new Map(assetsRefs.map((a) => [a.id, a.reference]));
|
||||
for (const c of correctivesParAppareil) {
|
||||
const ref = refDe.get(c.assetId);
|
||||
if (ref && parAppareil.has(ref)) parAppareil.get(ref)!.correctives = c._count._all;
|
||||
}
|
||||
const topAssets = [...parAppareil.entries()]
|
||||
.map(([reference, c]) => ({
|
||||
reference,
|
||||
siteName: c.siteName,
|
||||
correctives: c.correctives,
|
||||
partsCost: Math.round(c.partsCost * 100) / 100,
|
||||
laborCost: Math.round(c.laborCost * 100) / 100,
|
||||
total: Math.round((c.partsCost + c.laborCost) * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
.slice(0, 5);
|
||||
|
||||
const totalClos = clos12m.reduce((s, g) => s + g._count._all, 0);
|
||||
const preventifsClos = clos12m.find((g) => g.type === 'PREVENTIVE')?._count._all ?? 0;
|
||||
const grillesTerminees = grilles12m.filter((g) => g.status === 'DONE').length;
|
||||
|
||||
return {
|
||||
months,
|
||||
monthCost: costsByMonth.find((c) => c.month === moisCourant)?.total ?? 0,
|
||||
previousMonthCost: costsByMonth.find((c) => c.month === moisPrecedent)?.total ?? 0,
|
||||
closed: { total: totalClos, preventive: preventifsClos },
|
||||
preventiveRate: grilles12m.length
|
||||
? Math.round((grillesTerminees / grilles12m.length) * 100) / 100
|
||||
: null,
|
||||
avgResolutionDays: correctifsClos.length
|
||||
? Math.round(
|
||||
(correctifsClos.reduce(
|
||||
(s, w) => s + (w.completedAt!.getTime() - w.createdAt.getTime()),
|
||||
0,
|
||||
) /
|
||||
correctifsClos.length /
|
||||
86400e3) *
|
||||
10,
|
||||
) / 10
|
||||
: null,
|
||||
failuresByComponent,
|
||||
costsByMonth,
|
||||
topAssets,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AnalyticsModule } from './analytics/analytics.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { AssetsModule } from './assets/assets.module';
|
||||
import { DocumentsModule } from './documents/documents.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
@@ -9,7 +12,16 @@ import { demoModeEnabled } from './config/env';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { LocationsModule } from './locations/locations.module';
|
||||
import { MetersModule } from './meters/meters.module';
|
||||
import { PartnersModule } from './partners/partners.module';
|
||||
import { PartsModule } from './parts/parts.module';
|
||||
import { PortalModule } from './portal/portal.module';
|
||||
import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module';
|
||||
import { PreventiveModule } from './preventive/preventive.module';
|
||||
import { ReferenceValuesModule } from './reference-values/reference-values.module';
|
||||
import { RequestsModule } from './requests/requests.module';
|
||||
import { TeamsModule } from './teams/teams.module';
|
||||
import { WorkOrdersModule } from './work-orders/work-orders.module';
|
||||
import { PermissionsGuard } from './permissions/permissions.guard';
|
||||
import { PermissionsModule } from './permissions/permissions.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
@@ -37,6 +49,20 @@ export class AppModule {
|
||||
LocationsModule,
|
||||
AssetsModule,
|
||||
TeamsModule,
|
||||
// R2 — exploitation
|
||||
WorkOrdersModule,
|
||||
RequestsModule,
|
||||
ReferenceValuesModule,
|
||||
PreventiveModule,
|
||||
MetersModule,
|
||||
PortalModule,
|
||||
// R3 — gestion
|
||||
PartnersModule,
|
||||
PartsModule,
|
||||
PurchaseOrdersModule,
|
||||
DocumentsModule,
|
||||
AnalyticsModule,
|
||||
SearchModule,
|
||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||
],
|
||||
|
||||
@@ -31,6 +31,13 @@ export class AssetsController {
|
||||
return this.assetsService.list();
|
||||
}
|
||||
|
||||
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
|
||||
* doit pouvoir désigner l'appareil qu'il signale. */
|
||||
@Get('options')
|
||||
options() {
|
||||
return this.assetsService.options();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('ASSETS', 'view')
|
||||
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AssetCreate,
|
||||
AssetDetail,
|
||||
AssetDto,
|
||||
AssetOptionsResponse,
|
||||
AssetsResponse,
|
||||
AssetUpdate,
|
||||
} from '@siop/shared';
|
||||
@@ -28,6 +29,21 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
||||
export class AssetsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
|
||||
async options(): Promise<AssetOptionsResponse> {
|
||||
const rows = await this.prisma.asset.findMany({
|
||||
include: { location: { include: { parent: true } } },
|
||||
orderBy: { reference: 'asc' },
|
||||
});
|
||||
return {
|
||||
options: rows.map((r) => ({
|
||||
id: r.id,
|
||||
reference: r.reference,
|
||||
siteName: r.location.parent?.name ?? r.location.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async list(): Promise<AssetsResponse> {
|
||||
const rows = await this.prisma.asset.findMany({
|
||||
include: assetInclude,
|
||||
|
||||
@@ -13,6 +13,7 @@ const EnvSchema = z.object({
|
||||
MINIO_USE_SSL: z.string().optional(),
|
||||
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||
MINIO_BUCKET: z.string().default('siop2'),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
77
apps/api/src/documents/documents.controller.ts
Normal file
77
apps/api/src/documents/documents.controller.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
StreamableFile,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { DOCUMENT_MAX_BYTES } from '@siop/shared';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '../auth/current-user.decorator';
|
||||
import { DocumentsService } from './documents.service';
|
||||
|
||||
@Controller('documents')
|
||||
export class DocumentsController {
|
||||
constructor(private readonly documents: DocumentsService) {}
|
||||
|
||||
/** Bibliothèque interne — authentification seule (les cartes « Documents »
|
||||
* des fiches la consomment). */
|
||||
@Get()
|
||||
list(
|
||||
@Query('assetId') assetId?: string,
|
||||
@Query('workOrderId') workOrderId?: string,
|
||||
@Query('kind') kind?: string,
|
||||
) {
|
||||
return this.documents.list({ assetId, workOrderId, kind });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', { limits: { fileSize: DOCUMENT_MAX_BYTES } }),
|
||||
)
|
||||
upload(
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body() body: { kind?: string; assetId?: string; workOrderId?: string },
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('Aucun fichier reçu (champ « file »)');
|
||||
return this.documents.upload(
|
||||
{
|
||||
buffer: file.buffer,
|
||||
originalName: file.originalname,
|
||||
contentType: file.mimetype,
|
||||
size: file.size,
|
||||
kind: body.kind ?? 'OTHER',
|
||||
assetId: body.assetId || undefined,
|
||||
workOrderId: body.workOrderId || undefined,
|
||||
},
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/download')
|
||||
async download(@Param('id', ParseUUIDPipe) id: string): Promise<StreamableFile> {
|
||||
const { stream, fileName, contentType } = await this.documents.download(id);
|
||||
return new StreamableFile(stream as never, {
|
||||
type: contentType,
|
||||
disposition: `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
||||
return this.documents.remove(id, user);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/documents/documents.module.ts
Normal file
9
apps/api/src/documents/documents.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DocumentsController } from './documents.controller';
|
||||
import { DocumentsService } from './documents.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DocumentsController],
|
||||
providers: [DocumentsService],
|
||||
})
|
||||
export class DocumentsModule {}
|
||||
160
apps/api/src/documents/documents.service.ts
Normal file
160
apps/api/src/documents/documents.service.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
DOCUMENT_CONTENT_TYPES,
|
||||
DOCUMENT_KINDS,
|
||||
DOCUMENT_MAX_BYTES,
|
||||
type DocumentDto,
|
||||
type DocumentKind,
|
||||
type DocumentsResponse,
|
||||
} from '@siop/shared';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { FILE_STORAGE, type FileStorage } from '../files/file-storage';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const documentInclude = {
|
||||
asset: { select: { reference: true } },
|
||||
workOrder: { select: { reference: true } },
|
||||
uploadedBy: { select: { displayName: true } },
|
||||
} satisfies Prisma.DocumentInclude;
|
||||
|
||||
type Row = Prisma.DocumentGetPayload<{ include: typeof documentInclude }>;
|
||||
|
||||
export interface UploadInput {
|
||||
buffer: Buffer;
|
||||
originalName: string;
|
||||
contentType: string;
|
||||
size: number;
|
||||
kind: string;
|
||||
assetId?: string;
|
||||
workOrderId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocumentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
@Inject(FILE_STORAGE) private readonly storage: FileStorage,
|
||||
) {}
|
||||
|
||||
async list(filtres: {
|
||||
assetId?: string;
|
||||
workOrderId?: string;
|
||||
kind?: string;
|
||||
}): Promise<DocumentsResponse> {
|
||||
const rows = await this.prisma.document.findMany({
|
||||
where: {
|
||||
assetId: filtres.assetId || undefined,
|
||||
workOrderId: filtres.workOrderId || undefined,
|
||||
kind: (filtres.kind as DocumentKind) || undefined,
|
||||
},
|
||||
include: documentInclude,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return { documents: rows.map((r) => this.toDto(r)) };
|
||||
}
|
||||
|
||||
/** Upload : types fermés, 20 Mo max, rattachement REQUIS, permission
|
||||
* d'édition sur la CIBLE (appareil → ASSETS, OT → WORK_ORDERS). */
|
||||
async upload(input: UploadInput, user: AuthenticatedUser): Promise<DocumentDto> {
|
||||
if (!(DOCUMENT_KINDS as readonly string[]).includes(input.kind)) {
|
||||
throw new BadRequestException('Type de document inconnu');
|
||||
}
|
||||
if (!(DOCUMENT_CONTENT_TYPES as readonly string[]).includes(input.contentType)) {
|
||||
throw new BadRequestException('Format refusé — PDF, JPG ou PNG uniquement');
|
||||
}
|
||||
if (input.size > DOCUMENT_MAX_BYTES) {
|
||||
throw new BadRequestException('Fichier trop lourd — 20 Mo maximum');
|
||||
}
|
||||
if (!input.assetId && !input.workOrderId) {
|
||||
throw new BadRequestException('Rattachez le document à un appareil ou à un OT');
|
||||
}
|
||||
await this.assertCible(input.assetId, input.workOrderId, user, 'edit');
|
||||
|
||||
const storageKey = `documents/${randomUUID()}/${input.originalName.replace(/[^\w.\-()À-ſ ]/g, '_')}`;
|
||||
await this.storage.putObject(storageKey, input.buffer, input.contentType);
|
||||
const created = await this.prisma.document.create({
|
||||
data: {
|
||||
kind: input.kind as DocumentKind,
|
||||
fileName: input.originalName,
|
||||
storageKey,
|
||||
size: input.size,
|
||||
contentType: input.contentType,
|
||||
assetId: input.assetId,
|
||||
workOrderId: input.workOrderId,
|
||||
uploadedById: user.userId,
|
||||
},
|
||||
include: documentInclude,
|
||||
});
|
||||
return this.toDto(created);
|
||||
}
|
||||
|
||||
async download(id: string): Promise<{
|
||||
stream: NodeJS.ReadableStream;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
}> {
|
||||
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException('Document inconnu');
|
||||
return {
|
||||
stream: await this.storage.getObjectStream(doc.storageKey),
|
||||
fileName: doc.fileName,
|
||||
contentType: doc.contentType,
|
||||
};
|
||||
}
|
||||
|
||||
async remove(id: string, user: AuthenticatedUser): Promise<void> {
|
||||
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||
if (!doc) throw new NotFoundException('Document inconnu');
|
||||
await this.assertCible(doc.assetId ?? undefined, doc.workOrderId ?? undefined, user, 'edit');
|
||||
await this.prisma.document.delete({ where: { id } });
|
||||
await this.storage.removeObject(doc.storageKey).catch(() => {
|
||||
// le stockage peut être momentanément injoignable — la base fait foi
|
||||
});
|
||||
}
|
||||
|
||||
private async assertCible(
|
||||
assetId: string | undefined,
|
||||
workOrderId: string | undefined,
|
||||
user: AuthenticatedUser,
|
||||
right: 'edit',
|
||||
): Promise<void> {
|
||||
if (assetId) {
|
||||
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||
if (!asset) throw new BadRequestException('Appareil inconnu');
|
||||
if (!(await this.permissions.can(user.roleId, 'ASSETS', right))) {
|
||||
throw new ForbiddenException('Droit manquant : ASSETS.edit');
|
||||
}
|
||||
}
|
||||
if (workOrderId) {
|
||||
const ot = await this.prisma.workOrder.findUnique({ where: { id: workOrderId } });
|
||||
if (!ot) throw new BadRequestException('OT inconnu');
|
||||
if (!(await this.permissions.can(user.roleId, 'WORK_ORDERS', right))) {
|
||||
throw new ForbiddenException('Droit manquant : WORK_ORDERS.edit');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: Row): DocumentDto {
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
fileName: row.fileName,
|
||||
size: row.size,
|
||||
contentType: row.contentType,
|
||||
assetReference: row.asset?.reference ?? null,
|
||||
workOrderReference: row.workOrder?.reference ?? null,
|
||||
uploadedByName: row.uploadedBy?.displayName ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
|
||||
* ailleurs que dans son implémentation — le reste de l'API dépend de cette
|
||||
* interface (règle lint à venir en R0.12). R0 : santé seulement ;
|
||||
* les opérations arrivent avec les photos/documents (R2-R3).
|
||||
* ailleurs que dans son implémentation (règle ESLint) — le reste de l'API
|
||||
* dépend de cette interface. R3 : la bibliothèque de documents l'utilise ;
|
||||
* les téléchargements sont STREAMÉS par l'API (MinIO n'est pas exposé).
|
||||
*/
|
||||
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
||||
|
||||
export interface FileStorage {
|
||||
/** Lève une exception si le stockage est injoignable. */
|
||||
healthCheck(): Promise<void>;
|
||||
putObject(key: string, body: Buffer, contentType: string): Promise<void>;
|
||||
getObjectStream(key: string): Promise<NodeJS.ReadableStream>;
|
||||
removeObject(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import * as Minio from 'minio';
|
||||
import { loadEnv } from '../config/env';
|
||||
import type { FileStorage } from './file-storage';
|
||||
|
||||
/** Seul fichier du dépôt autorisé à importer le SDK MinIO. */
|
||||
/** Seul fichier du dépôt autorisé à importer le SDK MinIO (règle ESLint). */
|
||||
@Injectable()
|
||||
export class MinioStorageService implements FileStorage {
|
||||
export class MinioStorageService implements FileStorage, OnModuleInit {
|
||||
private readonly logger = new Logger(MinioStorageService.name);
|
||||
private readonly client: Minio.Client;
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor() {
|
||||
const env = loadEnv();
|
||||
this.bucket = env.MINIO_BUCKET;
|
||||
this.client = new Minio.Client({
|
||||
endPoint: env.MINIO_ENDPOINT,
|
||||
port: env.MINIO_PORT,
|
||||
@@ -19,7 +22,34 @@ export class MinioStorageService implements FileStorage {
|
||||
});
|
||||
}
|
||||
|
||||
/** Le bucket est créé au démarrage — aucune étape manuelle au déploiement. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
if (!(await this.client.bucketExists(this.bucket))) {
|
||||
await this.client.makeBucket(this.bucket);
|
||||
this.logger.log(`Bucket « ${this.bucket} » créé.`);
|
||||
}
|
||||
} catch (e) {
|
||||
// Le stockage peut être en retard au boot : /health le signalera.
|
||||
this.logger.warn(`Stockage indisponible au démarrage : ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<void> {
|
||||
await this.client.listBuckets();
|
||||
}
|
||||
|
||||
async putObject(key: string, body: Buffer, contentType: string): Promise<void> {
|
||||
await this.client.putObject(this.bucket, key, body, body.length, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
}
|
||||
|
||||
async getObjectStream(key: string): Promise<NodeJS.ReadableStream> {
|
||||
return this.client.getObject(this.bucket, key);
|
||||
}
|
||||
|
||||
async removeObject(key: string): Promise<void> {
|
||||
await this.client.removeObject(this.bucket, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,16 @@ type LocationRow = {
|
||||
guardianPhone: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
partnerId: string | null;
|
||||
partner: { name: string } | null;
|
||||
_count: { assets: number };
|
||||
};
|
||||
|
||||
const locationInclude = {
|
||||
partner: { select: { name: true } },
|
||||
_count: { select: { assets: true } },
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class LocationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -31,7 +38,7 @@ export class LocationsService {
|
||||
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
|
||||
async list(): Promise<LocationsResponse> {
|
||||
const rows: LocationRow[] = await this.prisma.location.findMany({
|
||||
include: { _count: { select: { assets: true } } },
|
||||
include: locationInclude,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const childAssets = new Map<string, number>();
|
||||
@@ -52,9 +59,10 @@ export class LocationsService {
|
||||
|
||||
async create(dto: LocationCreate): Promise<LocationDto> {
|
||||
await this.assertDepth(dto.parentId);
|
||||
await this.assertClientPartner(dto.partnerId);
|
||||
const created = await this.prisma.location.create({
|
||||
data: dto,
|
||||
include: { _count: { select: { assets: true } } },
|
||||
include: locationInclude,
|
||||
});
|
||||
return this.toDto(created, 0);
|
||||
}
|
||||
@@ -76,10 +84,11 @@ export class LocationsService {
|
||||
}
|
||||
await this.assertDepth(dto.parentId);
|
||||
}
|
||||
await this.assertClientPartner(dto.partnerId);
|
||||
const updated = await this.prisma.location.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
include: { _count: { select: { assets: true } } },
|
||||
include: locationInclude,
|
||||
});
|
||||
return this.toDto(updated, updated._count.assets);
|
||||
}
|
||||
@@ -96,6 +105,16 @@ export class LocationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** R3 : seul un tiers Client/syndic peut gérer un site. */
|
||||
private async assertClientPartner(partnerId?: string | null): Promise<void> {
|
||||
if (!partnerId) return;
|
||||
const partner = await this.prisma.partner.findUnique({ where: { id: partnerId } });
|
||||
if (!partner) throw new BadRequestException('Tiers inconnu');
|
||||
if (partner.kind !== 'CLIENT') {
|
||||
throw new BadRequestException('Seul un tiers « Client / syndic » peut être rattaché à un site');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -107,6 +126,8 @@ export class LocationsService {
|
||||
guardianPhone: row.guardianPhone,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
partnerId: row.partnerId,
|
||||
partnerName: row.partner?.name ?? null,
|
||||
assetCount,
|
||||
};
|
||||
}
|
||||
|
||||
40
apps/api/src/meters/meters.controller.ts
Normal file
40
apps/api/src/meters/meters.controller.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
MeterReadingCreateSchema,
|
||||
type MeterReadingCreate,
|
||||
} from '@siop/shared';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { MetersService } from './meters.service';
|
||||
|
||||
@Controller('assets/:id')
|
||||
export class MetersController {
|
||||
constructor(private readonly meters: MetersService) {}
|
||||
|
||||
@Get('meters')
|
||||
@RequirePermission('METERS', 'view')
|
||||
list(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.meters.list(id);
|
||||
}
|
||||
|
||||
@Post('meter-readings')
|
||||
@RequirePermission('METERS', 'create')
|
||||
addReading(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(MeterReadingCreateSchema)) body: MeterReadingCreate,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.meters.addReading(id, body, user);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/meters/meters.module.ts
Normal file
9
apps/api/src/meters/meters.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MetersController } from './meters.controller';
|
||||
import { MetersService } from './meters.service';
|
||||
|
||||
@Module({
|
||||
controllers: [MetersController],
|
||||
providers: [MetersService],
|
||||
})
|
||||
export class MetersModule {}
|
||||
77
apps/api/src/meters/meters.service.ts
Normal file
77
apps/api/src/meters/meters.service.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { METER_KINDS, type MeterReadingCreate, type MetersResponse } from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class MetersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(assetId: string): Promise<MetersResponse> {
|
||||
await this.assertAsset(assetId);
|
||||
const meters = await this.prisma.meter.findMany({
|
||||
where: { assetId },
|
||||
include: {
|
||||
readings: {
|
||||
include: { readBy: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Les deux compteurs existent toujours dans la réponse, même vides
|
||||
return {
|
||||
meters: METER_KINDS.map((kind) => {
|
||||
const meter = meters.find((m) => m.kind === kind);
|
||||
return {
|
||||
kind,
|
||||
readings:
|
||||
meter?.readings.map((r) => ({
|
||||
id: r.id,
|
||||
value: r.value,
|
||||
readBy: r.readBy
|
||||
? { id: r.readBy.id, displayName: r.readBy.displayName }
|
||||
: null,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})) ?? [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Invariant : un compteur ne redescend jamais. */
|
||||
async addReading(
|
||||
assetId: string,
|
||||
dto: MeterReadingCreate,
|
||||
user: AuthenticatedUser,
|
||||
): Promise<MetersResponse> {
|
||||
await this.assertAsset(assetId);
|
||||
const meter = await this.prisma.meter.upsert({
|
||||
where: { assetId_kind: { assetId, kind: dto.kind } },
|
||||
update: {},
|
||||
create: { assetId, kind: dto.kind },
|
||||
});
|
||||
const dernier = await this.prisma.meterReading.findFirst({
|
||||
where: { meterId: meter.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (dernier && dto.value <= dernier.value) {
|
||||
throw new BadRequestException(
|
||||
`Relevé refusé : ${dto.value} est inférieur ou égal au précédent (${dernier.value}) — un compteur ne redescend pas`,
|
||||
);
|
||||
}
|
||||
await this.prisma.meterReading.create({
|
||||
data: { meterId: meter.id, value: dto.value, readById: user.userId },
|
||||
});
|
||||
return this.list(assetId);
|
||||
}
|
||||
|
||||
private async assertAsset(assetId: string): Promise<void> {
|
||||
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||
if (!asset) throw new NotFoundException('Appareil inconnu');
|
||||
}
|
||||
}
|
||||
45
apps/api/src/partners/partners.controller.ts
Normal file
45
apps/api/src/partners/partners.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
PartnerCreateSchema,
|
||||
PartnerUpdateSchema,
|
||||
type PartnerCreate,
|
||||
type PartnerUpdate,
|
||||
} from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { PartnersService } from './partners.service';
|
||||
|
||||
/** Les tiers vivent sous la permission PURCHASE_ORDERS (décision R3). */
|
||||
@Controller('partners')
|
||||
export class PartnersController {
|
||||
constructor(private readonly partners: PartnersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||
list() {
|
||||
return this.partners.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('PURCHASE_ORDERS', 'create')
|
||||
create(@Body(new ZodValidationPipe(PartnerCreateSchema)) body: PartnerCreate) {
|
||||
return this.partners.create(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('PURCHASE_ORDERS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(PartnerUpdateSchema)) body: PartnerUpdate,
|
||||
) {
|
||||
return this.partners.update(id, body);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/partners/partners.module.ts
Normal file
9
apps/api/src/partners/partners.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PartnersController } from './partners.controller';
|
||||
import { PartnersService } from './partners.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PartnersController],
|
||||
providers: [PartnersService],
|
||||
})
|
||||
export class PartnersModule {}
|
||||
91
apps/api/src/partners/partners.service.ts
Normal file
91
apps/api/src/partners/partners.service.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
PartnerCreate,
|
||||
PartnerDto,
|
||||
PartnersResponse,
|
||||
PartnerUpdate,
|
||||
} from '@siop/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const partnerInclude = {
|
||||
_count: {
|
||||
select: {
|
||||
purchaseOrders: { where: { status: { in: ['DRAFT', 'SENT'] } } },
|
||||
},
|
||||
},
|
||||
// Rattachements de la maquette : sites (jamais les zones) gérés par un client/syndic.
|
||||
sites: {
|
||||
where: { parentId: null },
|
||||
select: { name: true },
|
||||
orderBy: { name: 'asc' as const },
|
||||
},
|
||||
} satisfies Prisma.PartnerInclude;
|
||||
|
||||
type Row = Prisma.PartnerGetPayload<{ include: typeof partnerInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class PartnersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<PartnersResponse> {
|
||||
const rows = await this.prisma.partner.findMany({
|
||||
include: partnerInclude,
|
||||
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
return { partners: rows.map((r) => this.toDto(r)) };
|
||||
}
|
||||
|
||||
async create(dto: PartnerCreate): Promise<PartnerDto> {
|
||||
try {
|
||||
const created = await this.prisma.partner.create({
|
||||
data: dto,
|
||||
include: partnerInclude,
|
||||
});
|
||||
return this.toDto(created);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce nom de tiers existe déjà');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: PartnerUpdate): Promise<PartnerDto> {
|
||||
try {
|
||||
const updated = await this.prisma.partner.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
include: partnerInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Tiers inconnu');
|
||||
}
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce nom de tiers existe déjà');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: Row): PartnerDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
contactName: row.contactName,
|
||||
phone: row.phone,
|
||||
email: row.email,
|
||||
city: row.city,
|
||||
isActive: row.isActive,
|
||||
openOrders: row._count.purchaseOrders,
|
||||
siteNames: row.sites.map((s) => s.name),
|
||||
};
|
||||
}
|
||||
}
|
||||
66
apps/api/src/parts/parts.controller.ts
Normal file
66
apps/api/src/parts/parts.controller.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
PartCreateSchema,
|
||||
PartUpdateSchema,
|
||||
StockMovementCreateSchema,
|
||||
type PartCreate,
|
||||
type PartUpdate,
|
||||
type StockMovementCreate,
|
||||
} 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 { PartsService } from './parts.service';
|
||||
|
||||
@Controller('parts')
|
||||
export class PartsController {
|
||||
constructor(private readonly parts: PartsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('PARTS', 'view')
|
||||
list() {
|
||||
return this.parts.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('PARTS', 'view')
|
||||
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.parts.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('PARTS', 'create')
|
||||
create(@Body(new ZodValidationPipe(PartCreateSchema)) body: PartCreate) {
|
||||
return this.parts.create(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('PARTS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(PartUpdateSchema)) body: PartUpdate,
|
||||
) {
|
||||
return this.parts.update(id, body);
|
||||
}
|
||||
|
||||
@Post(':id/movements')
|
||||
@RequirePermission('PARTS', 'edit')
|
||||
addMovement(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(StockMovementCreateSchema)) body: StockMovementCreate,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.parts.addMovement(id, body, user);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/parts/parts.module.ts
Normal file
10
apps/api/src/parts/parts.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PartsController } from './parts.controller';
|
||||
import { PartsService } from './parts.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PartsController],
|
||||
providers: [PartsService],
|
||||
exports: [PartsService],
|
||||
})
|
||||
export class PartsModule {}
|
||||
231
apps/api/src/parts/parts.service.ts
Normal file
231
apps/api/src/parts/parts.service.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
PartCreate,
|
||||
PartDetail,
|
||||
PartDto,
|
||||
PartsResponse,
|
||||
PartUpdate,
|
||||
StockMovementCreate,
|
||||
StockMovementDto,
|
||||
} from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const partInclude = { supplier: true } satisfies Prisma.PartInclude;
|
||||
type PartRow = Prisma.PartGetPayload<{ include: typeof partInclude }>;
|
||||
|
||||
const mvtInclude = {
|
||||
workOrder: { select: { reference: true } },
|
||||
purchaseOrder: { select: { reference: true } },
|
||||
by: { select: { displayName: true } },
|
||||
} satisfies Prisma.StockMovementInclude;
|
||||
type MvtRow = Prisma.StockMovementGetPayload<{ include: typeof mvtInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class PartsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Le stock EST la somme des mouvements — calculé, jamais stocké. */
|
||||
private async stocks(partIds?: string[]): Promise<Map<string, number>> {
|
||||
const grouped = await this.prisma.stockMovement.groupBy({
|
||||
by: ['partId'],
|
||||
where: partIds ? { partId: { in: partIds } } : undefined,
|
||||
_sum: { quantity: true },
|
||||
});
|
||||
return new Map(grouped.map((g) => [g.partId, g._sum.quantity ?? 0]));
|
||||
}
|
||||
|
||||
async list(): Promise<PartsResponse> {
|
||||
const rows = await this.prisma.part.findMany({
|
||||
include: partInclude,
|
||||
orderBy: { reference: 'asc' },
|
||||
});
|
||||
const stocks = await this.stocks();
|
||||
return { parts: rows.map((r) => this.toDto(r, stocks.get(r.id) ?? 0)) };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<PartDetail> {
|
||||
const row = await this.prisma.part.findUnique({ where: { id }, include: partInclude });
|
||||
if (!row) throw new NotFoundException('Pièce inconnue');
|
||||
const [stocks, movements] = await Promise.all([
|
||||
this.stocks([id]),
|
||||
this.prisma.stockMovement.findMany({
|
||||
where: { partId: id },
|
||||
include: mvtInclude,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
...this.toDto(row, stocks.get(id) ?? 0),
|
||||
movements: movements.map((m) => this.toMovementDto(m)),
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: PartCreate): Promise<PartDetail> {
|
||||
if (dto.supplierId) await this.assertSupplier(dto.supplierId);
|
||||
for (let essai = 0; ; essai++) {
|
||||
try {
|
||||
const created = await this.prisma.part.create({
|
||||
data: {
|
||||
reference: await this.nextReference(),
|
||||
designation: dto.designation,
|
||||
threshold: dto.threshold ?? 0,
|
||||
supplierId: dto.supplierId,
|
||||
compatible: dto.compatible,
|
||||
lastUnitPrice: dto.initialPrice,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return this.get(created.id);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002' &&
|
||||
essai < 3
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: PartUpdate): Promise<PartDetail> {
|
||||
if (dto.supplierId) await this.assertSupplier(dto.supplierId);
|
||||
try {
|
||||
await this.prisma.part.update({ where: { id }, data: dto, select: { id: true } });
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Pièce inconnue');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
/** Entrée manuelle ou ajustement — TOUJOURS un mouvement tracé. */
|
||||
async addMovement(
|
||||
id: string,
|
||||
dto: StockMovementCreate,
|
||||
user: AuthenticatedUser,
|
||||
): Promise<PartDetail> {
|
||||
const part = await this.prisma.part.findUnique({ where: { id } });
|
||||
if (!part) throw new NotFoundException('Pièce inconnue');
|
||||
if (dto.kind === 'ENTRY' && dto.quantity <= 0) {
|
||||
throw new BadRequestException('Une entrée manuelle est positive');
|
||||
}
|
||||
if (dto.kind === 'ADJUSTMENT' && !dto.reason?.trim()) {
|
||||
throw new BadRequestException('Un ajustement d’inventaire exige un motif');
|
||||
}
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const somme = await tx.stockMovement.aggregate({
|
||||
where: { partId: id },
|
||||
_sum: { quantity: true },
|
||||
});
|
||||
const stock = somme._sum.quantity ?? 0;
|
||||
if (stock + dto.quantity < 0) {
|
||||
throw new ConflictException(
|
||||
`Refusé : le stock deviendrait négatif (${stock} ${dto.quantity > 0 ? '+' : ''}${dto.quantity})`,
|
||||
);
|
||||
}
|
||||
await tx.stockMovement.create({
|
||||
data: {
|
||||
partId: id,
|
||||
kind: dto.kind,
|
||||
quantity: dto.quantity,
|
||||
reason: dto.reason,
|
||||
byId: user.userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
/** Consommation d'OT — stock suffisant exigé, PRIX FIGÉ au moment T.
|
||||
* Appelé par WorkOrdersService dans le périmètre d'un OT vérifié. */
|
||||
async consume(
|
||||
partId: string,
|
||||
quantity: number,
|
||||
workOrderId: string,
|
||||
user: AuthenticatedUser,
|
||||
): Promise<{ designation: string; unitPrice: number }> {
|
||||
const part = await this.prisma.part.findUnique({ where: { id: partId } });
|
||||
if (!part || !part.isActive) throw new BadRequestException('Pièce inconnue ou désactivée');
|
||||
if (part.lastUnitPrice === null) {
|
||||
throw new ConflictException(
|
||||
'Aucun prix connu pour cette pièce — réceptionnez un BC ou renseignez un prix initial',
|
||||
);
|
||||
}
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const somme = await tx.stockMovement.aggregate({
|
||||
where: { partId },
|
||||
_sum: { quantity: true },
|
||||
});
|
||||
const stock = somme._sum.quantity ?? 0;
|
||||
if (stock < quantity) {
|
||||
throw new ConflictException(`Stock insuffisant : ${stock} en stock, ${quantity} demandé`);
|
||||
}
|
||||
await tx.stockMovement.create({
|
||||
data: {
|
||||
partId,
|
||||
kind: 'CONSUMPTION',
|
||||
quantity: -quantity,
|
||||
unitPrice: part.lastUnitPrice,
|
||||
workOrderId,
|
||||
byId: user.userId,
|
||||
},
|
||||
});
|
||||
});
|
||||
return { designation: part.designation, unitPrice: Number(part.lastUnitPrice) };
|
||||
}
|
||||
|
||||
private async assertSupplier(supplierId: string): Promise<void> {
|
||||
const supplier = await this.prisma.partner.findUnique({ where: { id: supplierId } });
|
||||
if (!supplier || supplier.kind !== 'SUPPLIER') {
|
||||
throw new BadRequestException('Fournisseur inconnu');
|
||||
}
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('part_ref_seq')`;
|
||||
return `P-${String(nextval).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private toDto(row: PartRow, stock: number): PartDto {
|
||||
return {
|
||||
id: row.id,
|
||||
reference: row.reference,
|
||||
designation: row.designation,
|
||||
threshold: row.threshold,
|
||||
lastUnitPrice: row.lastUnitPrice === null ? null : Number(row.lastUnitPrice),
|
||||
compatible: row.compatible,
|
||||
supplierId: row.supplierId,
|
||||
supplierName: row.supplier?.name ?? null,
|
||||
isActive: row.isActive,
|
||||
stock,
|
||||
belowThreshold: stock < row.threshold,
|
||||
};
|
||||
}
|
||||
|
||||
private toMovementDto(m: MvtRow): StockMovementDto {
|
||||
return {
|
||||
id: m.id,
|
||||
kind: m.kind,
|
||||
quantity: m.quantity,
|
||||
unitPrice: m.unitPrice === null ? null : Number(m.unitPrice),
|
||||
reason: m.reason,
|
||||
workOrderReference: m.workOrder?.reference ?? null,
|
||||
purchaseOrderReference: m.purchaseOrder?.reference ?? null,
|
||||
byName: m.by?.displayName ?? null,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
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 {}
|
||||
101
apps/api/src/portal/portal.service.ts
Normal file
101
apps/api/src/portal/portal.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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 [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('request_ref_seq')`;
|
||||
return `DEM-${new Date().getFullYear()}-${String(nextval).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 {}
|
||||
229
apps/api/src/preventive/preventive.service.ts
Normal file
229
apps/api/src/preventive/preventive.service.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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 [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('work_order_ref_seq')`;
|
||||
return `OT-${new Date().getFullYear()}-${String(nextval).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,
|
||||
};
|
||||
}
|
||||
}
|
||||
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal file
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
PurchaseOrderCreateSchema,
|
||||
PurchaseOrderTransitionSchema,
|
||||
type PurchaseOrderCreate,
|
||||
type PurchaseOrderTransition,
|
||||
} 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 { PurchaseOrdersService } from './purchase-orders.service';
|
||||
|
||||
@Controller('purchase-orders')
|
||||
export class PurchaseOrdersController {
|
||||
constructor(private readonly purchaseOrders: PurchaseOrdersService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||
list() {
|
||||
return this.purchaseOrders.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.purchaseOrders.get(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('PURCHASE_ORDERS', 'create')
|
||||
create(
|
||||
@Body(new ZodValidationPipe(PurchaseOrderCreateSchema)) body: PurchaseOrderCreate,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.purchaseOrders.create(body, user);
|
||||
}
|
||||
|
||||
@Post(':id/transition')
|
||||
@HttpCode(200) // le contrat : 200, l'état change
|
||||
@RequirePermission('PURCHASE_ORDERS', 'edit')
|
||||
transition(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(PurchaseOrderTransitionSchema)) body: PurchaseOrderTransition,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.purchaseOrders.transition(id, body, user);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal file
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PurchaseOrdersController } from './purchase-orders.controller';
|
||||
import { PurchaseOrdersService } from './purchase-orders.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PurchaseOrdersController],
|
||||
providers: [PurchaseOrdersService],
|
||||
})
|
||||
export class PurchaseOrdersModule {}
|
||||
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal file
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderDto,
|
||||
PurchaseOrdersResponse,
|
||||
PurchaseOrderStatus,
|
||||
PurchaseOrderTransition,
|
||||
} from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const poInclude = {
|
||||
supplier: true,
|
||||
lines: { include: { part: true } },
|
||||
} satisfies Prisma.PurchaseOrderInclude;
|
||||
|
||||
type Row = Prisma.PurchaseOrderGetPayload<{ include: typeof poInclude }>;
|
||||
|
||||
/** Machine à états du BC : la réception est le seul chemin vers le stock. */
|
||||
const TRANSITIONS: Record<PurchaseOrderStatus, PurchaseOrderStatus[]> = {
|
||||
DRAFT: ['SENT', 'CANCELLED'],
|
||||
SENT: ['RECEIVED', 'CANCELLED'],
|
||||
RECEIVED: [],
|
||||
CANCELLED: [],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PurchaseOrdersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<PurchaseOrdersResponse> {
|
||||
const rows = await this.prisma.purchaseOrder.findMany({
|
||||
include: poInclude,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return { purchaseOrders: rows.map((r) => this.toDto(r)) };
|
||||
}
|
||||
|
||||
async get(id: string): Promise<PurchaseOrderDto> {
|
||||
const row = await this.prisma.purchaseOrder.findUnique({
|
||||
where: { id },
|
||||
include: poInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('BC inconnu');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(dto: PurchaseOrderCreate, user: AuthenticatedUser): Promise<PurchaseOrderDto> {
|
||||
const supplier = await this.prisma.partner.findUnique({
|
||||
where: { id: dto.supplierId },
|
||||
});
|
||||
if (!supplier || supplier.kind !== 'SUPPLIER' || !supplier.isActive) {
|
||||
throw new BadRequestException('Fournisseur inconnu ou inactif');
|
||||
}
|
||||
const parts = await this.prisma.part.findMany({
|
||||
where: { id: { in: dto.lines.map((l) => l.partId) } },
|
||||
});
|
||||
if (parts.length !== new Set(dto.lines.map((l) => l.partId)).size) {
|
||||
throw new BadRequestException('Pièce inconnue dans les lignes');
|
||||
}
|
||||
for (let essai = 0; ; essai++) {
|
||||
try {
|
||||
const created = await this.prisma.purchaseOrder.create({
|
||||
data: {
|
||||
reference: await this.nextReference(),
|
||||
supplierId: dto.supplierId,
|
||||
createdById: user.userId,
|
||||
lines: { create: dto.lines },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return this.get(created.id);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002' &&
|
||||
essai < 3
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async transition(
|
||||
id: string,
|
||||
dto: PurchaseOrderTransition,
|
||||
user: AuthenticatedUser,
|
||||
): Promise<PurchaseOrderDto> {
|
||||
const row = await this.prisma.purchaseOrder.findUnique({
|
||||
where: { id },
|
||||
include: poInclude,
|
||||
});
|
||||
if (!row) throw new NotFoundException('BC inconnu');
|
||||
if (!TRANSITIONS[row.status].includes(dto.to)) {
|
||||
throw new ConflictException(`Transition interdite : ${row.status} → ${dto.to}`);
|
||||
}
|
||||
if (dto.to === 'RECEIVED') {
|
||||
// LA règle : la réception crée les entrées de stock et FIGE les PU
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const line of row.lines) {
|
||||
await tx.stockMovement.create({
|
||||
data: {
|
||||
partId: line.partId,
|
||||
kind: 'RECEIPT',
|
||||
quantity: line.quantity,
|
||||
unitPrice: line.unitPrice,
|
||||
purchaseOrderId: row.id,
|
||||
byId: user.userId,
|
||||
},
|
||||
});
|
||||
await tx.part.update({
|
||||
where: { id: line.partId },
|
||||
data: { lastUnitPrice: line.unitPrice },
|
||||
});
|
||||
}
|
||||
await tx.purchaseOrder.update({
|
||||
where: { id },
|
||||
data: { status: 'RECEIVED', receivedAt: new Date() },
|
||||
});
|
||||
});
|
||||
} else {
|
||||
await this.prisma.purchaseOrder.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: dto.to,
|
||||
sentAt: dto.to === 'SENT' ? new Date() : undefined,
|
||||
cancelledAt: dto.to === 'CANCELLED' ? new Date() : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
private async nextReference(): Promise<string> {
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('purchase_order_ref_seq')`;
|
||||
return `BC-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
private toDto(row: Row): PurchaseOrderDto {
|
||||
const lines = row.lines.map((l) => ({
|
||||
id: l.id,
|
||||
partId: l.partId,
|
||||
partReference: l.part.reference,
|
||||
designation: l.part.designation,
|
||||
quantity: l.quantity,
|
||||
unitPrice: Number(l.unitPrice),
|
||||
}));
|
||||
return {
|
||||
id: row.id,
|
||||
reference: row.reference,
|
||||
status: row.status,
|
||||
supplierId: row.supplierId,
|
||||
supplierName: row.supplier.name,
|
||||
lines,
|
||||
total: lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0),
|
||||
sentAt: row.sentAt?.toISOString() ?? null,
|
||||
receivedAt: row.receivedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
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 {}
|
||||
165
apps/api/src/requests/requests.service.ts
Normal file
165
apps/api/src/requests/requests.service.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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 [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('request_ref_seq')`;
|
||||
return `DEM-${new Date().getFullYear()}-${String(nextval).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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
15
apps/api/src/search/search.controller.ts
Normal file
15
apps/api/src/search/search.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
/** Authentification seule (pas de @RequirePermission) : chaque famille de
|
||||
* résultats est filtrée par la matrice DANS le service. */
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(private readonly search: SearchService) {}
|
||||
|
||||
@Get()
|
||||
global(@CurrentUser() user: AuthenticatedUser, @Query('q') q = '') {
|
||||
return this.search.search(user, q);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/search/search.module.ts
Normal file
9
apps/api/src/search/search.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
105
apps/api/src/search/search.service.ts
Normal file
105
apps/api/src/search/search.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { SEARCH_MAX_PER_KIND, SEARCH_MIN_CHARS, type SearchResponse } from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/** Recherche globale de la topbar (⌘K). Chaque famille n'est interrogée que
|
||||
* si le rôle a la permission `view` du domaine ; les OT respectent en plus
|
||||
* l'invariant « voir autre » (même règle que la liste des OT). */
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async search(user: AuthenticatedUser, q: string): Promise<SearchResponse> {
|
||||
const terme = q.trim();
|
||||
const vide: SearchResponse = { workOrders: [], assets: [], sites: [] };
|
||||
if (terme.length < SEARCH_MIN_CHARS) return vide;
|
||||
|
||||
const [voitOT, voitParc, voitSites] = await Promise.all([
|
||||
this.permissions.can(user.roleId, 'WORK_ORDERS', 'view'),
|
||||
this.permissions.can(user.roleId, 'ASSETS', 'view'),
|
||||
this.permissions.can(user.roleId, 'LOCATIONS', 'view'),
|
||||
]);
|
||||
const contient = (champ: string): Prisma.StringFilter => ({
|
||||
contains: champ,
|
||||
mode: 'insensitive',
|
||||
});
|
||||
|
||||
const [workOrders, assets, sites] = await Promise.all([
|
||||
voitOT
|
||||
? this.prisma.workOrder.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ OR: [{ reference: contient(terme) }, { title: contient(terme) }] },
|
||||
await this.scopeOT(user),
|
||||
],
|
||||
},
|
||||
select: { id: true, reference: true, title: true, status: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
voitParc
|
||||
? this.prisma.asset.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ reference: contient(terme) },
|
||||
{ brand: contient(terme) },
|
||||
{ model: contient(terme) },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
reference: true,
|
||||
brand: true,
|
||||
model: true,
|
||||
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: { reference: 'asc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
voitSites
|
||||
? this.prisma.location.findMany({
|
||||
where: {
|
||||
parentId: null,
|
||||
OR: [{ name: contient(terme) }, { city: contient(terme) }],
|
||||
},
|
||||
select: { id: true, name: true, city: true },
|
||||
orderBy: { name: 'asc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
return {
|
||||
workOrders,
|
||||
assets: assets.map((a) => ({
|
||||
id: a.id,
|
||||
reference: a.reference,
|
||||
brand: a.brand,
|
||||
model: a.model,
|
||||
siteName: a.location.parent?.name ?? a.location.name,
|
||||
})),
|
||||
sites,
|
||||
};
|
||||
}
|
||||
|
||||
/** Même invariant que WorkOrdersService.scope — sans le droit « voir
|
||||
* autre », on ne trouve que SES OT. */
|
||||
private async scopeOT(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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,7 @@ export class UsersService {
|
||||
phone: dto.phone,
|
||||
roleId: dto.roleId,
|
||||
isActive: dto.isActive,
|
||||
hourlyRate: dto.hourlyRate,
|
||||
teams: dto.teamIds
|
||||
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
@@ -136,6 +137,7 @@ export class UsersService {
|
||||
? 'active'
|
||||
: 'invited',
|
||||
isDemo: row.isDemo,
|
||||
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
134
apps/api/src/work-orders/work-orders.controller.ts
Normal file
134
apps/api/src/work-orders/work-orders.controller.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AssigneesUpdateSchema,
|
||||
ChecklistPatchSchema,
|
||||
CommentCreateSchema,
|
||||
ConsumePartSchema,
|
||||
LaborTimeCreateSchema,
|
||||
ReportUpsertSchema,
|
||||
TransitionRequestSchema,
|
||||
WorkOrderCreateSchema,
|
||||
type AssigneesUpdate,
|
||||
type ChecklistPatch,
|
||||
type CommentCreate,
|
||||
type ConsumePart,
|
||||
type LaborTimeCreate,
|
||||
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);
|
||||
}
|
||||
|
||||
@Post(':id/consume-part')
|
||||
@RequirePermission('WORK_ORDERS', 'edit')
|
||||
consumePart(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(ConsumePartSchema)) body: ConsumePart,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.workOrders.consumePart(id, body, user);
|
||||
}
|
||||
|
||||
@Post(':id/labor')
|
||||
@RequirePermission('WORK_ORDERS', 'edit')
|
||||
addLabor(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(LaborTimeCreateSchema)) body: LaborTimeCreate,
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
) {
|
||||
return this.workOrders.addLabor(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);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/work-orders/work-orders.module.ts
Normal file
12
apps/api/src/work-orders/work-orders.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PartsModule } from '../parts/parts.module';
|
||||
import { WorkOrdersController } from './work-orders.controller';
|
||||
import { WorkOrdersService } from './work-orders.service';
|
||||
|
||||
@Module({
|
||||
imports: [PartsModule],
|
||||
controllers: [WorkOrdersController],
|
||||
providers: [WorkOrdersService],
|
||||
exports: [WorkOrdersService],
|
||||
})
|
||||
export class WorkOrdersModule {}
|
||||
527
apps/api/src/work-orders/work-orders.service.ts
Normal file
527
apps/api/src/work-orders/work-orders.service.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
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 { PartsService } from '../parts/parts.service';
|
||||
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 } },
|
||||
// R3 — coûts figés
|
||||
stockMovements: {
|
||||
where: { kind: 'CONSUMPTION' as const },
|
||||
include: { part: true },
|
||||
orderBy: { createdAt: 'asc' as const },
|
||||
},
|
||||
laborTimes: { include: { user: true }, orderBy: { createdAt: '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,
|
||||
private readonly parts: PartsService,
|
||||
) {}
|
||||
|
||||
/** 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> {
|
||||
// Séquence Postgres : insensible à la concurrence (jamais de collision)
|
||||
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||
SELECT nextval('work_order_ref_seq')`;
|
||||
return `OT-${new Date().getFullYear()}-${String(nextval).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,
|
||||
};
|
||||
}
|
||||
|
||||
// ————— R3 : coûts figés —————
|
||||
|
||||
/** Consommer une pièce : stock décrémenté, PRIX FIGÉ au moment T. */
|
||||
async consumePart(
|
||||
id: string,
|
||||
dto: { partId: string; quantity: number },
|
||||
user: AuthenticatedUser,
|
||||
): Promise<WorkOrderDetail> {
|
||||
const detail = await this.get(id, user); // périmètre + existence
|
||||
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
|
||||
throw new ConflictException('OT terminé : les coûts sont figés');
|
||||
}
|
||||
const { designation, unitPrice } = await this.parts.consume(
|
||||
dto.partId,
|
||||
dto.quantity,
|
||||
id,
|
||||
user,
|
||||
);
|
||||
await this.prisma.workOrderEvent.create({
|
||||
data: {
|
||||
workOrderId: id,
|
||||
kind: 'PART_CONSUMED',
|
||||
message: `${designation} × ${dto.quantity} (${unitPrice} MAD/u, prix figé)`,
|
||||
byId: user.userId,
|
||||
},
|
||||
});
|
||||
return this.get(id, user);
|
||||
}
|
||||
|
||||
/** Saisir de la main-d'œuvre : TAUX FIGÉ à la saisie. */
|
||||
async addLabor(
|
||||
id: string,
|
||||
dto: { minutes: number; userId?: string; note?: string },
|
||||
user: AuthenticatedUser,
|
||||
): Promise<WorkOrderDetail> {
|
||||
const detail = await this.get(id, user);
|
||||
if (detail.status === 'DONE' || detail.status === 'CANCELLED') {
|
||||
throw new ConflictException('OT terminé : les coûts sont figés');
|
||||
}
|
||||
const cibleId = dto.userId ?? user.userId;
|
||||
const cible = await this.prisma.user.findUnique({ where: { id: cibleId } });
|
||||
if (!cible) throw new BadRequestException('Personne inconnue');
|
||||
if (cible.hourlyRate === null) {
|
||||
throw new ConflictException(
|
||||
`Aucun taux horaire défini pour ${cible.displayName} — à renseigner dans Personnes`,
|
||||
);
|
||||
}
|
||||
await this.prisma.laborTime.create({
|
||||
data: {
|
||||
workOrderId: id,
|
||||
userId: cibleId,
|
||||
minutes: dto.minutes,
|
||||
hourlyRate: cible.hourlyRate, // figé
|
||||
note: dto.note,
|
||||
},
|
||||
});
|
||||
return this.get(id, user);
|
||||
}
|
||||
|
||||
// ————— 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;
|
||||
// Coûts figés : chaque ligne porte le prix/taux du moment T
|
||||
const partsCosts = row.stockMovements.map((m) => ({
|
||||
id: m.id,
|
||||
designation: m.part.designation,
|
||||
quantity: -m.quantity,
|
||||
unitPrice: Number(m.unitPrice ?? 0),
|
||||
total: -m.quantity * Number(m.unitPrice ?? 0),
|
||||
}));
|
||||
const laborCosts = row.laborTimes.map((l) => ({
|
||||
id: l.id,
|
||||
displayName: l.user.displayName,
|
||||
minutes: l.minutes,
|
||||
hourlyRate: Number(l.hourlyRate),
|
||||
total: Math.round((l.minutes / 60) * Number(l.hourlyRate) * 100) / 100,
|
||||
note: l.note,
|
||||
}));
|
||||
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,
|
||||
costs: {
|
||||
parts: partsCosts,
|
||||
labor: laborCosts,
|
||||
total:
|
||||
Math.round(
|
||||
(partsCosts.reduce((s, p) => s + p.total, 0) +
|
||||
laborCosts.reduce((s, l) => s + l.total, 0)) *
|
||||
100,
|
||||
) / 100,
|
||||
},
|
||||
allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
|
||||
closureBlockers: blockers,
|
||||
};
|
||||
}
|
||||
}
|
||||
141
apps/api/test/documents-analytics.e2e-spec.ts
Normal file
141
apps/api/test/documents-analytics.e2e-spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* E2E R3.2 — bibliothèque (upload MinIO via FileStorage, download streamé,
|
||||
* refus typés, permission sur la cible) + analytics (dérivé du réel).
|
||||
* Nécessite MinIO (infra locale / service CI).
|
||||
*/
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { seed } from '../prisma/seed';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
const PNG_1PX = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
describe('Bibliothèque & analytics (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let nadia: string;
|
||||
let karim: string; // Demandeur : aucune permission d'édition
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
const suffix = `doc-${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);
|
||||
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||
.accessToken as string;
|
||||
};
|
||||
nadia = await login('Gestionnaire');
|
||||
karim = await login('Demandeur');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.document.deleteMany({ where: { fileName: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('upload → liste filtrée → download identique → suppression', async () => {
|
||||
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
|
||||
const envoye = await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'CERTIFICATE')
|
||||
.field('assetId', a1.id)
|
||||
.attach('file', PNG_1PX, { filename: `certificat-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(201);
|
||||
expect(envoye.body.assetReference).toBe('A1');
|
||||
expect(envoye.body.size).toBe(PNG_1PX.length);
|
||||
|
||||
const liste = await http()
|
||||
.get(`/documents?assetId=${a1.id}&kind=CERTIFICATE`)
|
||||
.set(auth(nadia))
|
||||
.expect(200);
|
||||
expect(
|
||||
liste.body.documents.some((d: { fileName: string }) => d.fileName.includes(suffix)),
|
||||
).toBe(true);
|
||||
|
||||
const telecharge = await http()
|
||||
.get(`/documents/${envoye.body.id}/download`)
|
||||
.set(auth(nadia))
|
||||
.buffer(true)
|
||||
.parse((res, cb) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
})
|
||||
.expect(200);
|
||||
expect(Buffer.compare(telecharge.body as Buffer, PNG_1PX)).toBe(0); // octets identiques
|
||||
|
||||
await http().delete(`/documents/${envoye.body.id}`).set(auth(nadia)).expect(204);
|
||||
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
|
||||
});
|
||||
|
||||
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
|
||||
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'OTHER')
|
||||
.field('assetId', a1.id)
|
||||
.attach('file', Buffer.from('binaire'), { filename: `x-${suffix}.exe`, contentType: 'application/octet-stream' })
|
||||
.expect(400);
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'PHOTO')
|
||||
.attach('file', PNG_1PX, { filename: `orphelin-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(400); // aucun rattachement
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(nadia))
|
||||
.field('kind', 'PHOTO')
|
||||
.field('assetId', '00000000-0000-4000-8000-000000000000')
|
||||
.attach('file', PNG_1PX, { filename: `fantome-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(400);
|
||||
// Karim (Demandeur) : lecture de la bibliothèque OK, édition refusée
|
||||
await http().get('/documents').set(auth(karim)).expect(200);
|
||||
await http()
|
||||
.post('/documents')
|
||||
.set(auth(karim))
|
||||
.field('kind', 'PHOTO')
|
||||
.field('assetId', a1.id)
|
||||
.attach('file', PNG_1PX, { filename: `interdit-${suffix}.png`, contentType: 'image/png' })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('analytics : le tableau de la direction est dérivé du réel (bilans, coûts figés)', async () => {
|
||||
const res = await http().get('/analytics/summary').set(auth(nadia)).expect(200);
|
||||
const s = res.body;
|
||||
expect(s.costsByMonth).toHaveLength(12); // période par défaut
|
||||
// Le seed a consommé 325 MAD de pièces + 180 de MO sur OT-0341 ce mois-ci
|
||||
expect(s.monthCost).toBeGreaterThanOrEqual(505);
|
||||
// Pannes par organe : OT-0332 (seed) a un bilan « Guides »
|
||||
expect(
|
||||
s.failuresByComponent.some((f: { label: string }) => f.label === 'Guides'),
|
||||
).toBe(true);
|
||||
// Top équipements : A1 porte les coûts du seed
|
||||
expect(s.topAssets.some((t: { reference: string }) => t.reference === 'A1')).toBe(true);
|
||||
expect(s.closed.total).toBeGreaterThanOrEqual(1);
|
||||
// Karim (Demandeur, sans ANALYTICS) → 403
|
||||
await http().get('/analytics/summary').set(auth(karim)).expect(403);
|
||||
});
|
||||
});
|
||||
374
apps/api/test/exploitation.e2e-spec.ts
Normal file
374
apps/api/test/exploitation.e2e-spec.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 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);
|
||||
expect(position).toBeGreaterThanOrEqual(0);
|
||||
// Invariant DÉTERMINISTE (les specs parallèles créent des OT plus frais,
|
||||
// les positions absolues sont donc instables) : notre OT fraîchement
|
||||
// clôturé précède les OT du seed modifiés avant lui — avec l'ancien tri
|
||||
// « par statut », tout En cours passait devant tous les Terminés.
|
||||
const annee = new Date().getFullYear();
|
||||
const posGrilleSeed = listeSalma.workOrders.findIndex(
|
||||
(w: { reference: string }) => w.reference === `OT-${annee}-0338`, // En cours, seed
|
||||
);
|
||||
const posAncienDone = listeSalma.workOrders.findIndex(
|
||||
(w: { reference: string }) => w.reference === `OT-${annee}-0332`, // Terminé, seed
|
||||
);
|
||||
expect(position).toBeLessThan(posGrilleSeed);
|
||||
expect(position).toBeLessThan(posAncienDone);
|
||||
|
||||
// 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 } });
|
||||
});
|
||||
});
|
||||
351
apps/api/test/gestion.e2e-spec.ts
Normal file
351
apps/api/test/gestion.e2e-spec.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* E2E R3 — gestion : la recette officielle (consommer sous seuil → BC →
|
||||
* réception → réappro ; coût complet d'un OT) et les invariants comptables
|
||||
* (stock dérivé jamais négatif, prix et taux FIGÉS, ajustement motivé).
|
||||
*/
|
||||
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('Gestion (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let nadia: string; // Gestionnaire
|
||||
let salma: string; // Dispatcher
|
||||
let ahmed: string; // Technicien (PARTS view seulement)
|
||||
let admin: string;
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
const suffix = `G3-${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);
|
||||
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||
.accessToken as string;
|
||||
};
|
||||
nadia = await login('Gestionnaire');
|
||||
salma = await login('Dispatcher');
|
||||
ahmed = await login('Technicien');
|
||||
admin = await login('Administrateur');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.stockMovement.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ part: { designation: { contains: suffix } } },
|
||||
{ workOrder: { title: { contains: suffix } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||
await prisma.purchaseOrder.deleteMany({
|
||||
where: { lines: { some: { part: { designation: { contains: suffix } } } } },
|
||||
});
|
||||
await prisma.part.deleteMany({ where: { designation: { contains: suffix } } });
|
||||
await prisma.partner.deleteMany({ where: { name: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('recette R3 : consommer sous seuil → BC → réception → réappro (prix figés)', async () => {
|
||||
// Pièce dédiée au test : stock 6, seuil 4, PU initial 100
|
||||
const piece = await http()
|
||||
.post('/parts')
|
||||
.set(auth(nadia))
|
||||
.send({ designation: `Pièce ${suffix}`, threshold: 4, initialPrice: 100 })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post(`/parts/${piece.body.id}/movements`)
|
||||
.set(auth(nadia))
|
||||
.send({ kind: 'ENTRY', quantity: 6 })
|
||||
.expect(201);
|
||||
|
||||
// OT + consommation de 3 → stock 3, SOUS le seuil
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const ot = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({
|
||||
title: `Réparation ${suffix}`,
|
||||
type: 'CORRECTIVE',
|
||||
assetId: assets.assets[0].id,
|
||||
})
|
||||
.expect(201);
|
||||
const apresConso = await http()
|
||||
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||
.set(auth(salma))
|
||||
.send({ partId: piece.body.id, quantity: 3 })
|
||||
.expect(201);
|
||||
expect(apresConso.body.costs.parts[0]).toMatchObject({
|
||||
quantity: 3,
|
||||
unitPrice: 100,
|
||||
total: 300,
|
||||
});
|
||||
const sousSeuil = await http().get(`/parts/${piece.body.id}`).set(auth(nadia));
|
||||
expect(sousSeuil.body.stock).toBe(3);
|
||||
expect(sousSeuil.body.belowThreshold).toBe(true);
|
||||
|
||||
// BC : brouillon → envoyé → reçu (PU 110) → stock 13, plus d'alerte
|
||||
const { body: partners } = await http().get('/partners').set(auth(nadia));
|
||||
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
|
||||
const bc = await http()
|
||||
.post('/purchase-orders')
|
||||
.set(auth(nadia))
|
||||
.send({
|
||||
supplierId: fournisseur.id,
|
||||
lines: [{ partId: piece.body.id, quantity: 10, unitPrice: 110 }],
|
||||
})
|
||||
.expect(201);
|
||||
expect(bc.body.status).toBe('DRAFT');
|
||||
expect(bc.body.total).toBe(1100);
|
||||
await http()
|
||||
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||
.set(auth(nadia))
|
||||
.send({ to: 'SENT' })
|
||||
.expect(200);
|
||||
const recu = await http()
|
||||
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||
.set(auth(nadia))
|
||||
.send({ to: 'RECEIVED' })
|
||||
.expect(200);
|
||||
expect(recu.body.receivedAt).toBeTruthy();
|
||||
|
||||
const apresReception = await http().get(`/parts/${piece.body.id}`).set(auth(nadia));
|
||||
expect(apresReception.body.stock).toBe(13);
|
||||
expect(apresReception.body.belowThreshold).toBe(false);
|
||||
expect(apresReception.body.lastUnitPrice).toBe(110); // nouveau prix courant
|
||||
expect(
|
||||
apresReception.body.movements.some(
|
||||
(m: { kind: string; purchaseOrderReference: string | null }) =>
|
||||
m.kind === 'RECEIPT' && m.purchaseOrderReference === bc.body.reference,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// PRIX FIGÉ : la consommation passée reste à 100, malgré le nouveau PU 110
|
||||
const otRelu = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma));
|
||||
expect(otRelu.body.costs.parts[0].unitPrice).toBe(100);
|
||||
});
|
||||
|
||||
it('coût complet d’un OT : pièces + main-d’œuvre — le TAUX est figé à la saisie', async () => {
|
||||
const { body: users } = await http().get('/users').set(auth(admin));
|
||||
const ahmedUser = users.users.find(
|
||||
(u: { email: string }) => u.email === 'technicien@demo.siop.ma',
|
||||
);
|
||||
const tauxInitial = ahmedUser.hourlyRate; // 120 (seed)
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const ot = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({ title: `Coût complet ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
|
||||
.expect(201);
|
||||
|
||||
// Salma (sans taux défini) tente de saisir pour elle-même → refus motivé
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/labor`)
|
||||
.set(auth(salma))
|
||||
.send({ minutes: 30 })
|
||||
.expect(409);
|
||||
|
||||
// 1 h 30 d'Ahmed au taux courant
|
||||
const avecMO = await http()
|
||||
.post(`/work-orders/${ot.body.id}/labor`)
|
||||
.set(auth(salma))
|
||||
.send({ minutes: 90, userId: ahmedUser.id })
|
||||
.expect(201);
|
||||
expect(avecMO.body.costs.labor[0].hourlyRate).toBe(tauxInitial);
|
||||
expect(avecMO.body.costs.labor[0].total).toBe(
|
||||
Math.round((90 / 60) * tauxInitial * 100) / 100,
|
||||
);
|
||||
const totalAvant = avecMO.body.costs.total;
|
||||
|
||||
// L'admin augmente le taux courant d'Ahmed → le coût de l'OT NE BOUGE PAS
|
||||
await http()
|
||||
.patch(`/users/${ahmedUser.id}`)
|
||||
.set(auth(admin))
|
||||
.send({ hourlyRate: tauxInitial + 30 })
|
||||
.expect(200);
|
||||
const otApres = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma));
|
||||
expect(otApres.body.costs.total).toBe(totalAvant);
|
||||
// remise en l'état (le seed ne réécrit pas les taux existants)
|
||||
await http()
|
||||
.patch(`/users/${ahmedUser.id}`)
|
||||
.set(auth(admin))
|
||||
.send({ hourlyRate: tauxInitial })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('invariants : motif requis, stock jamais négatif, prix inconnu refusé, coûts figés après clôture', async () => {
|
||||
const piece = await http()
|
||||
.post('/parts')
|
||||
.set(auth(nadia))
|
||||
.send({ designation: `Sans prix ${suffix}`, threshold: 0 })
|
||||
.expect(201);
|
||||
|
||||
// ajustement sans motif → 400 ; stock négatif → 409
|
||||
await http()
|
||||
.post(`/parts/${piece.body.id}/movements`)
|
||||
.set(auth(nadia))
|
||||
.send({ kind: 'ADJUSTMENT', quantity: -1 })
|
||||
.expect(400);
|
||||
await http()
|
||||
.post(`/parts/${piece.body.id}/movements`)
|
||||
.set(auth(nadia))
|
||||
.send({ kind: 'ADJUSTMENT', quantity: -1, reason: 'test' })
|
||||
.expect(409);
|
||||
await http()
|
||||
.post(`/parts/${piece.body.id}/movements`)
|
||||
.set(auth(nadia))
|
||||
.send({ kind: 'ENTRY', quantity: -3 })
|
||||
.expect(400);
|
||||
|
||||
// consommation sans prix connu → 409 ; stock insuffisant → 409
|
||||
await http()
|
||||
.post(`/parts/${piece.body.id}/movements`)
|
||||
.set(auth(nadia))
|
||||
.send({ kind: 'ENTRY', quantity: 5 })
|
||||
.expect(201);
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const ot = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({ title: `Invariants ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||
.set(auth(salma))
|
||||
.send({ partId: piece.body.id, quantity: 2 })
|
||||
.expect(409); // pas de prix connu
|
||||
// Technicien : PARTS en lecture seule
|
||||
await http()
|
||||
.post('/parts')
|
||||
.set(auth(ahmed))
|
||||
.send({ designation: `Interdit ${suffix}` })
|
||||
.expect(403);
|
||||
// BC : réception d'un brouillon interdite
|
||||
const { body: partners } = await http().get('/partners').set(auth(nadia));
|
||||
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
|
||||
const bc = await http()
|
||||
.post('/purchase-orders')
|
||||
.set(auth(nadia))
|
||||
.send({ supplierId: fournisseur.id, lines: [{ partId: piece.body.id, quantity: 1, unitPrice: 10 }] })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||
.set(auth(nadia))
|
||||
.send({ to: 'RECEIVED' })
|
||||
.expect(409);
|
||||
await http()
|
||||
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||
.set(auth(nadia))
|
||||
.send({ to: 'CANCELLED' })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('refus propres : doublons, inconnus, transitions interdites, coûts figés après clôture', async () => {
|
||||
const GHOST = '00000000-0000-4000-8000-000000000000';
|
||||
// tiers : doublon 409, 404, désactivation
|
||||
const tiers = await http()
|
||||
.post('/partners')
|
||||
.set(auth(nadia))
|
||||
.send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post('/partners')
|
||||
.set(auth(nadia))
|
||||
.send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' })
|
||||
.expect(409);
|
||||
await http()
|
||||
.patch(`/partners/${tiers.body.id}`)
|
||||
.set(auth(nadia))
|
||||
.send({ isActive: false })
|
||||
.expect(200);
|
||||
await http().patch(`/partners/${GHOST}`).set(auth(nadia)).send({ city: 'X' }).expect(404);
|
||||
// pièce : fournisseur inconnu 400, 404, désactivée non consommable
|
||||
await http()
|
||||
.post('/parts')
|
||||
.set(auth(nadia))
|
||||
.send({ designation: `Fournisseur fantôme ${suffix}`, supplierId: GHOST })
|
||||
.expect(400);
|
||||
await http().patch(`/parts/${GHOST}`).set(auth(nadia)).send({ threshold: 1 }).expect(404);
|
||||
await http().get(`/parts/${GHOST}`).set(auth(nadia)).expect(404);
|
||||
const desactivee = await http()
|
||||
.post('/parts')
|
||||
.set(auth(nadia))
|
||||
.send({ designation: `Désactivée ${suffix}`, initialPrice: 10 })
|
||||
.expect(201);
|
||||
await http()
|
||||
.patch(`/parts/${desactivee.body.id}`)
|
||||
.set(auth(nadia))
|
||||
.send({ isActive: false })
|
||||
.expect(200);
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const ot = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({ title: `Refus ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||
.set(auth(salma))
|
||||
.send({ partId: desactivee.body.id, quantity: 1 })
|
||||
.expect(400);
|
||||
// BC : fournisseur inactif 400, pièce inconnue 400, 404, transition sur terminal 409
|
||||
await http()
|
||||
.post('/purchase-orders')
|
||||
.set(auth(nadia))
|
||||
.send({ supplierId: tiers.body.id, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] })
|
||||
.expect(400); // désactivé plus haut
|
||||
await http()
|
||||
.post('/purchase-orders')
|
||||
.set(auth(nadia))
|
||||
.send({ supplierId: GHOST, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] })
|
||||
.expect(400);
|
||||
await http().get(`/purchase-orders/${GHOST}`).set(auth(nadia)).expect(404);
|
||||
// main-d'œuvre : personne inconnue 400 ; OT annulé → coûts figés 409
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/labor`)
|
||||
.set(auth(salma))
|
||||
.send({ minutes: 10, userId: GHOST })
|
||||
.expect(400);
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/transition`)
|
||||
.set(auth(salma))
|
||||
.send({ to: 'CANCELLED', comment: 'test' })
|
||||
.expect(200);
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||
.set(auth(salma))
|
||||
.send({ partId: desactivee.body.id, quantity: 1 })
|
||||
.expect(409);
|
||||
await http()
|
||||
.post(`/work-orders/${ot.body.id}/labor`)
|
||||
.set(auth(salma))
|
||||
.send({ minutes: 10 })
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('le seed rejoue la carte maquette : OT-0341 coûte 505 MAD', async () => {
|
||||
const annee = new Date().getFullYear();
|
||||
const { body } = await http().get('/work-orders').set(auth(salma));
|
||||
const ot341 = body.workOrders.find(
|
||||
(w: { reference: string }) => w.reference === `OT-${annee}-0341`,
|
||||
);
|
||||
const detail = await http().get(`/work-orders/${ot341.id}`).set(auth(salma)).expect(200);
|
||||
expect(detail.body.costs.total).toBe(505); // 240 + 85 + 180
|
||||
expect(detail.body.costs.labor[0].hourlyRate).toBe(120);
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
141
apps/api/test/recherche.e2e-spec.ts
Normal file
141
apps/api/test/recherche.e2e-spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* E2E R3 (recette) — recherche globale : chaque famille de résultats est
|
||||
* filtrée par la matrice, les OT respectent « voir autre » ; au passage,
|
||||
* rattachements Tiers (siteNames) et période analytics paramétrable.
|
||||
*/
|
||||
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('Recherche globale & corrections de recette R3 (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let yasmine: string; // Administrateur
|
||||
let salma: string; // Dispatcher (voir autre)
|
||||
let youssef: string; // Technicien limité : SES OT, ASSETS view, pas de LOCATIONS
|
||||
let karim: string; // Demandeur : aucune des trois familles
|
||||
let otId: string;
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
const suffix = `rech-${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);
|
||||
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||
.accessToken as string;
|
||||
};
|
||||
yasmine = await login('Administrateur');
|
||||
salma = await login('Dispatcher');
|
||||
youssef = await login('Technicien limité');
|
||||
karim = await login('Demandeur');
|
||||
|
||||
// Un OT au titre unique, assigné à Ahmed (PAS à Youssef).
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
const ahmed = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
|
||||
const cree = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({
|
||||
title: `Recherche E2E ${suffix}`,
|
||||
type: 'CORRECTIVE',
|
||||
priority: 'LOW',
|
||||
assetId: a1.id,
|
||||
assigneeIds: [ahmed.id],
|
||||
})
|
||||
.expect(201);
|
||||
otId = cree.body.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('dispatcher (voir autre) : OT par titre, appareil par référence, site insensible à la casse', async () => {
|
||||
const parTitre = await http().get(`/search?q=${suffix}`).set(auth(salma)).expect(200);
|
||||
expect(parTitre.body.workOrders.map((w: { id: string }) => w.id)).toContain(otId);
|
||||
|
||||
const parRef = await http().get('/search?q=A1').set(auth(salma)).expect(200);
|
||||
expect(parRef.body.assets.some((a: { reference: string }) => a.reference === 'A1')).toBe(true);
|
||||
expect(parRef.body.assets[0].siteName).toBeTruthy();
|
||||
|
||||
const parSite = await http().get('/search?q=atlas').set(auth(salma)).expect(200);
|
||||
expect(parSite.body.sites.some((s: { name: string }) => s.name === 'Tour Atlas')).toBe(true);
|
||||
});
|
||||
|
||||
it('technicien limité : ne trouve pas l’OT d’un autre, ni les sites ; le parc oui', async () => {
|
||||
const res = await http().get(`/search?q=${suffix}`).set(auth(youssef)).expect(200);
|
||||
expect(res.body.workOrders).toHaveLength(0); // pas « voir autre »
|
||||
|
||||
const parc = await http().get('/search?q=A1').set(auth(youssef)).expect(200);
|
||||
expect(parc.body.assets.length).toBeGreaterThan(0); // ASSETS view
|
||||
const sites = await http().get('/search?q=atlas').set(auth(youssef)).expect(200);
|
||||
expect(sites.body.sites).toHaveLength(0); // pas de LOCATIONS view
|
||||
});
|
||||
|
||||
it('demandeur : 200 mais aucune famille (aucune permission view)', async () => {
|
||||
const res = await http().get('/search?q=atlas').set(auth(karim)).expect(200);
|
||||
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
|
||||
});
|
||||
|
||||
it('moins de 2 caractères : réponse vide, pas de requête inutile', async () => {
|
||||
const res = await http().get('/search?q=a').set(auth(salma)).expect(200);
|
||||
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
|
||||
});
|
||||
|
||||
it('sans jeton : 401 (API fermée par défaut)', async () => {
|
||||
await http().get('/search?q=atlas').expect(401);
|
||||
});
|
||||
|
||||
it('tiers : les syndics portent leurs sites (« Rattachements » de la maquette)', async () => {
|
||||
const { body } = await http().get('/partners').set(auth(yasmine)).expect(200);
|
||||
const atlas = body.partners.find(
|
||||
(p: { name: string }) => p.name === 'Atlas Property Management',
|
||||
);
|
||||
expect(atlas.siteNames).toEqual(['Tour Atlas']);
|
||||
const fournisseur = body.partners.find((p: { name: string }) => p.name === 'Lubmaroc');
|
||||
expect(fournisseur.siteNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('analytics : période 3/6/12 mois, 12 par défaut, valeur inconnue repliée sur 12', async () => {
|
||||
const trois = await http().get('/analytics/summary?months=3').set(auth(yasmine)).expect(200);
|
||||
expect(trois.body.months).toBe(3);
|
||||
expect(trois.body.costsByMonth).toHaveLength(3);
|
||||
|
||||
const defaut = await http().get('/analytics/summary').set(auth(yasmine)).expect(200);
|
||||
expect(defaut.body.months).toBe(12);
|
||||
expect(defaut.body.costsByMonth).toHaveLength(12);
|
||||
|
||||
const inconnu = await http().get('/analytics/summary?months=7').set(auth(yasmine)).expect(200);
|
||||
expect(inconnu.body.months).toBe(12);
|
||||
});
|
||||
|
||||
it('sites : le rattachement n’accepte qu’un tiers Client / syndic', async () => {
|
||||
const { body: partners } = await http().get('/partners').set(auth(yasmine));
|
||||
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
|
||||
const { body: locations } = await http().get('/locations').set(auth(yasmine));
|
||||
const site = locations.locations.find(
|
||||
(l: { parentId: string | null; name: string }) => !l.parentId && l.name === 'Anfa Place',
|
||||
);
|
||||
await http()
|
||||
.patch(`/locations/${site.id}`)
|
||||
.set(auth(yasmine))
|
||||
.send({ partnerId: fournisseur.id })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
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');
|
||||
});
|
||||
209
apps/web/e2e/parcours-r3.spec.ts
Normal file
209
apps/web/e2e/parcours-r3.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Recette R3 (plan de releases) : pièce sous le seuil → BC prérempli →
|
||||
* réception (le stock remonte, l'alerte s'éteint) ; coûts réels sur OT
|
||||
* (prix figé + taux figé) ; document rattaché téléversé puis téléchargé ;
|
||||
* statistiques lisibles par la Vue seule.
|
||||
* Chaque test crée SES données (marquées E2E, purgées au global-setup).
|
||||
*/
|
||||
|
||||
const suffix = Date.now().toString(36);
|
||||
|
||||
/** PNG 1×1 valide — assez pour tester upload/download réels via MinIO. */
|
||||
const PNG_1PX = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
|
||||
async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) {
|
||||
await page.goto('/connexion');
|
||||
await page.getByRole('button', { name: nom }).click();
|
||||
await expect(page.locator('.topbar')).toBeVisible();
|
||||
}
|
||||
|
||||
async function changerDeCompte(page: import('@playwright/test').Page, nom: RegExp) {
|
||||
await page.locator('.compte').click();
|
||||
await page.getByRole('menuitem', { name: nom }).click();
|
||||
}
|
||||
|
||||
test('recette R3 : tiers → pièce sous seuil → BC prérempli → réception → alerte éteinte', async ({
|
||||
page,
|
||||
}) => {
|
||||
const fournisseur = `E2E Fournisseur ${suffix}`;
|
||||
const piece = `E2E Pièce ${suffix}`;
|
||||
|
||||
// 1 · Nadia (gestionnaire) crée le fournisseur
|
||||
await connexionDemo(page, /Nadia Berrada/);
|
||||
await page.getByRole('link', { name: 'Tiers' }).click();
|
||||
await page.getByRole('button', { name: '+ Nouveau tiers' }).click();
|
||||
await page.getByLabel('Nom *').fill(fournisseur);
|
||||
await page.getByLabel('Type *').selectOption('SUPPLIER');
|
||||
await page.getByRole('button', { name: 'Créer', exact: true }).click();
|
||||
await expect(page.getByRole('cell', { name: fournisseur })).toBeVisible();
|
||||
|
||||
// 2 · Nouvelle pièce (seuil 5, prix initial 100) → fiche
|
||||
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||
await page.getByRole('button', { name: '+ Nouvelle pièce' }).click();
|
||||
await page.getByLabel('Désignation *').fill(piece);
|
||||
await page.getByLabel("Seuil d'alerte").fill('5');
|
||||
await page.getByLabel('Prix initial (MAD)').fill('100');
|
||||
await page.getByLabel('Fournisseur', { exact: true }).selectOption({ label: fournisseur });
|
||||
await page.getByRole('button', { name: 'Créer la pièce' }).click();
|
||||
await expect(page.getByRole('heading', { name: piece })).toBeVisible();
|
||||
|
||||
// 3 · Entrée de 2 → stock 2 < seuil 5 : alerte
|
||||
await page.getByRole('button', { name: 'Entrée de stock' }).click();
|
||||
await page.getByLabel(/Quantité/).fill('2');
|
||||
await page.getByRole('button', { name: 'Tracer le mouvement' }).click();
|
||||
await expect(page.locator('.entete-page .stock-bas')).toHaveText('Sous le seuil');
|
||||
await expect(page.locator('dd', { hasText: 'seuil : 5' })).toContainText('2');
|
||||
|
||||
// 4 · « Préparer le BC » → formulaire prérempli (fournisseur, qté 8, PU 100)
|
||||
await page.getByRole('button', { name: 'Préparer le BC' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Nouveau bon de commande' })).toBeVisible();
|
||||
await expect(page.getByLabel('Fournisseur *')).toHaveValue(/.+/);
|
||||
await expect(page.getByLabel('Quantité de la ligne 1')).toHaveValue('8'); // seuil − stock + 5
|
||||
await expect(page.getByLabel('Prix unitaire de la ligne 1')).toHaveValue('100');
|
||||
await page.getByRole('button', { name: 'Créer le BC (brouillon)' }).click();
|
||||
|
||||
// 5 · Envoyer puis réceptionner — le panneau suit le BC
|
||||
const ligneBC = page.locator('tr', { hasText: fournisseur }).first();
|
||||
await ligneBC.click();
|
||||
await expect(page.locator('.panneau .st')).toHaveText('Brouillon');
|
||||
await page.getByRole('button', { name: 'Envoyer au fournisseur' }).click();
|
||||
await expect(page.locator('.panneau .st')).toHaveText('Envoyé');
|
||||
await page.getByRole('button', { name: /Réceptionner/ }).click();
|
||||
await expect(page.locator('.panneau .st')).toHaveText('Reçu');
|
||||
|
||||
// 6 · Le stock est remonté (2 + 8 = 10), l'alerte est éteinte
|
||||
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||
const lignePiece = page.locator('tr', { hasText: piece });
|
||||
await expect(lignePiece.locator('td').nth(2)).toHaveText('10');
|
||||
await expect(lignePiece.locator('.stock-bas')).toHaveCount(0);
|
||||
|
||||
// 7 · La fiche pièce raconte tout : entrée manuelle + réception à PU figé
|
||||
await lignePiece.click();
|
||||
await expect(page.locator('.mvt.entree')).toHaveCount(2);
|
||||
await expect(page.getByText('100 MAD/u')).toBeVisible();
|
||||
});
|
||||
|
||||
test('recette R3 : coûts réels sur OT (prix + taux figés) + document + statistiques', async ({
|
||||
page,
|
||||
}) => {
|
||||
const piece = `E2E PièceOT ${suffix}`;
|
||||
const titreOT = `E2E ${suffix} — coûts`;
|
||||
|
||||
// 1 · Nadia crée une pièce avec du stock (10 à 100 MAD)
|
||||
await connexionDemo(page, /Nadia Berrada/);
|
||||
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||
await page.getByRole('button', { name: '+ Nouvelle pièce' }).click();
|
||||
await page.getByLabel('Désignation *').fill(piece);
|
||||
await page.getByLabel('Prix initial (MAD)').fill('100');
|
||||
await page.getByRole('button', { name: 'Créer la pièce' }).click();
|
||||
await expect(page.getByRole('heading', { name: piece })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Entrée de stock' }).click();
|
||||
await page.getByLabel(/Quantité/).fill('10');
|
||||
await page.getByRole('button', { name: 'Tracer le mouvement' }).click();
|
||||
await expect(page.locator('dd', { hasText: 'seuil : 0' })).toContainText('10');
|
||||
|
||||
// 2 · Salma (dispatcher) crée l'OT et l'assigne à Ahmed
|
||||
await changerDeCompte(page, /Salma Idrissi/);
|
||||
await page.getByRole('link', { name: /Ordres de travail/ }).click();
|
||||
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
|
||||
await page.getByLabel('Objet *').fill(titreOT);
|
||||
await page.getByLabel('Équipement *').selectOption({ index: 1 });
|
||||
await page.getByLabel('Assigner à').selectOption({ label: 'Ahmed Benali' });
|
||||
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
|
||||
await expect(page.getByRole('heading', { name: titreOT })).toBeVisible();
|
||||
|
||||
// 3 · Ahmed (technicien) démarre, consomme 2 pièces, saisit 1 h 30
|
||||
await changerDeCompte(page, /Ahmed Benali/);
|
||||
await page.getByRole('link', { name: /Ordres de travail/ }).click();
|
||||
await page.getByRole('cell', { name: titreOT }).click();
|
||||
await page.getByRole('button', { name: 'Démarrer' }).click();
|
||||
await expect(page.locator('.entete-page .st')).toHaveText('En cours');
|
||||
|
||||
await page.getByRole('button', { name: '+ Consommer une pièce' }).click();
|
||||
await page.getByLabel('Pièce *').selectOption({ label: `${piece} — stock : 10` });
|
||||
await expect(page.getByText('Prix figé à la consommation')).toBeVisible();
|
||||
await page.getByLabel('Quantité *').fill('2');
|
||||
await page.getByRole('button', { name: /^Consommer/ }).click();
|
||||
await expect(page.locator('.item-liste', { hasText: `${piece} × 2` })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: '+ Saisir du temps' }).click();
|
||||
await page.getByLabel('Heures').fill('1');
|
||||
await page.getByLabel('Minutes').fill('30');
|
||||
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||
// 2 × 100 + 1,5 h × 120 MAD/h (taux seedé d'Ahmed) = 380 MAD
|
||||
await expect(page.getByText('1 h 30 × 120 MAD/h')).toBeVisible();
|
||||
await expect(page.locator('.total .num')).toHaveText('380 MAD');
|
||||
|
||||
// 4 · Ahmed joint une photo — puis la retélécharge (aller-retour MinIO réel)
|
||||
await page.getByLabel('Choisir un fichier').setInputFiles({
|
||||
name: `photo-e2e-${suffix}.png`,
|
||||
mimeType: 'image/png',
|
||||
buffer: PNG_1PX,
|
||||
});
|
||||
await expect(page.getByText(`photo-e2e-${suffix}.png`)).toBeVisible();
|
||||
const attenteTelechargement = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Télécharger' }).click();
|
||||
expect((await attenteTelechargement).suggestedFilename()).toBe(`photo-e2e-${suffix}.png`);
|
||||
|
||||
// 5 · Rachid (vue seule) lit les statistiques
|
||||
await changerDeCompte(page, /Rachid Bennis/);
|
||||
await page.getByRole('link', { name: 'Statistiques', exact: true }).click();
|
||||
await expect(page.getByText('OT clôturés · 12 mois')).toBeVisible();
|
||||
await expect(page.getByText('Pannes par organe · 12 mois')).toBeVisible();
|
||||
await expect(page.getByText('Top équipements en coût · 12 mois')).toBeVisible();
|
||||
});
|
||||
|
||||
test('recette R3 corrigée : recherche ⌘K, tri/filtre stock, période stats, rattachements, dépôt', async ({
|
||||
page,
|
||||
}) => {
|
||||
await connexionDemo(page, /Yasmine Alaoui/);
|
||||
|
||||
// Recherche globale : ⌘K → site (insensible à la casse), clic → fiche site
|
||||
await page.keyboard.press('ControlOrMeta+k');
|
||||
await page.keyboard.type('atlas');
|
||||
await page.locator('.resultat', { hasText: 'Tour Atlas' }).first().click();
|
||||
await expect(page).toHaveURL(/\/sites\//);
|
||||
// le site porte son syndic (rattachement seedé, éditable par l'admin)
|
||||
await expect(page.getByLabel('Client / syndic du site')).toHaveValue(/.+/);
|
||||
|
||||
// Recherche OT au clavier seul (Entrée sur le premier résultat)
|
||||
await page.keyboard.press('ControlOrMeta+k');
|
||||
await page.keyboard.type('bruit');
|
||||
await page.locator('.resultat', { hasText: 'Bruit anormal' }).first().waitFor();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page).toHaveURL(/\/ot\//);
|
||||
|
||||
// Stock : les pièces sous le seuil remontent en tête, filtre fournisseur,
|
||||
// « Entrée de stock » accessible depuis la liste (choix de la pièce)
|
||||
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||
await expect(page.locator('tbody tr').first().locator('.stock-bas')).toHaveText(/Sous le seuil/);
|
||||
await page.getByLabel('Filtrer par fournisseur').selectOption({ label: 'Lubmaroc' });
|
||||
await expect(page.locator('tbody tr', { hasText: 'P-0042' })).toHaveCount(1);
|
||||
await page.getByLabel('Filtrer par fournisseur').selectOption({ index: 0 });
|
||||
await page.getByRole('button', { name: 'Entrée de stock' }).click();
|
||||
await expect(page.getByLabel('Pièce *')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Annuler' }).click();
|
||||
|
||||
// Fiche pièce : le fournisseur est un lien vers les tiers
|
||||
await page.getByRole('cell', { name: 'P-0113' }).click();
|
||||
await page.getByRole('link', { name: 'Ascentech Maroc' }).click();
|
||||
await expect(page).toHaveURL(/\/tiers/);
|
||||
// Tiers : les syndics affichent leurs sites
|
||||
await expect(page.getByRole('cell', { name: 'Tour Atlas' })).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: 'Résidence Al Manar' })).toBeVisible();
|
||||
|
||||
// Statistiques : la période est un vrai sélecteur
|
||||
await page.getByRole('link', { name: 'Statistiques', exact: true }).click();
|
||||
await page.getByLabel('Période').selectOption('3');
|
||||
await expect(page.getByText('OT clôturés · 3 mois')).toBeVisible();
|
||||
|
||||
// Bibliothèque : filtre « Rattaché à » + zone de glisser-déposer
|
||||
await page.getByRole('link', { name: 'Fichiers' }).click();
|
||||
await expect(page.getByLabel('Filtrer par appareil')).toBeVisible();
|
||||
await expect(page.locator('.zone-depot')).toBeVisible();
|
||||
});
|
||||
@@ -2,18 +2,32 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Coquille } from '@/layout/coquille';
|
||||
import PageAchats from '@/pages/achats';
|
||||
import PageActivation from '@/pages/activation';
|
||||
import PageAscenseurs from '@/pages/ascenseurs';
|
||||
import PageBibliotheque from '@/pages/bibliotheque';
|
||||
import PageCategories from '@/pages/categories';
|
||||
import PageCompteurs from '@/pages/compteurs';
|
||||
import PageConnexion from '@/pages/connexion';
|
||||
import PageDemandes from '@/pages/demandes';
|
||||
import PageDesign from '@/pages/design';
|
||||
import PageEtiquette from '@/pages/etiquette';
|
||||
import PageFicheAscenseur from '@/pages/fiche-ascenseur';
|
||||
import PageFicheOT from '@/pages/fiche-ot';
|
||||
import PageFichePiece from '@/pages/fiche-piece';
|
||||
import PageFicheSite from '@/pages/fiche-site';
|
||||
import PageNouvelAchat from '@/pages/nouvel-achat';
|
||||
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
|
||||
import PageNouvelOT from '@/pages/nouvel-ot';
|
||||
import PageOrdresTravail from '@/pages/ordres-travail';
|
||||
import PagePersonnes from '@/pages/personnes';
|
||||
import PagePortail from '@/pages/portail';
|
||||
import PagePreventif from '@/pages/preventif';
|
||||
import PageSites from '@/pages/sites';
|
||||
import PageStatistiques from '@/pages/statistiques';
|
||||
import PageStock from '@/pages/stock';
|
||||
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
||||
import PageTiers from '@/pages/tiers';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
||||
@@ -28,6 +42,8 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/connexion" element={<PageConnexion />} />
|
||||
<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="/design" element={dansCoquille(<PageDesign />)} />
|
||||
<Route path="/sites" element={dansCoquille(<PageSites />)} />
|
||||
@@ -36,8 +52,21 @@ export default function App() {
|
||||
<Route path="/ascenseurs/nouveau" element={dansCoquille(<PageNouvelAscenseur />)} />
|
||||
<Route path="/ascenseurs/:id" element={dansCoquille(<PageFicheAscenseur />)} />
|
||||
<Route path="/ascenseurs/:id/etiquette" element={dansCoquille(<PageEtiquette />)} />
|
||||
<Route path="/ascenseurs/:id/compteurs" element={dansCoquille(<PageCompteurs />)} />
|
||||
<Route path="/personnes" element={dansCoquille(<PagePersonnes />)} />
|
||||
<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 />)} />
|
||||
<Route path="/stock" element={dansCoquille(<PageStock />)} />
|
||||
<Route path="/stock/:id" element={dansCoquille(<PageFichePiece />)} />
|
||||
<Route path="/achats" element={dansCoquille(<PageAchats />)} />
|
||||
<Route path="/achats/nouveau" element={dansCoquille(<PageNouvelAchat />)} />
|
||||
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
|
||||
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
|
||||
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</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,
|
||||
});
|
||||
}
|
||||
268
apps/web/src/api/gestion.ts
Normal file
268
apps/web/src/api/gestion.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type {
|
||||
ConsumePart,
|
||||
LaborTimeCreate,
|
||||
PartCreate,
|
||||
PartnerCreate,
|
||||
PartnerUpdate,
|
||||
PartUpdate,
|
||||
PurchaseOrderCreate,
|
||||
PurchaseOrderTransition,
|
||||
StockMovementCreate,
|
||||
} from '@siop/shared';
|
||||
import { api, getToken } from './client';
|
||||
|
||||
/** Hooks R3 — client typé ; l'upload passe par FormData (multipart). */
|
||||
|
||||
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] })));
|
||||
}
|
||||
|
||||
// ————— Tiers —————
|
||||
|
||||
export function usePartners(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['partners'],
|
||||
enabled,
|
||||
queryFn: async () => (await unwrap(await api.GET('/partners'))).partners,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePartner() {
|
||||
const invalidate = useInvalidate('partners');
|
||||
return useMutation({
|
||||
mutationFn: async (body: PartnerCreate) => unwrap(await api.POST('/partners', { body })),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePartner() {
|
||||
const invalidate = useInvalidate('partners');
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...body }: PartnerUpdate & { id: string }) =>
|
||||
unwrap(await api.PATCH('/partners/{id}', { params: { path: { id } }, body })),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
// ————— Pièces & stock —————
|
||||
|
||||
export function useParts(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['parts'],
|
||||
enabled,
|
||||
queryFn: async () => (await unwrap(await api.GET('/parts'))).parts,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePart(id: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['parts', id],
|
||||
enabled: !!id,
|
||||
queryFn: async () =>
|
||||
unwrap(await api.GET('/parts/{id}', { params: { path: { id: id! } } })),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePart() {
|
||||
const invalidate = useInvalidate('parts');
|
||||
return useMutation({
|
||||
mutationFn: async (body: PartCreate) => unwrap(await api.POST('/parts', { body })),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePart(id: string) {
|
||||
const invalidate = useInvalidate('parts');
|
||||
return useMutation({
|
||||
mutationFn: async (body: PartUpdate) =>
|
||||
unwrap(await api.PATCH('/parts/{id}', { params: { path: { id } }, body })),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddStockMovement(id: string) {
|
||||
const invalidate = useInvalidate('parts');
|
||||
return useMutation({
|
||||
mutationFn: async (body: StockMovementCreate) =>
|
||||
unwrap(await api.POST('/parts/{id}/movements', { params: { path: { id } }, body })),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
// ————— Bons de commande —————
|
||||
|
||||
export function usePurchaseOrders(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['purchase-orders'],
|
||||
enabled,
|
||||
queryFn: async () =>
|
||||
(await unwrap(await api.GET('/purchase-orders'))).purchaseOrders,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePurchaseOrder() {
|
||||
const invalidate = useInvalidate('purchase-orders');
|
||||
return useMutation({
|
||||
mutationFn: async (body: PurchaseOrderCreate) =>
|
||||
unwrap(await api.POST('/purchase-orders', { body })),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransitionPurchaseOrder() {
|
||||
const invalidate = useInvalidate('purchase-orders', 'parts');
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...body }: PurchaseOrderTransition & { id: string }) =>
|
||||
unwrap(
|
||||
await api.POST('/purchase-orders/{id}/transition', { params: { path: { id } }, body }),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
// ————— Coûts sur OT —————
|
||||
|
||||
export function useConsumePart(workOrderId: string) {
|
||||
const invalidate = useInvalidate('work-orders', 'parts');
|
||||
return useMutation({
|
||||
mutationFn: async (body: ConsumePart) =>
|
||||
unwrap(
|
||||
await api.POST('/work-orders/{id}/consume-part', {
|
||||
params: { path: { id: workOrderId } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddLaborTime(workOrderId: string) {
|
||||
const invalidate = useInvalidate('work-orders');
|
||||
return useMutation({
|
||||
mutationFn: async (body: LaborTimeCreate) =>
|
||||
unwrap(
|
||||
await api.POST('/work-orders/{id}/labor', {
|
||||
params: { path: { id: workOrderId } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
// ————— Bibliothèque —————
|
||||
|
||||
export function useDocuments(filtres: { assetId?: string; workOrderId?: string; kind?: string } = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['documents', filtres],
|
||||
queryFn: async () =>
|
||||
(
|
||||
await unwrap(
|
||||
await api.GET('/documents', { params: { query: filtres } }),
|
||||
)
|
||||
).documents,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUploadDocument() {
|
||||
const invalidate = useInvalidate('documents');
|
||||
return useMutation({
|
||||
mutationFn: async (input: {
|
||||
file: File;
|
||||
kind: string;
|
||||
assetId?: string;
|
||||
workOrderId?: string;
|
||||
}) => {
|
||||
const form = new FormData();
|
||||
form.append('file', input.file);
|
||||
form.append('kind', input.kind);
|
||||
if (input.assetId) form.append('assetId', input.assetId);
|
||||
if (input.workOrderId) form.append('workOrderId', input.workOrderId);
|
||||
const res = await fetch('/api/documents', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const corps = (await res.json().catch(() => null)) as { message?: string } | null;
|
||||
throw new Error(corps?.message ?? `Téléversement refusé (${res.status})`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteDocument() {
|
||||
const invalidate = useInvalidate('documents');
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const res = await api.DELETE('/documents/{id}', { params: { path: { id } } });
|
||||
if (res.response.status >= 400) throw new Error('Suppression impossible');
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
/** Récupération authentifiée du fichier (un lien nu ne porte pas le jeton). */
|
||||
export async function blobDocument(id: string): Promise<Blob> {
|
||||
const res = await fetch(`/api/documents/${id}/download`, {
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Téléchargement impossible');
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
export async function telechargerDocument(id: string, fileName: string): Promise<void> {
|
||||
const blob = await blobDocument(id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Ouverture dans un nouvel onglet (PDF et images : le navigateur les affiche). */
|
||||
export async function ouvrirDocument(id: string): Promise<void> {
|
||||
// Onglet ouvert dans le geste utilisateur, sinon les bloqueurs de pop-up
|
||||
// refusent un window.open survenant après le fetch.
|
||||
const onglet = window.open('', '_blank');
|
||||
try {
|
||||
const blob = await blobDocument(id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (onglet) onglet.location.href = url;
|
||||
else window.open(url, '_blank');
|
||||
} catch (e) {
|
||||
onglet?.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ————— Analytics —————
|
||||
|
||||
export function useAnalyticsSummary(months: 3 | 6 | 12 = 12, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['analytics', months],
|
||||
enabled,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET('/analytics/summary', {
|
||||
params: { query: { months: String(months) } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
22
apps/web/src/api/recherche.ts
Normal file
22
apps/web/src/api/recherche.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { SEARCH_MIN_CHARS } from '@siop/shared';
|
||||
import { api } from './client';
|
||||
|
||||
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||
if (res.data !== undefined) return res.data;
|
||||
throw new Error(`Recherche indisponible (${res.response.status})`);
|
||||
}
|
||||
|
||||
/** Recherche globale de la topbar (⌘K). L'API filtre chaque famille par la
|
||||
* matrice — le client n'a aucune logique de permission à porter. */
|
||||
export function useGlobalSearch(q: string) {
|
||||
const terme = q.trim();
|
||||
return useQuery({
|
||||
queryKey: ['search', terme],
|
||||
enabled: terme.length >= SEARCH_MIN_CHARS,
|
||||
staleTime: 30_000,
|
||||
placeholderData: (precedent) => precedent, // pas de clignotement en tapant
|
||||
queryFn: async () =>
|
||||
unwrap(await api.GET('/search', { params: { query: { q: terme } } })),
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CategoryUpdate,
|
||||
InvitationCreate,
|
||||
LocationCreate,
|
||||
LocationUpdate,
|
||||
TeamCreate,
|
||||
UserUpdate,
|
||||
} from '@siop/shared';
|
||||
@@ -38,6 +39,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) {
|
||||
return useQuery({
|
||||
queryKey: ['assets', id],
|
||||
@@ -90,6 +99,15 @@ export function useCreateLocation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateLocation(id: string) {
|
||||
const invalidate = useInvalidate('locations', 'partners');
|
||||
return useMutation({
|
||||
mutationFn: async (body: LocationUpdate) =>
|
||||
unwrap(await api.PATCH('/locations/{id}', { params: { path: { id } }, body })),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateAsset() {
|
||||
const invalidate = useInvalidate('assets', 'locations');
|
||||
return useMutation({
|
||||
|
||||
2628
apps/web/src/api/schema.d.ts
vendored
2628
apps/web/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,9 @@ export function useMe() {
|
||||
}
|
||||
|
||||
/** ADR-002 : la liste n'existe qu'en DEMO_MODE — un 404 signifie « pas de
|
||||
* mode démo », le web n'affiche alors rien (null). */
|
||||
* mode démo », le web n'affiche alors rien (null). Une ERREUR RÉSEAU, en
|
||||
* revanche, signifie que l'API est injoignable : l'écran de connexion
|
||||
* l'affiche clairement (isError) au lieu de masquer la démo en silence. */
|
||||
export function useDemoAccounts() {
|
||||
return useQuery({
|
||||
queryKey: ['demo-accounts'],
|
||||
@@ -29,8 +31,9 @@ export function useDemoAccounts() {
|
||||
staleTime: 5 * 60_000,
|
||||
queryFn: async () => {
|
||||
const { data, response } = await api.GET('/auth/demo-accounts');
|
||||
if (response.status === 404) return null;
|
||||
return data?.accounts ?? null;
|
||||
if (response.status === 404) return null; // pas de mode démo : normal
|
||||
if (!data) throw new Error(`API : ${response.status}`);
|
||||
return data.accounts;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
179
apps/web/src/components/carte-documents.tsx
Normal file
179
apps/web/src/components/carte-documents.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
DOCUMENT_KIND_LABELS,
|
||||
DOCUMENT_KINDS,
|
||||
DOCUMENT_MAX_BYTES,
|
||||
type DocumentDto,
|
||||
type DocumentKind,
|
||||
} from '@siop/shared';
|
||||
import {
|
||||
blobDocument,
|
||||
ouvrirDocument,
|
||||
telechargerDocument,
|
||||
useDeleteDocument,
|
||||
useDocuments,
|
||||
useUploadDocument,
|
||||
} from '@/api/gestion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const CLASSE_TYPE: Partial<Record<DocumentKind, string>> = {
|
||||
CERTIFICATE: 'type-doc certificat',
|
||||
PHOTO: 'type-doc photo',
|
||||
};
|
||||
|
||||
export function tailleLisible(octets: number): string {
|
||||
if (octets === 0) return '0 Ko';
|
||||
if (octets >= 1024 * 1024) return `${(octets / (1024 * 1024)).toFixed(1).replace('.', ',')} Mo`;
|
||||
return `${Math.max(1, Math.round(octets / 1024))} Ko`;
|
||||
}
|
||||
|
||||
/** Aperçu réel des images : le fichier exige le jeton, donc pas de <img src>
|
||||
* direct — on récupère le blob et on affiche une URL objet, révoquée au
|
||||
* démontage. Les PDF gardent leur pictogramme (le navigateur les ouvre). */
|
||||
function ApercuVignette({ doc }: { doc: DocumentDto }) {
|
||||
const estImage = doc.contentType.startsWith('image/');
|
||||
const [url, setUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!estImage) return;
|
||||
let actif = true;
|
||||
let objet: string | undefined;
|
||||
blobDocument(doc.id)
|
||||
.then((blob) => {
|
||||
if (!actif) return;
|
||||
objet = URL.createObjectURL(blob);
|
||||
setUrl(objet);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
actif = false;
|
||||
if (objet) URL.revokeObjectURL(objet);
|
||||
};
|
||||
}, [doc.id, estImage]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="vignette"
|
||||
title={`Ouvrir ${doc.fileName}`}
|
||||
onClick={() => void ouvrirDocument(doc.id)}
|
||||
>
|
||||
{url ? <img src={url} alt={doc.fileName} /> : estImage ? '🖼' : '📄'}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSuppression?: (id: string) => void }) {
|
||||
return (
|
||||
<div className="doc">
|
||||
<ApercuVignette doc={doc} />
|
||||
<span className={CLASSE_TYPE[doc.kind] ?? 'type-doc'}>{DOCUMENT_KIND_LABELS[doc.kind]}</span>
|
||||
<b>{doc.fileName}</b>
|
||||
<span className="meta">
|
||||
{doc.assetReference ? `Asc. ${doc.assetReference} · ` : ''}
|
||||
{doc.workOrderReference ? `${doc.workOrderReference} · ` : ''}
|
||||
{tailleLisible(doc.size)} ·{' '}
|
||||
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(doc.createdAt))}
|
||||
{doc.uploadedByName ? ` · ${doc.uploadedByName}` : ''}
|
||||
</span>
|
||||
<span style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void ouvrirDocument(doc.id)}
|
||||
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
|
||||
>
|
||||
Ouvrir
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void telechargerDocument(doc.id, doc.fileName)}
|
||||
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
|
||||
>
|
||||
Télécharger
|
||||
</button>
|
||||
{surSuppression ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Supprimer « ${doc.fileName} » ?`)) surSuppression(doc.id);
|
||||
}}
|
||||
style={{ color: 'var(--danger)', fontWeight: 600, fontSize: 12.5 }}
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Carte « Documents » des fiches OT / ascenseur — la même bibliothèque,
|
||||
* filtrée sur la cible ; l'upload rattache automatiquement. */
|
||||
export function CarteDocuments({
|
||||
cible,
|
||||
peutEditer,
|
||||
}: {
|
||||
cible: { assetId: string } | { workOrderId: string };
|
||||
peutEditer: boolean;
|
||||
}) {
|
||||
const { data: documents } = useDocuments(cible);
|
||||
const upload = useUploadDocument();
|
||||
const suppression = useDeleteDocument();
|
||||
const fichierRef = useRef<HTMLInputElement>(null);
|
||||
const [kind, setKind] = useState<DocumentKind>('workOrderId' in cible ? 'PHOTO' : 'NOTICE');
|
||||
|
||||
const surFichier = (file: File | undefined) => {
|
||||
if (!file) return;
|
||||
if (file.size > DOCUMENT_MAX_BYTES) {
|
||||
window.alert('Fichier trop lourd (20 Mo max).');
|
||||
return;
|
||||
}
|
||||
upload.mutate({ file, kind, ...cible });
|
||||
if (fichierRef.current) fichierRef.current.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="carte">
|
||||
<h2>Documents</h2>
|
||||
{documents?.length ? (
|
||||
<div className="docs" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
|
||||
{documents.map((d) => (
|
||||
<VignetteDoc
|
||||
key={d.id}
|
||||
doc={d}
|
||||
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-encre-3">Aucun document rattaché.</p>
|
||||
)}
|
||||
{peutEditer ? (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<select
|
||||
aria-label="Type de document"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as DocumentKind)}
|
||||
className="rounded-lg border border-bordure-forte bg-surface p-2 text-encre"
|
||||
>
|
||||
{DOCUMENT_KINDS.map((k) => (
|
||||
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
ref={fichierRef}
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
aria-label="Choisir un fichier"
|
||||
className="hidden"
|
||||
onChange={(e) => surFichier(e.target.files?.[0])}
|
||||
/>
|
||||
<Button disabled={upload.isPending} onClick={() => fichierRef.current?.click()}>
|
||||
{upload.isPending ? 'Téléversement…' : 'Téléverser (PDF, JPG, PNG · 20 Mo)'}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{upload.isError ? <p className="erreur-form">{upload.error.message}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/components/chips-ot.tsx
Normal file
66
apps/web/src/components/chips-ot.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
REQUEST_STATUS_LABELS,
|
||||
WORK_ORDER_PRIORITY_LABELS,
|
||||
WORK_ORDER_STATUS_LABELS,
|
||||
type RequestStatus,
|
||||
type WorkOrderPriority,
|
||||
type WorkOrderStatus,
|
||||
} from '@siop/shared';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const ST_CLASSES: Record<WorkOrderStatus, string> = {
|
||||
OPEN: 'ouvert',
|
||||
IN_PROGRESS: 'encours',
|
||||
ON_HOLD: 'attente',
|
||||
DONE: 'termine',
|
||||
CANCELLED: 'annule',
|
||||
};
|
||||
|
||||
/** Statut OT — les couleurs réservées de la charte. */
|
||||
export function StatutOT({ statut }: { statut: WorkOrderStatus }) {
|
||||
return (
|
||||
<span className={cn('st', ST_CLASSES[statut])}>
|
||||
{WORK_ORDER_STATUS_LABELS[statut]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const PRIO_CLASSES: Record<WorkOrderPriority, string | null> = {
|
||||
NONE: null,
|
||||
LOW: 'basse',
|
||||
MEDIUM: 'moyenne',
|
||||
HIGH: 'haute',
|
||||
PERSON_TRAPPED: 'bloque',
|
||||
};
|
||||
|
||||
/** Priorité — pastille + libellé, jamais la couleur seule (charte). */
|
||||
export function PrioriteOT({ priorite }: { priorite: WorkOrderPriority }) {
|
||||
const classe = PRIO_CLASSES[priorite];
|
||||
if (!classe) return <span className="text-encre-3">—</span>;
|
||||
return (
|
||||
<span className={cn('prio', classe)}>
|
||||
<i />
|
||||
{WORK_ORDER_PRIORITY_LABELS[priorite]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const DEM_CLASSES: Record<RequestStatus, string> = {
|
||||
RECEIVED: 'recue',
|
||||
APPROVED: 'approuvee',
|
||||
REJECTED: 'rejetee',
|
||||
};
|
||||
|
||||
/** Statut de demande — « Résolue » quand l'OT lié est terminé. */
|
||||
export function StatutDemande({
|
||||
statut,
|
||||
otStatut,
|
||||
}: {
|
||||
statut: RequestStatus;
|
||||
otStatut?: WorkOrderStatus | null;
|
||||
}) {
|
||||
if (statut === 'APPROVED' && otStatut === 'DONE') {
|
||||
return <span className="st resolue">Résolue</span>;
|
||||
}
|
||||
return <span className={cn('st', DEM_CLASSES[statut])}>{REQUEST_STATUS_LABELS[statut]}</span>;
|
||||
}
|
||||
179
apps/web/src/components/recherche-globale.tsx
Normal file
179
apps/web/src/components/recherche-globale.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SEARCH_MIN_CHARS } from '@siop/shared';
|
||||
import { useGlobalSearch } from '@/api/recherche';
|
||||
import { StatutOT } from '@/components/chips-ot';
|
||||
import { IcoRecherche } from '@/components/nav-icons';
|
||||
|
||||
interface Resultat {
|
||||
cle: string;
|
||||
route: string;
|
||||
contenu: React.ReactNode;
|
||||
}
|
||||
|
||||
/** La recherche de la topbar, enfin active (recette R3) : ⌘K pour saisir,
|
||||
* résultats groupés (OT / ascenseurs / sites), déjà filtrés par la matrice
|
||||
* côté API — flèches + Entrée pour naviguer. */
|
||||
export function RechercheGlobale() {
|
||||
const navigate = useNavigate();
|
||||
const [saisie, setSaisie] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [ouverte, setOuverte] = useState(false);
|
||||
const [actif, setActif] = useState(0);
|
||||
const champ = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Débounce : on n'interroge l'API que 200 ms après la dernière frappe.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQ(saisie), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [saisie]);
|
||||
|
||||
// ⌘K / Ctrl+K : focus depuis n'importe où.
|
||||
useEffect(() => {
|
||||
const surTouche = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
champ.current?.focus();
|
||||
champ.current?.select();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', surTouche);
|
||||
return () => window.removeEventListener('keydown', surTouche);
|
||||
}, []);
|
||||
|
||||
const { data } = useGlobalSearch(q);
|
||||
|
||||
const resultats = useMemo<Resultat[]>(() => {
|
||||
if (!data) return [];
|
||||
return [
|
||||
...data.workOrders.map((w) => ({
|
||||
cle: `ot-${w.id}`,
|
||||
route: `/ot/${w.id}`,
|
||||
contenu: (
|
||||
<>
|
||||
<span className="ref num">{w.reference}</span>
|
||||
<span className="titre">{w.title}</span>
|
||||
<span className="fin"><StatutOT statut={w.status} /></span>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
...data.assets.map((a) => ({
|
||||
cle: `asc-${a.id}`,
|
||||
route: `/ascenseurs/${a.id}`,
|
||||
contenu: (
|
||||
<>
|
||||
<span className="ref num">{a.reference}</span>
|
||||
<span className="titre">
|
||||
{a.brand}
|
||||
{a.model ? ` ${a.model}` : ''}
|
||||
</span>
|
||||
<span className="fin">{a.siteName}</span>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
...data.sites.map((s) => ({
|
||||
cle: `site-${s.id}`,
|
||||
route: `/sites/${s.id}`,
|
||||
contenu: (
|
||||
<>
|
||||
<span className="titre"><b>{s.name}</b></span>
|
||||
<span className="fin">{s.city ?? ''}</span>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
const groupes = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const g: { libelle: string; debut: number; nombre: number }[] = [];
|
||||
if (data.workOrders.length) g.push({ libelle: 'Ordres de travail', debut: 0, nombre: data.workOrders.length });
|
||||
if (data.assets.length) g.push({ libelle: 'Ascenseurs', debut: data.workOrders.length, nombre: data.assets.length });
|
||||
if (data.sites.length) {
|
||||
g.push({
|
||||
libelle: 'Sites',
|
||||
debut: data.workOrders.length + data.assets.length,
|
||||
nombre: data.sites.length,
|
||||
});
|
||||
}
|
||||
return g;
|
||||
}, [data]);
|
||||
|
||||
const visible = ouverte && q.trim().length >= SEARCH_MIN_CHARS;
|
||||
|
||||
const aller = (r: Resultat | undefined) => {
|
||||
if (!r) return;
|
||||
setOuverte(false);
|
||||
setSaisie('');
|
||||
navigate(r.route);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="recherche" data-active>
|
||||
<IcoRecherche />
|
||||
<input
|
||||
ref={champ}
|
||||
type="search"
|
||||
placeholder="Rechercher un OT, un ascenseur, un site…"
|
||||
aria-label="Recherche globale"
|
||||
value={saisie}
|
||||
onChange={(e) => {
|
||||
setSaisie(e.target.value);
|
||||
setOuverte(true);
|
||||
setActif(0);
|
||||
}}
|
||||
onFocus={() => setOuverte(true)}
|
||||
onBlur={() => setOuverte(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOuverte(false);
|
||||
champ.current?.blur();
|
||||
}
|
||||
if (!visible || !resultats.length) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActif((a) => Math.min(a + 1, resultats.length - 1));
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActif((a) => Math.max(a - 1, 0));
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
aller(resultats[actif]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<kbd>⌘K</kbd>
|
||||
{visible ? (
|
||||
<div className="resultats" role="listbox" aria-label="Résultats">
|
||||
{groupes.map((g) => (
|
||||
<div key={g.libelle}>
|
||||
<div className="groupe-resultats">{g.libelle}</div>
|
||||
{resultats.slice(g.debut, g.debut + g.nombre).map((r, i) => (
|
||||
<button
|
||||
key={r.cle}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={g.debut + i === actif}
|
||||
className="resultat"
|
||||
data-actif={g.debut + i === actif || undefined}
|
||||
onMouseEnter={() => setActif(g.debut + i)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // avant le blur du champ
|
||||
aller(r);
|
||||
}}
|
||||
>
|
||||
{r.contenu}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{!resultats.length ? (
|
||||
<div className="groupe-resultats">Aucun résultat pour « {q.trim()} »</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,10 @@ import { getToken } from '@/api/client';
|
||||
import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth';
|
||||
import { Avatar, initialsOf } from '@/components/avatar';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { RechercheGlobale } from '@/components/recherche-globale';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { ObjectCategory, PermissionRight } from '@siop/shared';
|
||||
import { api } from '@/api/client';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import {
|
||||
IcoAscenseurs,
|
||||
@@ -16,7 +19,6 @@ import {
|
||||
IcoOrdresDeTravail,
|
||||
IcoPersonnes,
|
||||
IcoPreventif,
|
||||
IcoRecherche,
|
||||
IcoSites,
|
||||
IcoStatistiques,
|
||||
IcoStock,
|
||||
@@ -40,6 +42,7 @@ interface LienNav {
|
||||
route?: string;
|
||||
release?: string;
|
||||
permission?: [ObjectCategory, PermissionRight];
|
||||
badge?: 'urgences' | 'demandes';
|
||||
}
|
||||
|
||||
const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
|
||||
@@ -47,9 +50,9 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
|
||||
groupe: 'Exploitation',
|
||||
liens: [
|
||||
{ libelle: 'Tableau de bord', icone: IcoTableauDeBord, route: '/' },
|
||||
{ libelle: 'Ordres de travail', icone: IcoOrdresDeTravail, release: 'R2' },
|
||||
{ libelle: 'Demandes', icone: IcoDemandes, release: 'R2' },
|
||||
{ libelle: 'Préventif', icone: IcoPreventif, release: 'R2' },
|
||||
{ libelle: 'Ordres de travail', icone: IcoOrdresDeTravail, route: '/ot', permission: ['WORK_ORDERS', 'view'], badge: 'urgences' },
|
||||
{ libelle: 'Demandes', icone: IcoDemandes, route: '/demandes', permission: ['REQUESTS', 'view'], badge: 'demandes' },
|
||||
{ libelle: 'Préventif', icone: IcoPreventif, route: '/preventif', permission: ['WORK_ORDERS', 'view'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -63,15 +66,15 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
|
||||
{
|
||||
groupe: 'Ressources',
|
||||
liens: [
|
||||
{ libelle: 'Stock & achats', icone: IcoStock, release: 'R3' },
|
||||
{ libelle: 'Tiers', icone: IcoTiers, release: 'R3' },
|
||||
{ libelle: 'Fichiers', icone: IcoFichiers, release: 'R3' },
|
||||
{ libelle: 'Stock & achats', icone: IcoStock, route: '/stock', permission: ['PARTS', 'view'] },
|
||||
{ libelle: 'Tiers', icone: IcoTiers, route: '/tiers', permission: ['PURCHASE_ORDERS', 'view'] },
|
||||
{ libelle: 'Fichiers', icone: IcoFichiers, route: '/bibliotheque', permission: ['ASSETS', 'view'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
groupe: 'Pilotage',
|
||||
liens: [
|
||||
{ libelle: 'Statistiques', icone: IcoStatistiques, release: 'R3' },
|
||||
{ libelle: 'Statistiques', icone: IcoStatistiques, route: '/statistiques', permission: ['ANALYTICS', 'view'] },
|
||||
{ libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] },
|
||||
],
|
||||
},
|
||||
@@ -83,6 +86,29 @@ const THEME_ICONS: Record<ThemeMode, typeof Sun> = {
|
||||
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 }) {
|
||||
const navigate = useNavigate();
|
||||
const { data: me } = useMe();
|
||||
@@ -91,9 +117,18 @@ export function Coquille({ children }: { children: ReactNode }) {
|
||||
const logout = useLogout();
|
||||
const { can } = usePermissions();
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>(getThemeMode);
|
||||
const { urgences, demandesATraiter } = useSignauxExploitation(
|
||||
can('WORK_ORDERS', 'view'),
|
||||
can('REQUESTS', 'view'),
|
||||
);
|
||||
|
||||
if (!getToken()) return <Navigate to="/connexion" replace />;
|
||||
|
||||
const badges: Record<'urgences' | 'demandes', number> = {
|
||||
urgences: urgences.length,
|
||||
demandes: demandesATraiter,
|
||||
};
|
||||
|
||||
const cyclerTheme = () => {
|
||||
const mode = nextThemeMode(themeMode);
|
||||
applyThemeMode(mode);
|
||||
@@ -116,7 +151,7 @@ export function Coquille({ children }: { children: ReactNode }) {
|
||||
<div className="groupe">{groupe}</div>
|
||||
{liens
|
||||
.filter(({ permission }) => !permission || can(...permission))
|
||||
.map(({ libelle, icone: Icone, route, release }) =>
|
||||
.map(({ libelle, icone: Icone, route, release, badge }) =>
|
||||
route ? (
|
||||
<NavLink
|
||||
key={libelle}
|
||||
@@ -126,6 +161,9 @@ export function Coquille({ children }: { children: ReactNode }) {
|
||||
>
|
||||
<Icone />
|
||||
{libelle}
|
||||
{badge && badges[badge] > 0 ? (
|
||||
<span className="badge-nav num">{badges[badge]}</span>
|
||||
) : null}
|
||||
</NavLink>
|
||||
) : (
|
||||
<a
|
||||
@@ -147,10 +185,20 @@ export function Coquille({ children }: { children: ReactNode }) {
|
||||
|
||||
<div className="contenu">
|
||||
<header className="topbar">
|
||||
<div className="recherche" aria-hidden="true">
|
||||
<IcoRecherche />
|
||||
Rechercher un OT, un ascenseur, un site… ⌘K
|
||||
</div>
|
||||
<RechercheGlobale />
|
||||
{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
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
|
||||
@@ -10,6 +10,8 @@ const loc = (over: Partial<LocationDto>): LocationDto => ({
|
||||
city: null,
|
||||
guardianName: null,
|
||||
guardianPhone: null,
|
||||
partnerId: null,
|
||||
partnerName: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
assetCount: 0,
|
||||
|
||||
157
apps/web/src/pages/achats.tsx
Normal file
157
apps/web/src/pages/achats.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
PURCHASE_ORDER_STATUS_LABELS,
|
||||
type PurchaseOrderDto,
|
||||
type PurchaseOrderStatus,
|
||||
} from '@siop/shared';
|
||||
import { usePurchaseOrders, useTransitionPurchaseOrder } from '@/api/gestion';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const fmt = new Intl.NumberFormat('fr-FR');
|
||||
|
||||
const CLASSE_STATUT: Record<PurchaseOrderStatus, string> = {
|
||||
DRAFT: 'st brouillon',
|
||||
SENT: 'st envoye',
|
||||
RECEIVED: 'st recu',
|
||||
CANCELLED: 'st annule',
|
||||
};
|
||||
|
||||
export function StatutBC({ statut }: { statut: PurchaseOrderStatus }) {
|
||||
return <span className={CLASSE_STATUT[statut]}>{PURCHASE_ORDER_STATUS_LABELS[statut]}</span>;
|
||||
}
|
||||
|
||||
/** Écran 3 des maquettes R3 : liste + panneau — la réception crée les
|
||||
* mouvements d'entrée et fige le PU côté API, en une transaction. */
|
||||
export default function PageAchats() {
|
||||
const { data: bons } = usePurchaseOrders();
|
||||
const { can } = usePermissions();
|
||||
const [selection, setSelection] = useState<string | null>(null);
|
||||
|
||||
const liste = bons ?? [];
|
||||
// sans sélection explicite, le panneau montre le premier BC de la liste
|
||||
const bc = liste.find((b) => b.id === selection) ?? liste[0];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Bons de commande</h1>
|
||||
<div className="actions">
|
||||
{can('PURCHASE_ORDERS', 'create') ? (
|
||||
<Link to="/achats/nouveau">
|
||||
<Button variant="prim">+ Nouveau BC</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grille-2" style={{ gridTemplateColumns: '1.1fr 1fr' }}>
|
||||
<div className="carte" style={{ padding: 0 }}>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>N°</th><th>Fournisseur</th><th>Lignes</th><th>Total</th><th>Statut</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{liste.map((b) => (
|
||||
<tr
|
||||
key={b.id}
|
||||
onClick={() => setSelection(b.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: bc?.id === b.id ? 'var(--primaire-doux)' : undefined,
|
||||
}}
|
||||
>
|
||||
<td className="ref num">{b.reference}</td>
|
||||
<td>{b.supplierName}</td>
|
||||
<td className="num">{b.lines.length}</td>
|
||||
<td className="num">{fmt.format(b.total)} MAD</td>
|
||||
<td><StatutBC statut={b.status} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{bc ? <PanneauBC bc={bc} /> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PanneauBC({ bc }: { bc: PurchaseOrderDto }) {
|
||||
const { can } = usePermissions();
|
||||
const transition = useTransitionPurchaseOrder();
|
||||
const peutEditer = can('PURCHASE_ORDERS', 'edit');
|
||||
|
||||
return (
|
||||
<div className="panneau">
|
||||
<h2
|
||||
style={{
|
||||
fontSize: 13, fontWeight: 700, letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase', color: 'var(--encre-2)',
|
||||
}}
|
||||
>
|
||||
{bc.reference} — {bc.supplierName} <StatutBC statut={bc.status} />
|
||||
</h2>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Pièce</th><th>Qté</th><th>PU</th><th>Total</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bc.lines.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td>{l.designation}</td>
|
||||
<td className="num">{l.quantity}</td>
|
||||
<td className="num">{fmt.format(l.unitPrice)}</td>
|
||||
<td className="num">{fmt.format(l.quantity * l.unitPrice)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="total">
|
||||
<span>Total</span>
|
||||
<span className="num">{fmt.format(bc.total)} MAD</span>
|
||||
</div>
|
||||
{peutEditer && (bc.status === 'DRAFT' || bc.status === 'SENT') ? (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="dangerLeger"
|
||||
disabled={transition.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Annuler le ${bc.reference} ?`)) {
|
||||
transition.mutate({ id: bc.id, to: 'CANCELLED' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Annuler le BC
|
||||
</Button>
|
||||
{bc.status === 'DRAFT' ? (
|
||||
<Button
|
||||
variant="prim"
|
||||
disabled={transition.isPending}
|
||||
onClick={() => transition.mutate({ id: bc.id, to: 'SENT' })}
|
||||
>
|
||||
Envoyer au fournisseur
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="succes"
|
||||
disabled={transition.isPending}
|
||||
onClick={() => transition.mutate({ id: bc.id, to: 'RECEIVED' })}
|
||||
>
|
||||
Réceptionner (→ entrées de stock)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{transition.isError ? <p className="erreur-form">{transition.error.message}</p> : null}
|
||||
<p style={{ fontSize: 12, color: 'var(--encre-3)' }}>
|
||||
La réception crée un mouvement d'entrée par ligne et fige le PU — le stock
|
||||
remonte, l'alerte s'éteint.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
apps/web/src/pages/bibliotheque.tsx
Normal file
214
apps/web/src/pages/bibliotheque.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
import {
|
||||
DOCUMENT_KIND_LABELS,
|
||||
DOCUMENT_KINDS,
|
||||
DOCUMENT_MAX_BYTES,
|
||||
type DocumentKind,
|
||||
} from '@siop/shared';
|
||||
import { useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion';
|
||||
import { useAssetOptions } from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { tailleLisible, VignetteDoc } from '@/components/carte-documents';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
|
||||
/** Écran 7 des maquettes R3 : notices, certificats, photos — toujours
|
||||
* RATTACHÉS (appareil ou OT). Ces documents nourriront le RAG en R5. */
|
||||
export default function PageBibliotheque() {
|
||||
const { can } = usePermissions();
|
||||
const [kind, setKind] = useState<DocumentKind | ''>('');
|
||||
const [assetId, setAssetId] = useState('');
|
||||
const { data: options } = useAssetOptions();
|
||||
const { data: documents } = useDocuments({
|
||||
...(kind ? { kind } : {}),
|
||||
...(assetId ? { assetId } : {}),
|
||||
});
|
||||
const suppression = useDeleteDocument();
|
||||
const [modale, setModale] = useState(false);
|
||||
const [fichierDepose, setFichierDepose] = useState<File | null>(null);
|
||||
const [survol, setSurvol] = useState(false);
|
||||
|
||||
const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0);
|
||||
const peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit');
|
||||
|
||||
const surDepot = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setSurvol(false);
|
||||
const fichier = e.dataTransfer.files?.[0];
|
||||
if (!fichier) return;
|
||||
setFichierDepose(fichier);
|
||||
setModale(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Bibliothèque</h1>
|
||||
<span className="filajout">
|
||||
{documents?.length ?? 0} documents · {tailleLisible(totalOctets)}
|
||||
</span>
|
||||
<div className="actions">
|
||||
{peutEditer ? (
|
||||
<Button variant="prim" onClick={() => setModale(true)}>Téléverser</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="filtres">
|
||||
<select
|
||||
aria-label="Filtrer par type"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as DocumentKind | '')}
|
||||
>
|
||||
<option value="">Type : tous</option>
|
||||
{DOCUMENT_KINDS.map((k) => (
|
||||
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filtrer par appareil"
|
||||
value={assetId}
|
||||
onChange={(e) => setAssetId(e.target.value)}
|
||||
>
|
||||
<option value="">Rattaché à : tout le parc</option>
|
||||
{(options ?? []).map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.reference} — {a.siteName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{peutEditer ? (
|
||||
<button
|
||||
type="button"
|
||||
className="zone-depot"
|
||||
data-survol={survol || undefined}
|
||||
onClick={() => setModale(true)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setSurvol(true);
|
||||
}}
|
||||
onDragLeave={() => setSurvol(false)}
|
||||
onDrop={surDepot}
|
||||
>
|
||||
📂 Glissez un fichier ici — PDF, JPG, PNG · 20 Mo max · rattachez-le à un appareil ou un OT
|
||||
</button>
|
||||
) : null}
|
||||
{documents?.length ? (
|
||||
<div className="docs">
|
||||
{documents.map((d) => (
|
||||
<VignetteDoc
|
||||
key={d.id}
|
||||
doc={d}
|
||||
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>
|
||||
)}
|
||||
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||
Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) —
|
||||
le rattachement propre commence ici.
|
||||
</div>
|
||||
{peutEditer ? (
|
||||
<ModaleTeleversement
|
||||
ouverte={modale}
|
||||
fichierInitial={fichierDepose}
|
||||
surFermeture={(o) => {
|
||||
setModale(o);
|
||||
if (!o) setFichierDepose(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleTeleversement({
|
||||
ouverte,
|
||||
fichierInitial,
|
||||
surFermeture,
|
||||
}: {
|
||||
ouverte: boolean;
|
||||
fichierInitial?: File | null;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const { data: options } = useAssetOptions();
|
||||
const upload = useUploadDocument();
|
||||
const fichierRef = useRef<HTMLInputElement>(null);
|
||||
const [kind, setKind] = useState<DocumentKind>('NOTICE');
|
||||
const [assetId, setAssetId] = useState('');
|
||||
const [fichier, setFichier] = useState<File | null>(null);
|
||||
|
||||
// Fichier arrivé par glisser-déposer : la modale s'ouvre déjà remplie.
|
||||
useEffect(() => {
|
||||
if (fichierInitial) setFichier(fichierInitial);
|
||||
}, [fichierInitial]);
|
||||
|
||||
const envoyer = () => {
|
||||
if (!fichier || !assetId) return;
|
||||
if (fichier.size > DOCUMENT_MAX_BYTES) {
|
||||
window.alert('Fichier trop lourd (20 Mo max).');
|
||||
return;
|
||||
}
|
||||
upload.mutate(
|
||||
{ file: fichier, kind, assetId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFichier(null);
|
||||
surFermeture(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre="Téléverser un document" ouverte={ouverte} surFermeture={surFermeture}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="tv-appareil">Rattacher à l'appareil *</label>
|
||||
<select id="tv-appareil" value={assetId} onChange={(e) => setAssetId(e.target.value)}>
|
||||
<option value="">Sélectionner…</option>
|
||||
{(options ?? []).map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.reference} — {a.siteName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="tv-type">Type *</label>
|
||||
<select id="tv-type" value={kind} onChange={(e) => setKind(e.target.value as DocumentKind)}>
|
||||
{DOCUMENT_KINDS.map((k) => (
|
||||
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
ref={fichierRef}
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
aria-label="Choisir un fichier"
|
||||
className="hidden"
|
||||
onChange={(e) => setFichier(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button type="button" className="zone-photo" onClick={() => fichierRef.current?.click()}>
|
||||
{fichier
|
||||
? `📄 ${fichier.name} (${tailleLisible(fichier.size)})`
|
||||
: '📁 Choisir un fichier — PDF, JPG, PNG · 20 Mo max'}
|
||||
</button>
|
||||
<p className="note-douce">
|
||||
Les photos prises sur une intervention se téléversent plutôt depuis la fiche OT —
|
||||
elles restent rattachées à l'intervention.
|
||||
</p>
|
||||
{upload.isError ? <p className="erreur-form">{upload.error.message}</p> : null}
|
||||
<div className="pied">
|
||||
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||
<Button variant="prim" disabled={!fichier || !assetId || upload.isPending} onClick={envoyer}>
|
||||
Téléverser
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default function PageConnexion() {
|
||||
const navigate = useNavigate();
|
||||
const login = useLogin();
|
||||
const demoLogin = useDemoLogin();
|
||||
const { data: demoAccounts } = useDemoAccounts();
|
||||
const { data: demoAccounts, isError: apiInjoignable } = useDemoAccounts();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
@@ -33,6 +33,12 @@ export default function PageConnexion() {
|
||||
<div className="conn">
|
||||
<div className="carte-conn">
|
||||
<Logo sousTitre="Maintenance d'ascenseurs" />
|
||||
{apiInjoignable ? (
|
||||
<p className="erreur-form" role="alert">
|
||||
L'API ne répond pas — en local, démarrez-la avec <code>pnpm dev</code>{' '}
|
||||
(à la racine, elle lance l'API et le web ensemble).
|
||||
</p>
|
||||
) : null}
|
||||
<form
|
||||
onSubmit={surConnexion}
|
||||
className="flex flex-col gap-[14px]"
|
||||
|
||||
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 { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
|
||||
import { useWorkOrders } from '@/api/exploitation';
|
||||
import {
|
||||
useAddComponent,
|
||||
useAsset,
|
||||
@@ -10,13 +11,15 @@ import {
|
||||
useUpdateAsset,
|
||||
} from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { CarteDocuments } from '@/components/carte-documents';
|
||||
import { StatutOT } from '@/components/chips-ot';
|
||||
import { StatutEquipement } from '@/components/statut-equipement';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
import { formatDateFr } from '@/lib/lieux';
|
||||
|
||||
/** Fiche appareil — écran validé en R0 (maquette-web, écran 5) ; historique
|
||||
* (R2) et documents (R3) sont annoncés, pas simulés. */
|
||||
* réel depuis R2, documents réels depuis R3. */
|
||||
export default function PageFicheAscenseur() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -51,6 +54,9 @@ export default function PageFicheAscenseur() {
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/compteurs`)}>
|
||||
Compteurs
|
||||
</Button>
|
||||
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/etiquette`)}>
|
||||
Étiquette QR
|
||||
</Button>
|
||||
@@ -129,18 +135,8 @@ export default function PageFicheAscenseur() {
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||
<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' }}>
|
||||
<h2>Documents</h2>
|
||||
<p className="text-encre-3">
|
||||
Notices et certificats rejoindront la bibliothèque en R3.
|
||||
</p>
|
||||
</div>
|
||||
<CarteHistorique assetId={asset.id} />
|
||||
<CarteDocuments cible={{ assetId: asset.id }} peutEditer={can('ASSETS', 'edit')} />
|
||||
</div>
|
||||
</div>
|
||||
<NouvelOrgane assetId={asset.id} ouverte={modaleOrgane} surFermeture={setModaleOrgane} />
|
||||
@@ -148,6 +144,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({
|
||||
assetId,
|
||||
ouverte,
|
||||
|
||||
525
apps/web/src/pages/fiche-ot.tsx
Normal file
525
apps/web/src/pages/fiche-ot.tsx
Normal file
@@ -0,0 +1,525 @@
|
||||
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 { useAddLaborTime, useConsumePart, useParts } from '@/api/gestion';
|
||||
import { useUsers } from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { Avatar } from '@/components/avatar';
|
||||
import { CarteDocuments } from '@/components/carte-documents';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
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">
|
||||
<CarteCouts ot={ot} peutEditer={peutEditer} />
|
||||
<CarteDocuments cible={{ workOrderId: ot.id }} peutEditer={peutEditer} />
|
||||
</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 fmtMad = new Intl.NumberFormat('fr-FR');
|
||||
|
||||
function minutesLisibles(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
return h ? `${h} h${m ? ` ${String(m).padStart(2, '0')}` : ''}` : `${m} min`;
|
||||
}
|
||||
|
||||
/** Maquette R3 écran 4 : pièces à prix FIGÉ + main-d'œuvre à taux FIGÉ.
|
||||
* Le coût suit l'OT pour toujours, même si prix et taux changent ensuite. */
|
||||
function CarteCouts({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
|
||||
const [modale, setModale] = useState<'piece' | 'temps' | null>(null);
|
||||
const cloture = ot.status === 'DONE' || ot.status === 'CANCELLED';
|
||||
const { parts, labor, total } = ot.costs;
|
||||
|
||||
return (
|
||||
<div className="carte">
|
||||
<h2>Pièces & main-d'œuvre</h2>
|
||||
{parts.length + labor.length === 0 ? (
|
||||
<p className="text-encre-3">Aucune consommation ni temps saisi.</p>
|
||||
) : (
|
||||
<>
|
||||
{parts.map((p) => (
|
||||
<div className="item-liste" key={p.id}>
|
||||
<span className="ico">▣</span>
|
||||
{p.designation} × {p.quantity}
|
||||
<span className="fin num">{fmtMad.format(p.total)} MAD</span>
|
||||
</div>
|
||||
))}
|
||||
{labor.map((l) => (
|
||||
<div className="item-liste" key={l.id}>
|
||||
<span className="ico">◔</span>
|
||||
{l.displayName} · {minutesLisibles(l.minutes)} × {fmtMad.format(l.hourlyRate)} MAD/h
|
||||
{l.note ? <span className="text-encre-3"> — {l.note}</span> : null}
|
||||
<span className="fin num">{fmtMad.format(l.total)} MAD</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="total">
|
||||
<span>Coût total de l'OT</span>
|
||||
<span className="num">{fmtMad.format(total)} MAD</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{peutEditer && !cloture ? (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||
<Button onClick={() => setModale('piece')}>+ Consommer une pièce</Button>
|
||||
<Button onClick={() => setModale('temps')}>+ Saisir du temps</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{modale === 'piece' ? (
|
||||
<ModaleConsommation otId={ot.id} surFermeture={() => setModale(null)} />
|
||||
) : null}
|
||||
{modale === 'temps' ? (
|
||||
<ModaleTemps otId={ot.id} surFermeture={() => setModale(null)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleConsommation({ otId, surFermeture }: { otId: string; surFermeture: () => void }) {
|
||||
const { data: pieces } = useParts();
|
||||
const consommation = useConsumePart(otId);
|
||||
const [partId, setPartId] = useState('');
|
||||
const [quantite, setQuantite] = useState(1);
|
||||
const piece = (pieces ?? []).find((p) => p.id === partId);
|
||||
const stockApres = piece ? piece.stock - quantite : null;
|
||||
|
||||
return (
|
||||
<Modale titre="Consommer une pièce" ouverte surFermeture={surFermeture}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="cp-piece">Pièce *</label>
|
||||
<select id="cp-piece" value={partId} onChange={(e) => setPartId(e.target.value)}>
|
||||
<option value="">Sélectionner…</option>
|
||||
{(pieces ?? [])
|
||||
.filter((p) => p.isActive)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id} disabled={p.stock <= 0}>
|
||||
{p.designation} — stock : {p.stock}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="cp-qte">Quantité *</label>
|
||||
<input
|
||||
id="cp-qte"
|
||||
type="number"
|
||||
min={1}
|
||||
max={piece?.stock}
|
||||
className="num"
|
||||
style={{ width: 90 }}
|
||||
value={quantite || ''}
|
||||
onChange={(e) => setQuantite(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
{piece ? (
|
||||
<p className="note-douce">
|
||||
Prix figé à la consommation :{' '}
|
||||
<b className="num">
|
||||
{piece.lastUnitPrice != null ? `${fmtMad.format(piece.lastUnitPrice)} MAD/u` : 'aucun prix connu'}
|
||||
</b>{' '}
|
||||
(dernier prix d'achat). Le stock passera à <b className="num">{stockApres}</b>
|
||||
{stockApres != null && stockApres <= piece.threshold
|
||||
? ' — sous le seuil, l’alerte s’allumera.'
|
||||
: '.'}
|
||||
</p>
|
||||
) : null}
|
||||
{consommation.isError ? <p className="erreur-form">{consommation.error.message}</p> : null}
|
||||
<div className="pied">
|
||||
<Button onClick={surFermeture}>Annuler</Button>
|
||||
<Button
|
||||
variant="prim"
|
||||
disabled={!piece || quantite < 1 || (piece && quantite > piece.stock) || consommation.isPending}
|
||||
onClick={() =>
|
||||
consommation.mutate({ partId, quantity: quantite }, { onSuccess: surFermeture })
|
||||
}
|
||||
>
|
||||
Consommer (− stock, prix figé)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleTemps({ otId, surFermeture }: { otId: string; surFermeture: () => void }) {
|
||||
const saisie = useAddLaborTime(otId);
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
const heures = Number(donnees.get('heures') || 0);
|
||||
const minutes = Number(donnees.get('minutes') || 0);
|
||||
const totalMinutes = heures * 60 + minutes;
|
||||
if (totalMinutes < 1) return;
|
||||
saisie.mutate(
|
||||
{
|
||||
minutes: totalMinutes,
|
||||
note: String(donnees.get('note') ?? '').trim() || undefined,
|
||||
},
|
||||
{ onSuccess: surFermeture },
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Modale titre="Saisir du temps" ouverte surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="form-grille">
|
||||
<div className="champ">
|
||||
<label htmlFor="st-h">Heures</label>
|
||||
<input id="st-h" name="heures" type="number" min={0} max={24} defaultValue={1} className="num" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="st-m">Minutes</label>
|
||||
<input id="st-m" name="minutes" type="number" min={0} max={59} defaultValue={0} className="num" />
|
||||
</div>
|
||||
<div className="champ pleine-l">
|
||||
<label htmlFor="st-note">Note (optionnelle)</label>
|
||||
<input id="st-note" name="note" placeholder="Ex. : réglage des coulisseaux" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="note-douce">
|
||||
Le temps est valorisé au <b>taux horaire figé</b> de la personne connectée
|
||||
(géré dans Personnes) — il ne bougera plus, même si le taux change.
|
||||
</p>
|
||||
{saisie.isError ? <p className="erreur-form">{saisie.error.message}</p> : null}
|
||||
<div className="pied">
|
||||
<Button onClick={surFermeture}>Annuler</Button>
|
||||
<Button type="submit" variant="prim" disabled={saisie.isPending}>Enregistrer</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
188
apps/web/src/pages/fiche-piece.tsx
Normal file
188
apps/web/src/pages/fiche-piece.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { STOCK_MOVEMENT_KIND_LABELS } from '@siop/shared';
|
||||
import { useAddStockMovement, usePart } from '@/api/gestion';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
|
||||
const fmt = new Intl.NumberFormat('fr-FR');
|
||||
|
||||
/** Écran 2 des maquettes R3 : les mouvements SONT le stock. */
|
||||
export default function PageFichePiece() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: piece } = usePart(id);
|
||||
const { can } = usePermissions();
|
||||
const [modale, setModale] = useState<'ENTRY' | 'ADJUSTMENT' | null>(null);
|
||||
|
||||
if (!piece) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="filajout">
|
||||
<Link to="/stock">Stock</Link> / {piece.reference}
|
||||
</div>
|
||||
<div className="entete-page">
|
||||
<h1>{piece.designation}</h1>
|
||||
{piece.belowThreshold ? <span className="stock-bas">Sous le seuil</span> : null}
|
||||
<div className="actions">
|
||||
{can('PARTS', 'edit') ? (
|
||||
<>
|
||||
<Button onClick={() => setModale('ENTRY')}>Entrée de stock</Button>
|
||||
<Button onClick={() => setModale('ADJUSTMENT')}>Ajustement d'inventaire</Button>
|
||||
</>
|
||||
) : null}
|
||||
{piece.belowThreshold && can('PURCHASE_ORDERS', 'create') ? (
|
||||
<Link to={`/achats/nouveau?part=${piece.id}`}>
|
||||
<Button variant="prim">Préparer le BC</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grille-2">
|
||||
<div className="carte">
|
||||
<h2>Identité</h2>
|
||||
<dl className="infos">
|
||||
<dt>Référence</dt>
|
||||
<dd className="num">{piece.reference}</dd>
|
||||
<dt>Stock actuel</dt>
|
||||
<dd className="num"><b>{piece.stock}</b> (seuil : {piece.threshold})</dd>
|
||||
<dt>Dernier PU</dt>
|
||||
<dd className="num">
|
||||
{piece.lastUnitPrice != null ? `${fmt.format(piece.lastUnitPrice)} MAD` : 'aucun prix connu'}
|
||||
</dd>
|
||||
<dt>Fournisseur</dt>
|
||||
<dd>
|
||||
{piece.supplierName ? (
|
||||
<Link to="/tiers" style={{ color: 'var(--primaire)', fontWeight: 600 }}>
|
||||
{piece.supplierName}
|
||||
</Link>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</dd>
|
||||
<dt>Compatible</dt>
|
||||
<dd>{piece.compatible ?? '—'}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="carte">
|
||||
<h2>
|
||||
Mouvements{' '}
|
||||
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||
— le stock EST cette liste
|
||||
</span>
|
||||
</h2>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Date</th><th>Mouvement</th><th>Qté</th><th>Origine</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{piece.movements.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td className="num">
|
||||
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(m.createdAt))}
|
||||
</td>
|
||||
<td>
|
||||
<span className={m.quantity > 0 ? 'mvt entree' : 'mvt sortie'}>
|
||||
{m.quantity > 0 ? '+ ' : '− '}
|
||||
{STOCK_MOVEMENT_KIND_LABELS[m.kind]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{Math.abs(m.quantity)}</td>
|
||||
<td>
|
||||
{m.workOrderReference ? (
|
||||
<span className="ref num">{m.workOrderReference}</span>
|
||||
) : m.purchaseOrderReference ? (
|
||||
<>
|
||||
<span className="ref num">{m.purchaseOrderReference}</span>
|
||||
{m.unitPrice != null ? ` · ${fmt.format(m.unitPrice)} MAD/u` : ''}
|
||||
</>
|
||||
) : (
|
||||
m.reason ?? '—'
|
||||
)}
|
||||
{m.byName ? ` · ${m.byName}` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModaleMouvement
|
||||
pieceId={piece.id}
|
||||
kind={modale}
|
||||
surFermeture={() => setModale(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleMouvement({
|
||||
pieceId,
|
||||
kind,
|
||||
surFermeture,
|
||||
}: {
|
||||
pieceId: string;
|
||||
kind: 'ENTRY' | 'ADJUSTMENT' | null;
|
||||
surFermeture: () => void;
|
||||
}) {
|
||||
const mouvement = useAddStockMovement(pieceId);
|
||||
if (!kind) return null;
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
mouvement.mutate(
|
||||
{
|
||||
kind,
|
||||
quantity: Number(donnees.get('quantity')),
|
||||
reason: String(donnees.get('reason') ?? '').trim() || undefined,
|
||||
},
|
||||
{ onSuccess: surFermeture },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale
|
||||
titre={kind === 'ENTRY' ? 'Entrée de stock' : "Ajustement d'inventaire"}
|
||||
ouverte
|
||||
surFermeture={surFermeture}
|
||||
>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="mv-qte">
|
||||
Quantité {kind === 'ADJUSTMENT' ? '(± signée)' : ''} *
|
||||
</label>
|
||||
<input
|
||||
id="mv-qte"
|
||||
name="quantity"
|
||||
type="number"
|
||||
required
|
||||
min={kind === 'ENTRY' ? 1 : undefined}
|
||||
placeholder={kind === 'ENTRY' ? '5' : '-1'}
|
||||
/>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="mv-motif">
|
||||
Motif {kind === 'ADJUSTMENT' ? '(requis)' : '(optionnel)'}
|
||||
</label>
|
||||
<input
|
||||
id="mv-motif"
|
||||
name="reason"
|
||||
required={kind === 'ADJUSTMENT'}
|
||||
placeholder={kind === 'ADJUSTMENT' ? 'Ex. : pièce endommagée à l’inventaire' : 'Ex. : reprise de stock atelier'}
|
||||
/>
|
||||
</div>
|
||||
{mouvement.isError ? <p className="erreur-form">{mouvement.error.message}</p> : null}
|
||||
<div className="pied">
|
||||
<Button onClick={surFermeture}>Annuler</Button>
|
||||
<Button type="submit" variant="prim" disabled={mouvement.isPending}>
|
||||
Tracer le mouvement
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAssets, useCreateLocation, useLocations } from '@/api/referentiel';
|
||||
import { usePartners } from '@/api/gestion';
|
||||
import { useAssets, useCreateLocation, useLocations, useUpdateLocation } from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { CarteGeo } from '@/components/carte-geo';
|
||||
import { StatutEquipement } from '@/components/statut-equipement';
|
||||
@@ -56,6 +57,14 @@ export default function PageFicheSite() {
|
||||
<span className="num"> · {site.guardianPhone}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
<dt>Client / syndic</dt>
|
||||
<dd>
|
||||
<ChampSyndic
|
||||
siteId={site.id}
|
||||
partnerId={site.partnerId}
|
||||
partnerName={site.partnerName}
|
||||
/>
|
||||
</dd>
|
||||
<dt>Appareils</dt>
|
||||
<dd className="num">{site.assetCount}</dd>
|
||||
</dl>
|
||||
@@ -121,6 +130,40 @@ export default function PageFicheSite() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Rattachement du site à son client/syndic (colonne « Rattachements » des
|
||||
* Tiers). Éditable si LOCATIONS.edit ET si la matrice donne accès aux tiers
|
||||
* (la liste des tiers est sous PURCHASE_ORDERS.view) — sinon, lecture seule. */
|
||||
function ChampSyndic({
|
||||
siteId,
|
||||
partnerId,
|
||||
partnerName,
|
||||
}: {
|
||||
siteId: string;
|
||||
partnerId: string | null;
|
||||
partnerName: string | null;
|
||||
}) {
|
||||
const { can } = usePermissions();
|
||||
const editable = can('LOCATIONS', 'edit') && can('PURCHASE_ORDERS', 'view');
|
||||
const { data: partners } = usePartners(editable);
|
||||
const maj = useUpdateLocation(siteId);
|
||||
|
||||
if (!editable) return <>{partnerName ?? '—'}</>;
|
||||
const clients = (partners ?? []).filter((p) => p.kind === 'CLIENT' && p.isActive);
|
||||
return (
|
||||
<select
|
||||
aria-label="Client / syndic du site"
|
||||
value={partnerId ?? ''}
|
||||
onChange={(e) => maj.mutate({ partnerId: e.target.value || null })}
|
||||
disabled={maj.isPending}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function NouvelleZone({
|
||||
siteId,
|
||||
ouverte,
|
||||
|
||||
166
apps/web/src/pages/nouvel-achat.tsx
Normal file
166
apps/web/src/pages/nouvel-achat.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCreatePurchaseOrder, usePartners, useParts } from '@/api/gestion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const fmt = new Intl.NumberFormat('fr-FR');
|
||||
|
||||
type Ligne = { partId: string; quantity: number; unitPrice: number };
|
||||
|
||||
/** Création de BC — préremplie depuis « Préparer le BC » (?part=…) :
|
||||
* fournisseur de la pièce, quantité proposée pour repasser le seuil,
|
||||
* dernier PU connu. Tout reste modifiable avant l'envoi. */
|
||||
export default function PageNouvelAchat() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const partPrerempli = params.get('part');
|
||||
const { data: parts, isFetching } = useParts();
|
||||
const { data: partners } = usePartners();
|
||||
const creation = useCreatePurchaseOrder();
|
||||
|
||||
const [supplierId, setSupplierId] = useState('');
|
||||
const [lignes, setLignes] = useState<Ligne[]>([]);
|
||||
const [initialise, setInitialise] = useState(false);
|
||||
|
||||
const fournisseurs = (partners ?? []).filter((p) => p.kind === 'SUPPLIER' && p.isActive);
|
||||
const pieces = (parts ?? []).filter((p) => p.isActive);
|
||||
|
||||
useEffect(() => {
|
||||
// attendre la fin du refetch : le cache peut porter un stock périmé
|
||||
if (initialise || !parts || isFetching) return;
|
||||
setInitialise(true);
|
||||
const piece = parts.find((p) => p.id === partPrerempli);
|
||||
if (!piece) return;
|
||||
setSupplierId(piece.supplierId ?? '');
|
||||
setLignes([
|
||||
{
|
||||
partId: piece.id,
|
||||
// proposition : revenir 5 unités au-dessus du seuil
|
||||
quantity: Math.max(piece.threshold - piece.stock + 5, 1),
|
||||
unitPrice: piece.lastUnitPrice ?? 0,
|
||||
},
|
||||
]);
|
||||
}, [initialise, parts, isFetching, partPrerempli]);
|
||||
|
||||
const majLigne = (index: number, patch: Partial<Ligne>) =>
|
||||
setLignes((l) => l.map((ligne, i) => (i === index ? { ...ligne, ...patch } : ligne)));
|
||||
|
||||
const total = lignes.reduce((somme, l) => somme + l.quantity * l.unitPrice, 0);
|
||||
const valide =
|
||||
supplierId && lignes.length > 0 && lignes.every((l) => l.partId && l.quantity > 0 && l.unitPrice > 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="filajout">
|
||||
<Link to="/achats">Bons de commande</Link> / nouveau
|
||||
</div>
|
||||
<div className="entete-page">
|
||||
<h1>Nouveau bon de commande</h1>
|
||||
</div>
|
||||
<div className="carte" style={{ maxWidth: 760 }}>
|
||||
<div className="champ" style={{ marginBottom: 14 }}>
|
||||
<label htmlFor="bc-fournisseur">Fournisseur *</label>
|
||||
<select
|
||||
id="bc-fournisseur"
|
||||
value={supplierId}
|
||||
onChange={(e) => setSupplierId(e.target.value)}
|
||||
>
|
||||
<option value="">Sélectionner…</option>
|
||||
{fournisseurs.map((f) => (
|
||||
<option key={f.id} value={f.id}>{f.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Pièce</th><th style={{ width: 90 }}>Qté</th><th style={{ width: 120 }}>PU (MAD)</th><th style={{ width: 100 }}>Total</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lignes.map((ligne, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`Pièce de la ligne ${i + 1}`}
|
||||
value={ligne.partId}
|
||||
onChange={(e) => {
|
||||
const piece = pieces.find((p) => p.id === e.target.value);
|
||||
majLigne(i, {
|
||||
partId: e.target.value,
|
||||
unitPrice: piece?.lastUnitPrice ?? ligne.unitPrice,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">Sélectionner…</option>
|
||||
{pieces.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.reference} — {p.designation} (stock : {p.stock})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`Quantité de la ligne ${i + 1}`}
|
||||
type="number"
|
||||
min={1}
|
||||
className="num"
|
||||
value={ligne.quantity || ''}
|
||||
onChange={(e) => majLigne(i, { quantity: Number(e.target.value) })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
aria-label={`Prix unitaire de la ligne ${i + 1}`}
|
||||
type="number"
|
||||
min={0.01}
|
||||
step="0.01"
|
||||
className="num"
|
||||
value={ligne.unitPrice || ''}
|
||||
onChange={(e) => majLigne(i, { unitPrice: Number(e.target.value) })}
|
||||
/>
|
||||
</td>
|
||||
<td className="num">{fmt.format(ligne.quantity * ligne.unitPrice)}</td>
|
||||
<td>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-[12px]"
|
||||
onClick={() => setLignes((l) => l.filter((_, j) => j !== i))}
|
||||
>
|
||||
Retirer
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<Button onClick={() => setLignes((l) => [...l, { partId: '', quantity: 1, unitPrice: 0 }])}>
|
||||
+ Ajouter une ligne
|
||||
</Button>
|
||||
</div>
|
||||
<div className="total">
|
||||
<span>Total</span>
|
||||
<span className="num">{fmt.format(total)} MAD</span>
|
||||
</div>
|
||||
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
|
||||
<div className="pied">
|
||||
<Link to="/achats"><Button>Annuler</Button></Link>
|
||||
<Button
|
||||
variant="prim"
|
||||
disabled={!valide || creation.isPending}
|
||||
onClick={() =>
|
||||
creation.mutate(
|
||||
{ supplierId, lines: lignes },
|
||||
{ onSuccess: () => navigate('/achats') },
|
||||
)
|
||||
}
|
||||
>
|
||||
Créer le BC (brouillon)
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useResendInvitation,
|
||||
useRoles,
|
||||
useTeams,
|
||||
useUpdateUser,
|
||||
useUsers,
|
||||
} from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
@@ -44,6 +45,7 @@ export default function PagePersonnes() {
|
||||
<th>Personne</th>
|
||||
<th>Rôle</th>
|
||||
<th>Équipe</th>
|
||||
<th>Taux horaire</th>
|
||||
<th>Statut</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -66,6 +68,13 @@ export default function PagePersonnes() {
|
||||
</td>
|
||||
<td>{u.role.name}</td>
|
||||
<td>{u.teams.map((t) => t.name).join(', ') || '—'}</td>
|
||||
<td>
|
||||
<CelluleTaux
|
||||
userId={u.id}
|
||||
taux={u.hourlyRate}
|
||||
editable={can('PEOPLE_TEAMS', 'edit')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{u.status === 'active' ? (
|
||||
<span className="st termine">Actif</span>
|
||||
@@ -139,6 +148,64 @@ export default function PagePersonnes() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Taux horaire courant (R3) — appliqué aux futures saisies de temps ; les
|
||||
* temps déjà valorisés gardent leur taux figé. */
|
||||
function CelluleTaux({
|
||||
userId,
|
||||
taux,
|
||||
editable,
|
||||
}: {
|
||||
userId: string;
|
||||
taux: number | null;
|
||||
editable: boolean;
|
||||
}) {
|
||||
const maj = useUpdateUser();
|
||||
const [edition, setEdition] = useState(false);
|
||||
|
||||
if (!edition) {
|
||||
return (
|
||||
<span className="num">
|
||||
{taux != null ? `${new Intl.NumberFormat('fr-FR').format(taux)} MAD/h` : '—'}
|
||||
{editable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-1 px-2 py-0.5 text-[12px]"
|
||||
aria-label="Modifier le taux horaire"
|
||||
onClick={() => setEdition(true)}
|
||||
>
|
||||
✎
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
autoFocus
|
||||
defaultValue={taux ?? ''}
|
||||
aria-label="Nouveau taux horaire (MAD/h)"
|
||||
className="num w-24 rounded-lg border border-bordure-forte bg-surface px-2 py-1 text-encre"
|
||||
disabled={maj.isPending}
|
||||
onBlur={(e) => {
|
||||
const valeur = e.target.value.trim();
|
||||
const nombre = Number(valeur);
|
||||
if (valeur && nombre > 0 && nombre !== taux) {
|
||||
maj.mutate({ id: userId, hourlyRate: nombre }, { onSettled: () => setEdition(false) });
|
||||
} else {
|
||||
setEdition(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
if (e.key === 'Escape') setEdition(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleInvitation({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
158
apps/web/src/pages/statistiques.tsx
Normal file
158
apps/web/src/pages/statistiques.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import { ANALYTICS_PERIODS, type AnalyticsPeriod } from '@siop/shared';
|
||||
import { useAnalyticsSummary } from '@/api/gestion';
|
||||
|
||||
const fmt = new Intl.NumberFormat('fr-FR');
|
||||
const fmtCompact = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 1 });
|
||||
|
||||
function enMilliers(v: number): string {
|
||||
return v >= 1000 ? `${fmtCompact.format(v / 1000)} k` : fmt.format(v);
|
||||
}
|
||||
|
||||
/** « 2026-03 » → « mars » (maquette : étiquettes de mois en toutes lettres). */
|
||||
function moisLisible(period: string): string {
|
||||
const [annee, mois] = period.split('-').map(Number);
|
||||
return new Intl.DateTimeFormat('fr-FR', { month: 'long' }).format(new Date(annee, mois - 1, 1));
|
||||
}
|
||||
|
||||
/** Écran 5 des maquettes R3 : M. Bennis lit en 30 s — barres horizontales
|
||||
* à étiquettes directes (palette CVD validée), chiffres tabulaires. */
|
||||
export default function PageStatistiques() {
|
||||
const [periode, setPeriode] = useState<AnalyticsPeriod>(12);
|
||||
const { data } = useAnalyticsSummary(periode);
|
||||
if (!data) return null;
|
||||
|
||||
const delta =
|
||||
data.previousMonthCost > 0
|
||||
? Math.round(((data.monthCost - data.previousMonthCost) / data.previousMonthCost) * 100)
|
||||
: null;
|
||||
const maxPannes = Math.max(...data.failuresByComponent.map((f) => f.count), 1);
|
||||
const maxCout = Math.max(...data.costsByMonth.map((c) => c.total), 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Statistiques</h1>
|
||||
<div className="actions">
|
||||
<select
|
||||
aria-label="Période"
|
||||
value={periode}
|
||||
onChange={(e) => setPeriode(Number(e.target.value) as AnalyticsPeriod)}
|
||||
>
|
||||
{ANALYTICS_PERIODS.map((m) => (
|
||||
<option key={m} value={m}>Période : {m} derniers mois</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||
<div className="kpi">
|
||||
<div className="lib">
|
||||
Coût · {new Intl.DateTimeFormat('fr-FR', { month: 'long' }).format(new Date())}
|
||||
</div>
|
||||
<div className="val num">
|
||||
{fmt.format(data.monthCost)}{' '}
|
||||
<span style={{ fontSize: 14, color: 'var(--encre-2)' }}>MAD</span>
|
||||
</div>
|
||||
{delta != null ? (
|
||||
<div className={`delta ${delta <= 0 ? 'bon' : 'mauvais'}`}>
|
||||
{delta > 0 ? '+' : '−'}{Math.abs(delta)} % vs mois précédent
|
||||
</div>
|
||||
) : (
|
||||
<div className="delta neutre">pas de mois précédent</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="lib">OT clôturés · {data.months} mois</div>
|
||||
<div className="val num">{fmt.format(data.closed.total)}</div>
|
||||
<div className="delta neutre">dont {fmt.format(data.closed.preventive)} préventifs</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="lib">Taux de préventif</div>
|
||||
<div className="val num">
|
||||
{data.preventiveRate != null ? `${Math.round(data.preventiveRate * 100)} %` : '—'}
|
||||
</div>
|
||||
<div className="delta bon">grilles faites / dues</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="lib">Durée moyenne de résolution</div>
|
||||
<div className="val num">
|
||||
{data.avgResolutionDays != null ? `${fmtCompact.format(data.avgResolutionDays)} j` : '—'}
|
||||
</div>
|
||||
<div className="delta neutre">dépannages, {data.months} mois</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grille-2">
|
||||
<div className="carte graf">
|
||||
<h2>
|
||||
Pannes par organe · {data.months} mois{' '}
|
||||
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||
(bilans codés)
|
||||
</span>
|
||||
</h2>
|
||||
<div className="barres-h">
|
||||
{data.failuresByComponent.map((f) => (
|
||||
<div className="ligne-b" key={f.label}>
|
||||
<span>{f.label}</span>
|
||||
<div className="fond-b">
|
||||
<div
|
||||
className="rempli"
|
||||
style={{ width: `${(f.count / maxPannes) * 100}%`, background: 'var(--graf-1)' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="valeur num">{f.count}</span>
|
||||
</div>
|
||||
))}
|
||||
{!data.failuresByComponent.length ? (
|
||||
<p className="text-encre-3">Aucun bilan codé sur la période.</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="carte graf">
|
||||
<h2>
|
||||
Coûts par mois{' '}
|
||||
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||
(pièces + main-d'œuvre, MAD)
|
||||
</span>
|
||||
</h2>
|
||||
<div className="barres-h">
|
||||
{data.costsByMonth.map((c) => (
|
||||
<div className="ligne-b" key={c.month}>
|
||||
<span style={{ textTransform: 'capitalize' }}>{moisLisible(c.month)}</span>
|
||||
<div className="fond-b">
|
||||
<div
|
||||
className="rempli"
|
||||
style={{ width: `${(c.total / maxCout) * 100}%`, background: 'var(--graf-2)' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="valeur num">{enMilliers(c.total)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="carte">
|
||||
<h2>Top équipements en coût · {data.months} mois</h2>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Appareil</th><th>Site</th><th>Dépannages</th><th>Pièces</th><th>Main-d'œuvre</th><th>Total</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.topAssets.map((a) => (
|
||||
<tr key={a.reference}>
|
||||
<td className="ref num">{a.reference}</td>
|
||||
<td>{a.siteName}</td>
|
||||
<td className="num">{a.correctives}</td>
|
||||
<td className="num">{fmt.format(a.partsCost)}</td>
|
||||
<td className="num">{fmt.format(a.laborCost)}</td>
|
||||
<td className="num"><b>{fmt.format(a.total)} MAD</b></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
266
apps/web/src/pages/stock.tsx
Normal file
266
apps/web/src/pages/stock.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAddStockMovement, useCreatePart, usePartners, useParts } from '@/api/gestion';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
|
||||
const fmt = new Intl.NumberFormat('fr-FR');
|
||||
|
||||
/** Écran 1 des maquettes R3 : le stock DÉRIVÉ des mouvements — jamais de
|
||||
* quantité saisie. Sous le seuil : strie ambre + « Préparer le BC »,
|
||||
* et les pièces en alerte remontent en tête de liste (maquette). */
|
||||
export default function PageStock() {
|
||||
const navigate = useNavigate();
|
||||
const { data: parts } = useParts();
|
||||
const { can } = usePermissions();
|
||||
const [modale, setModale] = useState<'piece' | 'entree' | null>(null);
|
||||
const [alerte, setAlerte] = useState(false);
|
||||
const [fournisseur, setFournisseur] = useState('');
|
||||
|
||||
const toutes = parts ?? [];
|
||||
const sousSeuil = toutes.filter((p) => p.belowThreshold && p.isActive);
|
||||
const fournisseurs = [...new Set(toutes.map((p) => p.supplierName).filter(Boolean))] as string[];
|
||||
const visibles = (alerte ? sousSeuil : toutes)
|
||||
.filter((p) => !fournisseur || p.supplierName === fournisseur)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.belowThreshold && b.isActive) - Number(a.belowThreshold && a.isActive) ||
|
||||
a.reference.localeCompare(b.reference),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Stock de pièces</h1>
|
||||
<span className="filajout">
|
||||
{toutes.length} références
|
||||
{sousSeuil.length ? (
|
||||
<>
|
||||
{' · '}
|
||||
<span className="stock-bas">{sousSeuil.length} sous le seuil</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
<div className="actions">
|
||||
{can('PURCHASE_ORDERS', 'view') ? (
|
||||
<Link to="/achats"><Button>Bons de commande</Button></Link>
|
||||
) : null}
|
||||
{can('PARTS', 'edit') ? (
|
||||
<Button onClick={() => setModale('entree')}>Entrée de stock</Button>
|
||||
) : null}
|
||||
{can('PARTS', 'create') ? (
|
||||
<Button variant="prim" onClick={() => setModale('piece')}>
|
||||
+ Nouvelle pièce
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="filtres">
|
||||
<select
|
||||
aria-label="Filtrer par alerte"
|
||||
value={alerte ? 'seuil' : ''}
|
||||
onChange={(e) => setAlerte(e.target.value === 'seuil')}
|
||||
>
|
||||
<option value="">Alerte : toutes</option>
|
||||
<option value="seuil">Sous le seuil</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filtrer par fournisseur"
|
||||
value={fournisseur}
|
||||
onChange={(e) => setFournisseur(e.target.value)}
|
||||
>
|
||||
<option value="">Fournisseur : tous</option>
|
||||
{fournisseurs.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="carte" style={{ padding: 0 }}>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Réf.</th>
|
||||
<th>Désignation</th>
|
||||
<th>Stock</th>
|
||||
<th>Seuil</th>
|
||||
<th>Dernier PU</th>
|
||||
<th>Fournisseur</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibles.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className={p.belowThreshold ? 'ligne-seuil' : undefined}
|
||||
onClick={() => navigate(`/stock/${p.id}`)}
|
||||
style={{ cursor: 'pointer', color: !p.isActive ? 'var(--encre-3)' : undefined }}
|
||||
>
|
||||
<td className="ref num">{p.reference}</td>
|
||||
<td>{p.designation}{!p.isActive ? ' (désactivée)' : ''}</td>
|
||||
<td className="num">{p.belowThreshold ? <b>{p.stock}</b> : p.stock}</td>
|
||||
<td className="num">{p.threshold}</td>
|
||||
<td className="num">{p.lastUnitPrice != null ? `${fmt.format(p.lastUnitPrice)} MAD` : '—'}</td>
|
||||
<td>{p.supplierName ?? '—'}</td>
|
||||
<td onClick={(e) => e.stopPropagation()}>
|
||||
{p.belowThreshold && can('PURCHASE_ORDERS', 'create') ? (
|
||||
<>
|
||||
<span className="stock-bas">Sous le seuil</span>{' '}
|
||||
<Link
|
||||
to={`/achats/nouveau?part=${p.id}`}
|
||||
style={{ color: 'var(--primaire)', fontWeight: 600 }}
|
||||
>
|
||||
Préparer le BC
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||
Le stock affiché = somme des mouvements (réceptions de BC, entrées manuelles,
|
||||
consommations d'OT, ajustements motivés). <b>Aucune saisie directe de quantité.</b>
|
||||
</div>
|
||||
{can('PARTS', 'create') ? (
|
||||
<NouvellePiece ouverte={modale === 'piece'} surFermeture={() => setModale(null)} />
|
||||
) : null}
|
||||
{can('PARTS', 'edit') && modale === 'entree' ? (
|
||||
<EntreeDeStock surFermeture={() => setModale(null)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Entrée de stock depuis la liste (maquette) : on choisit la pièce ici —
|
||||
* même mouvement tracé que depuis la fiche. */
|
||||
function EntreeDeStock({ surFermeture }: { surFermeture: () => void }) {
|
||||
const { data: parts } = useParts();
|
||||
const [pieceId, setPieceId] = useState('');
|
||||
const mouvement = useAddStockMovement(pieceId);
|
||||
const actives = (parts ?? []).filter((p) => p.isActive);
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!pieceId) return;
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
mouvement.mutate(
|
||||
{
|
||||
kind: 'ENTRY',
|
||||
quantity: Number(donnees.get('quantity')),
|
||||
reason: String(donnees.get('reason') ?? '').trim() || undefined,
|
||||
},
|
||||
{ onSuccess: surFermeture },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre="Entrée de stock" ouverte surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="es-piece">Pièce *</label>
|
||||
<select id="es-piece" required value={pieceId} onChange={(e) => setPieceId(e.target.value)}>
|
||||
<option value="">Sélectionner…</option>
|
||||
{actives.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.reference} — {p.designation} (stock : {p.stock})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="es-qte">Quantité *</label>
|
||||
<input id="es-qte" name="quantity" type="number" required min={1} placeholder="5" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="es-motif">Motif (optionnel)</label>
|
||||
<input id="es-motif" name="reason" placeholder="Ex. : reprise de stock atelier" />
|
||||
</div>
|
||||
{mouvement.isError ? <p className="erreur-form">{mouvement.error.message}</p> : null}
|
||||
<div className="pied">
|
||||
<Button onClick={surFermeture}>Annuler</Button>
|
||||
<Button type="submit" variant="prim" disabled={!pieceId || mouvement.isPending}>
|
||||
Tracer le mouvement
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
|
||||
function NouvellePiece({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
}: {
|
||||
ouverte: boolean;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { data: partners } = usePartners();
|
||||
const creation = useCreatePart();
|
||||
const fournisseurs = (partners ?? []).filter((p) => p.kind === 'SUPPLIER' && p.isActive);
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
const champ = (n: string) => String(donnees.get(n) ?? '').trim();
|
||||
creation.mutate(
|
||||
{
|
||||
designation: champ('designation'),
|
||||
threshold: champ('threshold') ? Number(champ('threshold')) : undefined,
|
||||
supplierId: champ('supplierId') || undefined,
|
||||
compatible: champ('compatible') || undefined,
|
||||
initialPrice: champ('initialPrice') ? Number(champ('initialPrice')) : undefined,
|
||||
},
|
||||
{ onSuccess: (piece) => navigate(`/stock/${piece.id}`) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre="Nouvelle pièce" ouverte={ouverte} surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="np-designation">Désignation *</label>
|
||||
<input id="np-designation" name="designation" required placeholder="Ex. : Contact de porte NC-31" />
|
||||
</div>
|
||||
<div className="form-grille">
|
||||
<div className="champ">
|
||||
<label htmlFor="np-seuil">Seuil d'alerte</label>
|
||||
<input id="np-seuil" name="threshold" type="number" min="0" placeholder="0" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="np-prix">Prix initial (MAD)</label>
|
||||
<input id="np-prix" name="initialPrice" type="number" min="0" step="0.01" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="np-fournisseur">Fournisseur</label>
|
||||
<select id="np-fournisseur" name="supplierId" defaultValue="">
|
||||
<option value="">—</option>
|
||||
{fournisseurs.map((f) => (
|
||||
<option key={f.id} value={f.id}>{f.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="np-compatible">Compatible avec</label>
|
||||
<input id="np-compatible" name="compatible" placeholder="Ex. : Otis Gen2" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="note-douce">
|
||||
La référence P-#### est générée. Le stock initial s'ajoute ensuite par
|
||||
une <b>entrée</b> — jamais par un champ de quantité.
|
||||
</p>
|
||||
{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}>Créer la pièce</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,243 @@
|
||||
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 { useAnalyticsSummary } from '@/api/gestion';
|
||||
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,
|
||||
* graphiques) arrive avec l'exploitation (R2), conformément au plan. */
|
||||
const ST_COULEURS: Record<string, string> = {
|
||||
OPEN: 'var(--st-ouvert)',
|
||||
IN_PROGRESS: 'var(--st-encours)',
|
||||
ON_HOLD: 'var(--st-attente)',
|
||||
DONE: 'var(--st-termine)',
|
||||
};
|
||||
|
||||
/** Écran 2 validé R0 — données réelles R2, pannes par organe réelles R3. */
|
||||
export default function PageTableauDeBord() {
|
||||
const { data: me } = useMe();
|
||||
const navigate = useNavigate();
|
||||
const { can, chargé } = usePermissions();
|
||||
const peutVoirOT = can('WORK_ORDERS', 'view');
|
||||
const peutVoirStats = can('ANALYTICS', 'view');
|
||||
const { data: workOrders } = useWorkOrders(peutVoirOT);
|
||||
const { data: requests } = useRequests();
|
||||
const { data: preventif } = usePreventiveStatus(peutVoirOT);
|
||||
const { data: stats } = useAnalyticsSummary(12, peutVoirStats);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Tableau de bord</h1>
|
||||
</div>
|
||||
<div className="carte">
|
||||
<h2>Fondations (R0)</h2>
|
||||
<p>
|
||||
Bienvenue{me ? ` ${me.displayName}` : ''} — vous êtes connecté avec le
|
||||
rôle <b>{me?.role.name}</b>. Cette coquille valide l'authentification,
|
||||
la bascule de rôle en un clic et le bi-thème ; les indicateurs
|
||||
d'exploitation arriveront avec la release R2.
|
||||
</p>
|
||||
<p className="mt-2 text-encre-2">
|
||||
La référence vivante du design est consultable sur la page{' '}
|
||||
<Link to="/design" className="font-semibold text-primaire">
|
||||
/design
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
{me ? (
|
||||
<div className="carte">
|
||||
<h2>Vos droits (matrice en base)</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{me.permissions
|
||||
.filter((p) => p.canView)
|
||||
.map((p) => (
|
||||
<span key={p.objectCategory} className="st ouvert">
|
||||
{p.objectCategory}
|
||||
<div className="entete-page"><h1>Tableau de bord</h1></div>
|
||||
{urgences.map((u) => (
|
||||
<div className="bandeau-urgence" key={u.id}>
|
||||
<span className="point-pulse" />
|
||||
<div>
|
||||
<b>{u.title} — Ascenseur {u.assetReference}</b> ({u.siteName})
|
||||
<br />
|
||||
<span className="depuis">
|
||||
{u.reference} · signalé{' '}
|
||||
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(u.createdAt))}
|
||||
{u.assignees.length === 0 ? ' · aucune intervention en cours' : ''}
|
||||
</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) ? (
|
||||
<p className="text-encre-2">Ce rôle ne voit aucune catégorie d'objets.</p>
|
||||
) : null}
|
||||
<div className="kpis">
|
||||
<div className="kpi">
|
||||
<div className="lib">OT ouverts</div>
|
||||
<div className="val num">{ouverts.length}</div>
|
||||
<div className="delta neutre">
|
||||
dont {tous.filter((w) => w.status === 'IN_PROGRESS').length} en cours
|
||||
</div>
|
||||
</div>
|
||||
<div className={enRetard.length ? 'kpi alarme' : 'kpi'}>
|
||||
<div className="lib">En retard</div>
|
||||
<div className="val num">{enRetard.length}</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="lib">Préventif · {preventif ? mois(preventif.month) : '…'}</div>
|
||||
<div className="val num">
|
||||
{preventif && preventif.generated > 0
|
||||
? `${Math.round((preventif.done / preventif.generated) * 100)} %`
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="delta bon">
|
||||
{preventif ? `${preventif.done} / ${preventif.generated} grilles` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="lib">Demandes à traiter</div>
|
||||
<div className="val num">{aTraiter.length}</div>
|
||||
<div className="delta neutre">
|
||||
{aTraiter[0]
|
||||
? `la + ancienne : ${new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(aTraiter[aTraiter.length - 1]!.createdAt))}`
|
||||
: 'file vide'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grille-2">
|
||||
<div className="carte">
|
||||
<h2>OT par statut</h2>
|
||||
<div className="barres-h flex flex-col gap-2">
|
||||
{parStatut.map(({ statut, n }) => (
|
||||
<div className="ligne-b" key={statut}>
|
||||
<span>{WORK_ORDER_STATUS_LABELS[statut]}</span>
|
||||
<div className="fond-b">
|
||||
<div
|
||||
className="rempli"
|
||||
style={{ width: `${(n / max) * 100}%`, background: ST_COULEURS[statut] }}
|
||||
/>
|
||||
</div>
|
||||
<span className="valeur num">{n}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{peutVoirStats ? (
|
||||
<div className="carte graf">
|
||||
<h2>
|
||||
Pannes par organe · 12 mois{' '}
|
||||
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||
(bilans codés)
|
||||
</span>
|
||||
</h2>
|
||||
{stats?.failuresByComponent.length ? (
|
||||
<div className="barres-h">
|
||||
{stats.failuresByComponent.map((f) => (
|
||||
<div className="ligne-b" key={f.label}>
|
||||
<span>{f.label}</span>
|
||||
<div className="fond-b">
|
||||
<div
|
||||
className="rempli"
|
||||
style={{
|
||||
width: `${(f.count / Math.max(...stats.failuresByComponent.map((x) => x.count), 1)) * 100}%`,
|
||||
background: 'var(--graf-1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="valeur num">{f.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-encre-3">Aucun bilan codé sur la période.</p>
|
||||
)}
|
||||
<p className="mt-2">
|
||||
<Link to="/statistiques" className="text-[12.5px] font-semibold text-primaire">
|
||||
Toutes les statistiques →
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="carte">
|
||||
<h2>Pannes par organe · 12 mois</h2>
|
||||
<p className="text-encre-3">Réservé aux rôles disposant du pilotage.</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>
|
||||
) : 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
133
apps/web/src/pages/tiers.tsx
Normal file
133
apps/web/src/pages/tiers.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { PARTNER_KIND_LABELS, PARTNER_KINDS } from '@siop/shared';
|
||||
import { useCreatePartner, usePartners } from '@/api/gestion';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
|
||||
/** Écran 6 des maquettes R3 : annuaire minimal — pas un CRM. */
|
||||
export default function PageTiers() {
|
||||
const { data: partners } = usePartners();
|
||||
const { can } = usePermissions();
|
||||
const [modale, setModale] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Tiers</h1>
|
||||
<div className="actions">
|
||||
{can('PURCHASE_ORDERS', 'create') ? (
|
||||
<Button variant="prim" onClick={() => setModale(true)}>
|
||||
+ Nouveau tiers
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="carte" style={{ padding: 0 }}>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Nom</th><th>Type</th><th>Contact</th><th>Rattachements</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(partners ?? []).map((p) => (
|
||||
<tr key={p.id} style={!p.isActive ? { color: 'var(--encre-3)' } : undefined}>
|
||||
<td>
|
||||
<b>{p.name}</b>
|
||||
{p.city ? <span className="sous">{p.city}</span> : null}
|
||||
</td>
|
||||
<td>{PARTNER_KIND_LABELS[p.kind]}</td>
|
||||
<td>
|
||||
{p.contactName}
|
||||
{p.contactName && p.phone ? ' · ' : null}
|
||||
{p.phone ? <span className="num">{p.phone}</span> : null}
|
||||
{!p.contactName && !p.phone && !p.email ? '—' : null}
|
||||
{p.email ? <span className="sous">{p.email}</span> : null}
|
||||
</td>
|
||||
<td className="num">
|
||||
{p.kind === 'SUPPLIER'
|
||||
? p.openOrders
|
||||
? `${p.openOrders} BC en cours`
|
||||
: '—'
|
||||
: p.siteNames.length
|
||||
? p.siteNames.join(' · ')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<NouveauTiers ouverte={modale} surFermeture={setModale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NouveauTiers({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
}: {
|
||||
ouverte: boolean;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const creation = useCreatePartner();
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
const champ = (n: string) => String(donnees.get(n) ?? '').trim();
|
||||
creation.mutate(
|
||||
{
|
||||
name: champ('name'),
|
||||
kind: champ('kind') as 'SUPPLIER' | 'CLIENT',
|
||||
contactName: champ('contactName') || undefined,
|
||||
phone: champ('phone') || undefined,
|
||||
email: champ('email') || undefined,
|
||||
city: champ('city') || undefined,
|
||||
},
|
||||
{ onSuccess: () => surFermeture(false) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre="Nouveau tiers" ouverte={ouverte} surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="nt-nom">Nom *</label>
|
||||
<input id="nt-nom" name="name" required placeholder="Ex. : Ascentech Maroc" />
|
||||
</div>
|
||||
<div className="form-grille">
|
||||
<div className="champ">
|
||||
<label htmlFor="nt-type">Type *</label>
|
||||
<select id="nt-type" name="kind" required defaultValue="SUPPLIER">
|
||||
{PARTNER_KINDS.map((k) => (
|
||||
<option key={k} value={k}>{PARTNER_KIND_LABELS[k]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="nt-ville">Ville</label>
|
||||
<input id="nt-ville" name="city" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="nt-contact">Contact</label>
|
||||
<input id="nt-contact" name="contactName" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="nt-tel">Téléphone</label>
|
||||
<input id="nt-tel" name="phone" />
|
||||
</div>
|
||||
<div className="champ pleine-l">
|
||||
<label htmlFor="nt-email">Email</label>
|
||||
<input id="nt-email" name="email" type="email" />
|
||||
</div>
|
||||
</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}>Créer</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
@@ -187,7 +187,38 @@ table {
|
||||
.recherche svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex: none;
|
||||
}
|
||||
.recherche[data-active] { position: relative; color: var(--encre-3); }
|
||||
.recherche input {
|
||||
flex: 1; min-width: 0; border: 0; background: transparent; outline: none;
|
||||
color: var(--encre); font-size: 13px; font-family: inherit;
|
||||
}
|
||||
.recherche input::placeholder { color: var(--encre-3); }
|
||||
.recherche kbd {
|
||||
font-family: inherit; font-size: 11px; color: var(--encre-3);
|
||||
border: 1px solid var(--bordure); border-radius: 5px; padding: 1px 5px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.recherche .resultats {
|
||||
position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 50;
|
||||
background: var(--surface); border: 1px solid var(--bordure);
|
||||
border-radius: var(--rayon); box-shadow: 0 12px 32px rgb(15 23 42 / .14);
|
||||
padding: 6px; max-height: 420px; overflow-y: auto;
|
||||
}
|
||||
.groupe-resultats {
|
||||
font-size: 10.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
|
||||
color: var(--encre-3); padding: 8px 10px 4px;
|
||||
}
|
||||
.resultat {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
padding: 8px 10px; border-radius: 8px; text-align: left;
|
||||
color: var(--encre); font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.resultat[data-actif] { background: var(--primaire-doux); }
|
||||
.resultat .ref { color: var(--primaire); font-weight: 700; flex: none; }
|
||||
.resultat .titre { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.resultat .fin { flex: none; color: var(--encre-2); font-size: 12px; display: inline-flex; }
|
||||
.compte {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
@@ -750,6 +781,223 @@ table {
|
||||
.avatars .avatar { width: 24px; height: 24px; font-size: 10px; border: 2px solid var(--surface); margin-left: -6px; }
|
||||
.avatars .avatar:first-child { margin-left: 0; }
|
||||
|
||||
/* ═══ R2 — Exploitation (classes extraites des maquettes validées) ═══ */
|
||||
/* Statuts de DEMANDE (validés R2 : extension cohérente des couleurs) */
|
||||
.st.recue { color: var(--st-ouvert); background: var(--st-ouvert-fond); }
|
||||
.st.approuvee { color: var(--st-encours); background: var(--st-encours-fond); }
|
||||
.st.rejetee { color: var(--st-annule); background: var(--st-annule-fond); }
|
||||
.st.resolue { color: var(--st-termine); background: var(--st-termine-fond); }
|
||||
|
||||
/* Urgence « personne bloquée » — seule animation continue de l'app */
|
||||
.chip-urgence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
background: var(--prio-bloque-fond);
|
||||
color: var(--prio-bloque);
|
||||
border: 1px solid color-mix(in srgb, var(--prio-bloque) 30%, transparent);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--rayon-pastille);
|
||||
font-weight: 700;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.bandeau-urgence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
background: var(--prio-bloque-fond);
|
||||
border: 1px solid color-mix(in srgb, var(--prio-bloque) 35%, transparent);
|
||||
border-left: 4px solid var(--prio-bloque);
|
||||
border-radius: var(--rayon);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.bandeau-urgence b { color: var(--prio-bloque); }
|
||||
.bandeau-urgence .depuis { color: var(--encre-2); font-size: 12.5px; }
|
||||
|
||||
/* Tuiles KPI (tableau de bord + préventif) */
|
||||
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
@media (max-width: 1100px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
|
||||
.kpi { background: var(--surface); border: 1px solid var(--bordure); border-radius: var(--rayon); padding: 14px 16px; }
|
||||
.kpi .lib { font-size: 11.5px; letter-spacing: 0.05em; text-transform: uppercase; color: var(--encre-2); font-weight: 600; }
|
||||
.kpi .val { font-size: 27px; font-weight: 700; letter-spacing: -0.02em; margin-top: 4px; }
|
||||
.kpi .delta { font-size: 12px; font-weight: 600; margin-top: 2px; }
|
||||
.delta.bon { color: var(--succes); }
|
||||
.delta.mauvais { color: var(--danger); }
|
||||
.delta.neutre { color: var(--encre-3); }
|
||||
.kpi.alarme { border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
|
||||
.kpi.alarme .val { color: var(--danger); }
|
||||
|
||||
/* Barres horizontales (OT par statut) */
|
||||
.barres-h .ligne-b { display: grid; grid-template-columns: 110px 1fr 40px; align-items: center; gap: 10px; font-size: 13px; }
|
||||
.barres-h .fond-b { background: var(--graf-grille); border-radius: 4px; height: 14px; overflow: hidden; }
|
||||
.barres-h .rempli { height: 100%; border-radius: 4px 0 0 4px; }
|
||||
.barres-h .valeur { text-align: right; font-weight: 600; }
|
||||
|
||||
/* Activité chronologique */
|
||||
.chrono { list-style: none; display: flex; flex-direction: column; gap: 0; font-size: 13px; padding: 0; }
|
||||
.chrono li { position: relative; padding: 0 0 16px 22px; }
|
||||
.chrono li::before { content: ''; position: absolute; left: 5px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--bordure-forte); }
|
||||
.chrono li.fait::before { background: var(--primaire); }
|
||||
.chrono li::after { content: ''; position: absolute; left: 8.5px; top: 15px; bottom: -2px; width: 1px; background: var(--bordure); }
|
||||
.chrono li:last-child::after { display: none; }
|
||||
.chrono .quand { color: var(--encre-3); font-size: 11.5px; }
|
||||
|
||||
/* Checklist Fait / N-A */
|
||||
.check { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--bordure); font-size: 13.5px; }
|
||||
.check:last-of-type { border-bottom: 0; }
|
||||
.coche {
|
||||
width: 19px; height: 19px; border-radius: 5px; border: 1.5px solid var(--bordure-forte);
|
||||
flex: none; display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 11px; background: none; padding: 0; cursor: pointer;
|
||||
}
|
||||
.coche.ok { background: var(--succes); border-color: var(--succes); }
|
||||
.coche.na { background: var(--encre-3); border-color: var(--encre-3); font-size: 8.5px; }
|
||||
.check .qui-fait { margin-left: auto; color: var(--encre-3); font-size: 12px; white-space: nowrap; }
|
||||
|
||||
/* Bilan codé (6 champs) */
|
||||
.bilan { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; }
|
||||
@media (max-width: 900px) { .bilan { grid-template-columns: 1fr; } }
|
||||
.bilan .champ-b { font-size: 12.5px; }
|
||||
.bilan .champ-b label { color: var(--encre-2); font-weight: 600; display: block; margin-bottom: 4px; }
|
||||
.bilan .champ-b label em { color: var(--danger); font-style: normal; }
|
||||
.bilan select {
|
||||
width: 100%; padding: 8px 11px; border: 1px solid var(--bordure-forte);
|
||||
border-radius: var(--rayon-controle); font: inherit; background: var(--surface); color: var(--encre);
|
||||
}
|
||||
.avertissement { margin-top: 12px; font-size: 12.5px; color: var(--alerte); }
|
||||
|
||||
/* Préventif */
|
||||
.periode {
|
||||
display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 700;
|
||||
color: var(--primaire); background: var(--primaire-doux); padding: 2px 9px; border-radius: var(--rayon-pastille);
|
||||
}
|
||||
.periode.reglementaire { color: var(--safran); background: var(--safran-doux); }
|
||||
.gen-info { border-left: 3px solid var(--succes); }
|
||||
|
||||
/* Panneau d'approbation (demandes) */
|
||||
.panneau { border: 1px solid var(--bordure); border-radius: var(--rayon); background: var(--surface); padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
/* Interrupteur (personne bloquée) */
|
||||
.gros-toggle {
|
||||
display: flex; align-items: center; gap: 12px; background: var(--prio-bloque-fond);
|
||||
border: 1px solid color-mix(in srgb, var(--prio-bloque) 30%, transparent);
|
||||
border-radius: 10px; padding: 12px 14px;
|
||||
}
|
||||
.gros-toggle b { color: var(--prio-bloque); }
|
||||
.interrupteur {
|
||||
margin-left: auto; width: 44px; height: 24px; border-radius: var(--rayon-pastille);
|
||||
background: var(--bordure-forte); position: relative; flex: none; border: 0; cursor: pointer;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.interrupteur::after {
|
||||
content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px;
|
||||
border-radius: 50%; background: #fff; transition: left var(--transition);
|
||||
}
|
||||
.interrupteur[aria-checked='true'] { background: var(--prio-bloque); }
|
||||
.interrupteur[aria-checked='true']::after { left: 22px; }
|
||||
|
||||
/* Relevés de compteurs */
|
||||
.releve-saisie { display: flex; gap: 8px; align-items: center; }
|
||||
.releve-saisie input {
|
||||
width: 140px; padding: 8px 11px; border: 1px solid var(--bordure-forte);
|
||||
border-radius: var(--rayon-controle); font: inherit; background: var(--surface);
|
||||
color: var(--encre); text-align: right;
|
||||
}
|
||||
|
||||
/* ═══ R3 — Gestion (classes extraites de maquette-r3.html validée) ═══ */
|
||||
/* Statuts de BON DE COMMANDE */
|
||||
.st.brouillon { color: var(--st-annule); background: var(--st-annule-fond); }
|
||||
.st.envoye { color: var(--st-ouvert); background: var(--st-ouvert-fond); }
|
||||
.st.recu { color: var(--st-termine); background: var(--st-termine-fond); }
|
||||
|
||||
/* Alerte de stock sous seuil */
|
||||
.stock-bas {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
color: var(--alerte); font-weight: 700; font-size: 12.5px;
|
||||
}
|
||||
.stock-bas::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--alerte); }
|
||||
.ligne-seuil td:first-child { box-shadow: inset 3px 0 0 var(--alerte); }
|
||||
|
||||
/* Mouvements de stock */
|
||||
.mvt { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; font-size: 12.5px; }
|
||||
.mvt.entree { color: var(--succes); }
|
||||
.mvt.sortie { color: var(--danger); }
|
||||
|
||||
/* Coûts */
|
||||
.total { display: flex; justify-content: space-between; font-weight: 700; padding-top: 10px; font-size: 14px; }
|
||||
|
||||
/* Bibliothèque */
|
||||
.zone-depot {
|
||||
width: 100%; border: 1.5px dashed var(--bordure-forte, var(--bordure));
|
||||
border-radius: var(--rayon); background: var(--surface);
|
||||
padding: 16px; color: var(--encre-2); font-size: 13px; text-align: center;
|
||||
cursor: pointer; transition: border-color .15s, background-color .15s;
|
||||
}
|
||||
.zone-depot:hover, .zone-depot[data-survol] {
|
||||
border-color: var(--primaire); background: var(--primaire-doux); color: var(--encre);
|
||||
}
|
||||
.docs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
@media (max-width: 1100px) { .docs { grid-template-columns: repeat(2, 1fr); } }
|
||||
.doc {
|
||||
border: 1px solid var(--bordure); border-radius: var(--rayon); background: var(--surface);
|
||||
padding: 12px; display: flex; flex-direction: column; gap: 8px; font-size: 12.5px;
|
||||
}
|
||||
.doc .vignette {
|
||||
height: 84px; border-radius: 8px; background: var(--surface-2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--encre-3); font-size: 24px;
|
||||
width: 100%; padding: 0; border: 0; overflow: hidden; cursor: pointer;
|
||||
}
|
||||
.doc .vignette img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.doc b { font-size: 13px; word-break: break-all; }
|
||||
.doc .meta { color: var(--encre-2); font-size: 11.5px; }
|
||||
.type-doc {
|
||||
display: inline-flex; font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em;
|
||||
text-transform: uppercase; color: var(--primaire); background: var(--primaire-doux);
|
||||
padding: 2px 8px; border-radius: var(--rayon-pastille); align-self: flex-start;
|
||||
}
|
||||
.type-doc.certificat { color: var(--safran); background: var(--safran-doux); }
|
||||
.type-doc.photo { color: var(--graf-3); background: var(--st-termine-fond); }
|
||||
|
||||
/* ═══ 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 ═══ */
|
||||
.nuancier {
|
||||
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>
|
||||
840
docs/02-design/maquettes/maquette-r3.html
Normal file
840
docs/02-design/maquettes/maquette-r3.html
Normal file
@@ -0,0 +1,840 @@
|
||||
<!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 · R3 Gestion</title>
|
||||
</head>
|
||||
<body>
|
||||
<!--
|
||||
SIOP V2 — Maquettes HD de la release R3 « Gestion » (design-first).
|
||||
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE APPLICATIF R3 (principe n°1).
|
||||
Écrans déjà validés non redessinés : la tuile « Coût » du tableau de bord et
|
||||
la carte « Pièces & main-d'œuvre » de la fiche OT (maquette-web, R0).
|
||||
CSS répliquée des maquettes validées ; ajouts R3 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 R3 (à reverser dans l'app après validation) ═══ */
|
||||
/* Statuts de BON DE COMMANDE */
|
||||
.st.brouillon{color:var(--st-annule);background:var(--st-annule-fond)}
|
||||
.st.envoye{color:var(--st-ouvert);background:var(--st-ouvert-fond)}
|
||||
.st.recu{color:var(--st-termine);background:var(--st-termine-fond)}
|
||||
|
||||
/* Alerte de stock sous seuil */
|
||||
.stock-bas{display:inline-flex;align-items:center;gap:6px;color:var(--alerte);font-weight:700;font-size:12.5px}
|
||||
.stock-bas::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--alerte)}
|
||||
.ligne-seuil td:first-child{box-shadow:inset 3px 0 0 var(--alerte)}
|
||||
|
||||
/* Mouvements de stock : entrée / sortie */
|
||||
.mvt{display:inline-flex;align-items:center;gap:5px;font-weight:700;font-size:12.5px}
|
||||
.mvt.entree{color:var(--succes)}
|
||||
.mvt.sortie{color:var(--danger)}
|
||||
|
||||
/* Documents (bibliothèque) */
|
||||
.docs{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
|
||||
@media(max-width:1100px){.docs{grid-template-columns:repeat(2,1fr)}}
|
||||
.doc{border:1px solid var(--bordure);border-radius:var(--rayon);background:var(--surface);
|
||||
padding:12px;display:flex;flex-direction:column;gap:8px;font-size:12.5px}
|
||||
.doc .vignette{height:84px;border-radius:8px;background:var(--surface-2);display:flex;
|
||||
align-items:center;justify-content:center;color:var(--encre-3);font-size:24px}
|
||||
.doc b{font-size:13px;word-break:break-all}
|
||||
.doc .meta{color:var(--encre-2);font-size:11.5px}
|
||||
.type-doc{display:inline-flex;font-size:10.5px;font-weight:700;letter-spacing:.05em;
|
||||
text-transform:uppercase;color:var(--primaire);background:var(--primaire-doux);
|
||||
padding:2px 8px;border-radius:999px;align-self:flex-start}
|
||||
|
||||
/* Total & lignes de coûts */
|
||||
.total{display:flex;justify-content:space-between;font-weight:700;padding-top:10px;font-size:14px}
|
||||
.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}
|
||||
</style>
|
||||
|
||||
<!-- ═══════════ BARRE DE REVUE ═══════════ -->
|
||||
<header class="revue">
|
||||
<b>SIOP V2 · Maquettes R3 — Gestion</b>
|
||||
<nav class="onglets" role="tablist" aria-label="Écrans">
|
||||
<button role="tab" aria-selected="true" data-cible="stock">1 · Stock</button>
|
||||
<button role="tab" aria-selected="false" data-cible="fiche-piece">2 · Fiche pièce</button>
|
||||
<button role="tab" aria-selected="false" data-cible="bons">3 · Bons de commande</button>
|
||||
<button role="tab" aria-selected="false" data-cible="couts-ot">4 · Coûts sur OT</button>
|
||||
<button role="tab" aria-selected="false" data-cible="statistiques">5 · Statistiques</button>
|
||||
<button role="tab" aria-selected="false" data-cible="tiers">6 · Tiers</button>
|
||||
<button role="tab" aria-selected="false" data-cible="bibliotheque">7 · Bibliothèque</button>
|
||||
</nav>
|
||||
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
|
||||
</header>
|
||||
|
||||
<!-- ═══════════ 1 · STOCK ═══════════ -->
|
||||
<p class="note-ecran" data-note="stock"><b>Intention :</b> le stock est <b>DÉRIVÉ des mouvements</b> (décision v1 éprouvée) — jamais de champ « quantité » éditable. Sous le seuil : strie ambre + alerte, et le bouton « préparer le BC » prérempli. Prix unitaire = dernier prix d'achat (figé à chaque consommation).</p>
|
||||
<section class="ecran actif" id="stock">
|
||||
<div class="app"><aside class="nav">
|
||||
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||
<div class="groupe">Ressources</div>
|
||||
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3.3 8.3L12 13l8.7-4.7M12 13v9"/></svg>Stock & achats</a>
|
||||
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 21a7 7 0 1 1 6-11l6 6v3h-3l-1-1-2 1-1-2-2 1z" transform="rotate(180 12 12)"/><circle cx="9" cy="9" r="1.5"/></svg>Tiers</a>
|
||||
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Fichiers</a>
|
||||
</aside>
|
||||
<div class="contenu">
|
||||
<div class="topbar">
|
||||
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher une pièce…</div>
|
||||
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-4)">NB</span>
|
||||
<div class="qui"><b>Nadia Berrada</b><br><small>Gestionnaire ▾</small></div></div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<div class="entete-page">
|
||||
<h1>Stock de pièces</h1><span class="filajout">42 références · <span class="stock-bas">2 sous le seuil</span></span>
|
||||
<div class="actions">
|
||||
<button class="btn">Entrée de stock</button>
|
||||
<button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouvelle pièce</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filtres">
|
||||
<span class="select">Alerte : <b>Toutes</b> ▾</span>
|
||||
<span class="select">Fournisseur : <b>Tous</b> ▾</span>
|
||||
</div>
|
||||
<div class="carte" style="padding:0"><div class="table"><table>
|
||||
<thead><tr><th>Réf.</th><th>Désignation</th><th>Stock</th><th>Seuil</th><th>Dernier PU</th><th>Fournisseur</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="ligne-seuil"><td class="ref num">P-0113</td><td>Contact de porte NC-31</td><td class="num"><b>2</b></td><td class="num">5</td><td class="num">85 MAD</td><td>Ascentech Maroc</td><td><span class="stock-bas">Sous le seuil</span> · <a href="#" style="color:var(--primaire);font-weight:600">Préparer le BC</a></td></tr>
|
||||
<tr class="ligne-seuil"><td class="ref num">P-0087</td><td>Coulisseau de guide 16 mm</td><td class="num"><b>4</b></td><td class="num">6</td><td class="num">120 MAD</td><td>Ascentech Maroc</td><td><span class="stock-bas">Sous le seuil</span> · <a href="#" style="color:var(--primaire);font-weight:600">Préparer le BC</a></td></tr>
|
||||
<tr><td class="ref num">P-0042</td><td>Graisse guide (cartouche 400 g)</td><td class="num">18</td><td class="num">8</td><td class="num">85 MAD</td><td>Lubmaroc</td><td></td></tr>
|
||||
<tr><td class="ref num">P-0031</td><td>Bouton palier lumineux Ø22</td><td class="num">26</td><td class="num">10</td><td class="num">45 MAD</td><td>Ascentech Maroc</td><td></td></tr>
|
||||
<tr><td class="ref num">P-0019</td><td>Cellule barrière porte (paire)</td><td class="num">7</td><td class="num">4</td><td class="num">640 MAD</td><td>SchindlerParts</td><td></td></tr>
|
||||
</tbody>
|
||||
</table></div></div>
|
||||
<div class="carte" style="border-style:dashed;color:var(--encre-2);font-size:13px">
|
||||
Le stock affiché = somme des mouvements (réceptions de BC, entrées manuelles, consommations d'OT,
|
||||
ajustements d'inventaire motivés). <b>Aucune saisie directe de quantité.</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 2 · FICHE PIÈCE ═══════════ -->
|
||||
<p class="note-ecran" data-note="fiche-piece" hidden><b>Intention :</b> l'histoire d'une pièce — chaque ligne de stock vient d'un mouvement daté, tracé, motivé (réception BC, consommation OT, ajustement). Le lien vers l'OT ou le BC est toujours cliquable.</p>
|
||||
<section class="ecran" id="fiche-piece">
|
||||
<div class="app"><aside class="nav">
|
||||
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||
<div class="groupe">Ressources</div>
|
||||
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3.3 8.3L12 13l8.7-4.7M12 13v9"/></svg>Stock & achats</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-4)">NB</span>
|
||||
<div class="qui"><b>Nadia Berrada</b><br><small>Gestionnaire ▾</small></div></div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<div class="filajout"><a href="#">Stock</a> / P-0113</div>
|
||||
<div class="entete-page">
|
||||
<h1>Contact de porte NC-31</h1><span class="stock-bas">Sous le seuil</span>
|
||||
<div class="actions"><button class="btn">Ajustement d'inventaire</button><button class="btn prim">Préparer le BC</button></div>
|
||||
</div>
|
||||
<div class="grille-2">
|
||||
<div class="carte">
|
||||
<h2>Identité</h2>
|
||||
<dl class="infos">
|
||||
<dt>Référence</dt><dd class="num">P-0113</dd>
|
||||
<dt>Stock actuel</dt><dd class="num"><b>2</b> (seuil : 5)</dd>
|
||||
<dt>Dernier PU</dt><dd class="num">85 MAD <span style="color:var(--encre-2)">(BC-2026-0021, 12 juin)</span></dd>
|
||||
<dt>Fournisseur</dt><dd><a href="#">Ascentech Maroc</a></dd>
|
||||
<dt>Compatible</dt><dd>Otis Gen2 · Schindler 3300</dd>
|
||||
</dl>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<h2>Mouvements <span style="text-transform:none;letter-spacing:0;color:var(--encre-3)">— le stock EST cette liste</span></h2>
|
||||
<div class="table"><table>
|
||||
<thead><tr><th>Date</th><th>Mouvement</th><th>Qté</th><th>Origine</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="num">14 juil.</td><td><span class="mvt sortie">− Sortie</span></td><td class="num">1</td><td><a href="#" class="ref num">OT-2026-0336</a> · Ahmed</td></tr>
|
||||
<tr><td class="num">2 juil.</td><td><span class="mvt sortie">− Sortie</span></td><td class="num">2</td><td><a href="#" class="ref num">OT-2026-0329</a> · Fatima-Zahra</td></tr>
|
||||
<tr><td class="num">12 juin</td><td><span class="mvt entree">+ Réception</span></td><td class="num">5</td><td><a href="#" class="ref num">BC-2026-0021</a> · 85 MAD/u</td></tr>
|
||||
<tr><td class="num">3 mai</td><td><span class="mvt sortie">− Ajustement</span></td><td class="num">1</td><td>Inventaire — pièce endommagée (Nadia)</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 3 · BONS DE COMMANDE ═══════════ -->
|
||||
<p class="note-ecran" data-note="bons" hidden><b>Intention :</b> le circuit d'achat minimal : Brouillon → Envoyé → Reçu. <b>La réception crée les mouvements d'entrée</b> (et fige le PU). Le BC préparé depuis une alerte arrive prérempli.</p>
|
||||
<section class="ecran" id="bons">
|
||||
<div class="app"><aside class="nav">
|
||||
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||
<div class="groupe">Ressources</div>
|
||||
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3.3 8.3L12 13l8.7-4.7M12 13v9"/></svg>Stock & achats</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-4)">NB</span>
|
||||
<div class="qui"><b>Nadia Berrada</b><br><small>Gestionnaire ▾</small></div></div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<div class="entete-page">
|
||||
<h1>Bons de commande</h1>
|
||||
<div class="actions"><button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouveau BC</button></div>
|
||||
</div>
|
||||
<div class="grille-2" style="grid-template-columns:1.1fr 1fr">
|
||||
<div class="carte" style="padding:0"><div class="table"><table>
|
||||
<thead><tr><th>N°</th><th>Fournisseur</th><th>Lignes</th><th>Total</th><th>Statut</th></tr></thead>
|
||||
<tbody>
|
||||
<tr style="background:var(--primaire-doux)"><td class="ref num">BC-2026-0024</td><td>Ascentech Maroc</td><td class="num">2</td><td class="num">1 105 MAD</td><td><span class="st envoye">Envoyé</span></td></tr>
|
||||
<tr><td class="ref num">BC-2026-0023</td><td>SchindlerParts</td><td class="num">1</td><td class="num">1 280 MAD</td><td><span class="st brouillon">Brouillon</span></td></tr>
|
||||
<tr><td class="ref num">BC-2026-0021</td><td>Ascentech Maroc</td><td class="num">3</td><td class="num">1 940 MAD</td><td><span class="st recu">Reçu</span></td></tr>
|
||||
<tr><td class="ref num">BC-2026-0019</td><td>Lubmaroc</td><td class="num">1</td><td class="num">850 MAD</td><td><span class="st recu">Reçu</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)">BC-2026-0024 — Ascentech Maroc</h2>
|
||||
<div class="table"><table>
|
||||
<thead><tr><th>Pièce</th><th>Qté</th><th>PU</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Contact de porte NC-31</td><td class="num">10</td><td class="num">85</td><td class="num">850</td></tr>
|
||||
<tr><td>Coulisseau de guide 16 mm</td><td class="num">2</td><td class="num">127,50</td><td class="num">255</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
<div class="total"><span>Total</span><span class="num">1 105 MAD</span></div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn danger-l">Annuler le BC</button>
|
||||
<button class="btn succes">Réceptionner (→ entrées de stock)</button>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--encre-3)">La réception crée un mouvement d'entrée par ligne et fige le PU — le stock remonte, l'alerte s'éteint.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 4 · COÛTS SUR OT ═══════════ -->
|
||||
<p class="note-ecran" data-note="couts-ot" hidden><b>Intention :</b> la carte « Pièces & main-d'œuvre » (fiche OT validée R0) devient réelle : consommer une pièce <b>décrémente le stock et FIGE le prix</b> ; la main-d'œuvre = heures × <b>taux figé</b> de la personne (géré dans Personnes). Le coût total suit l'OT pour toujours, même si prix et taux changent ensuite.</p>
|
||||
<section class="ecran" id="couts-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="#" 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-0341</div>
|
||||
<div class="entete-page"><h1>Bruit anormal en gaine</h1><span class="st encours">En cours</span></div>
|
||||
<div class="grille-2">
|
||||
<div class="carte">
|
||||
<h2>Pièces & main-d'œuvre</h2>
|
||||
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8z"/></svg></span>Coulisseau de guide 16 mm × 2<span class="fin num">240 MAD</span></div>
|
||||
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8z"/></svg></span>Graisse guide (cartouche) × 1<span class="fin num">85 MAD</span></div>
|
||||
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg></span>Ahmed Benali · 1 h 30 × 120 MAD/h<span class="fin num">180 MAD</span></div>
|
||||
<div class="total"><span>Coût total de l'OT</span><span class="num">505 MAD</span></div>
|
||||
<div style="display:flex;gap:8px;margin-top:10px">
|
||||
<button class="btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Consommer une pièce</button>
|
||||
<button class="btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>Saisir du temps</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="voile" style="border-radius:var(--rayon)">
|
||||
<div class="modale" style="position:static;transform:none;width:380px">
|
||||
<h3>Consommer une pièce</h3>
|
||||
<div class="champ"><label>Pièce</label><div class="faux-select">Coulisseau de guide 16 mm — stock : 4 <span>▾</span></div></div>
|
||||
<div class="champ"><label for="cp-qte">Quantité</label><input id="cp-qte" value="2" class="num" style="width:90px"></div>
|
||||
<div class="note-douce" style="font-size:12px;background:var(--surface-2);border-radius:8px;padding:8px 10px;color:var(--encre-2)">
|
||||
Prix figé à la consommation : <b class="num">120 MAD/u</b> (dernier prix d'achat).
|
||||
Le stock passera à <b class="num">2</b> — sous le seuil, une alerte partira.
|
||||
</div>
|
||||
<div class="pied"><button class="btn">Annuler</button><button class="btn prim">Consommer (− stock, prix figé)</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 5 · STATISTIQUES ═══════════ -->
|
||||
<p class="note-ecran" data-note="statistiques" hidden><b>Intention :</b> M. Bennis lit en 30 s : coûts, pannes par organe (ENFIN alimenté par les bilans codés), taux de préventif, top équipements. Palette graphique validée CVD, étiquettes directes, chiffres tabulaires.</p>
|
||||
<section class="ecran" id="statistiques">
|
||||
<div class="app"><aside class="nav">
|
||||
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||
<div class="groupe">Pilotage</div>
|
||||
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 3v18h18"/><rect x="7" y="12" width="3" height="6"/><rect x="12" y="8" width="3" height="10"/><rect x="17" y="5" width="3" height="13"/></svg>Statistiques</a>
|
||||
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0M17 8a3 3 0 1 1 0 .01M16.5 13.5a6 6 0 0 1 5 6"/></svg>Personnes</a>
|
||||
</aside>
|
||||
<div class="contenu">
|
||||
<div class="topbar">
|
||||
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--safran)">RB</span>
|
||||
<div class="qui"><b>Rachid Bennis</b><br><small>Vue seule ▾</small></div></div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<div class="entete-page">
|
||||
<h1>Statistiques</h1>
|
||||
<div class="actions"><span class="select">Période : <b>12 derniers mois</b> ▾</span></div>
|
||||
</div>
|
||||
<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
|
||||
<div class="kpi"><div class="lib">Coût · juillet</div><div class="val num">18 450 <span style="font-size:14px;color:var(--encre-2)">MAD</span></div><div class="delta bon">−12 % vs juin</div></div>
|
||||
<div class="kpi"><div class="lib">OT clôturés · 12 mois</div><div class="val num">241</div><div class="delta neutre">dont 148 préventifs</div></div>
|
||||
<div class="kpi"><div class="lib">Taux de préventif</div><div class="val num">92 %</div><div class="delta bon">grilles faites / dues</div></div>
|
||||
<div class="kpi"><div class="lib">Durée moyenne de résolution</div><div class="val num">2,4 j</div><div class="delta neutre">dépannages, 12 mois</div></div>
|
||||
</div>
|
||||
<div class="grille-2">
|
||||
<div class="carte graf">
|
||||
<h2>Pannes par organe · 12 mois <span style="text-transform:none;letter-spacing:0;color:var(--encre-3)">(bilans codés)</span></h2>
|
||||
<div class="barres-h">
|
||||
<div class="ligne-b"><span>Portes</span><div class="fond-b"><div class="rempli" style="width:100%;background:var(--graf-1)"></div></div><span class="valeur num">34</span></div>
|
||||
<div class="ligne-b"><span>Armoire cde</span><div class="fond-b"><div class="rempli" style="width:53%;background:var(--graf-1)"></div></div><span class="valeur num">18</span></div>
|
||||
<div class="ligne-b"><span>Boutons</span><div class="fond-b"><div class="rempli" style="width:35%;background:var(--graf-1)"></div></div><span class="valeur num">12</span></div>
|
||||
<div class="ligne-b"><span>Guides</span><div class="fond-b"><div class="rempli" style="width:26%;background:var(--graf-1)"></div></div><span class="valeur num">9</span></div>
|
||||
<div class="ligne-b"><span>Treuil</span><div class="fond-b"><div class="rempli" style="width:21%;background:var(--graf-1)"></div></div><span class="valeur num">7</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="carte graf">
|
||||
<h2>Coûts par mois <span style="text-transform:none;letter-spacing:0;color:var(--encre-3)">(pièces + main-d'œuvre, MAD)</span></h2>
|
||||
<div class="barres-h">
|
||||
<div class="ligne-b"><span>Mars</span><div class="fond-b"><div class="rempli" style="width:72%;background:var(--graf-2)"></div></div><span class="valeur num">21 k</span></div>
|
||||
<div class="ligne-b"><span>Avril</span><div class="fond-b"><div class="rempli" style="width:60%;background:var(--graf-2)"></div></div><span class="valeur num">17 k</span></div>
|
||||
<div class="ligne-b"><span>Mai</span><div class="fond-b"><div class="rempli" style="width:100%;background:var(--graf-2)"></div></div><span class="valeur num">29 k</span></div>
|
||||
<div class="ligne-b"><span>Juin</span><div class="fond-b"><div class="rempli" style="width:73%;background:var(--graf-2)"></div></div><span class="valeur num">21 k</span></div>
|
||||
<div class="ligne-b"><span>Juillet</span><div class="fond-b"><div class="rempli" style="width:64%;background:var(--graf-2)"></div></div><span class="valeur num">18,5 k</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="carte">
|
||||
<h2>Top équipements en coût · 12 mois</h2>
|
||||
<div class="table"><table>
|
||||
<thead><tr><th>Appareil</th><th>Site</th><th>Dépannages</th><th>Pièces</th><th>Main-d'œuvre</th><th>Total</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="ref num">B2</td><td>Tour Atlas</td><td class="num">9</td><td class="num">6 420</td><td class="num">3 240</td><td class="num"><b>9 660 MAD</b></td></tr>
|
||||
<tr><td class="ref num">A1</td><td>Résidence Al Manar</td><td class="num">6</td><td class="num">2 210</td><td class="num">1 560</td><td class="num"><b>3 770 MAD</b></td></tr>
|
||||
<tr><td class="ref num">C1</td><td>Anfa Place</td><td class="num">4</td><td class="num">1 890</td><td class="num">980</td><td class="num"><b>2 870 MAD</b></td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 6 · TIERS ═══════════ -->
|
||||
<p class="note-ecran" data-note="tiers" hidden><b>Intention :</b> l'annuaire minimal — fournisseurs de pièces et syndics/clients des sites. Un tiers se rattache aux BC (fournisseur) ou aux sites (syndic). Fiche simple, pas de CRM.</p>
|
||||
<section class="ecran" id="tiers">
|
||||
<div class="app"><aside class="nav">
|
||||
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||
<div class="groupe">Ressources</div>
|
||||
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3.3 8.3L12 13l8.7-4.7M12 13v9"/></svg>Stock & achats</a>
|
||||
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 21a7 7 0 1 1 6-11l6 6v3h-3l-1-1-2 1-1-2-2 1z" transform="rotate(180 12 12)"/><circle cx="9" cy="9" r="1.5"/></svg>Tiers</a>
|
||||
</aside>
|
||||
<div class="contenu">
|
||||
<div class="topbar">
|
||||
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher un tiers…</div>
|
||||
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-4)">NB</span>
|
||||
<div class="qui"><b>Nadia Berrada</b><br><small>Gestionnaire ▾</small></div></div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<div class="entete-page">
|
||||
<h1>Tiers</h1>
|
||||
<div class="actions"><button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouveau tiers</button></div>
|
||||
</div>
|
||||
<div class="carte" style="padding:0"><div class="table"><table>
|
||||
<thead><tr><th>Nom</th><th>Type</th><th>Contact</th><th>Rattachements</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><b>Ascentech Maroc</b><span class="sous">Casablanca — pièces détachées</span></td><td>Fournisseur</td><td>M. Berrini · <span class="num">05 22 34 56 78</span></td><td class="num">2 BC en cours</td></tr>
|
||||
<tr><td><b>SchindlerParts</b><span class="sous">Importateur agréé</span></td><td>Fournisseur</td><td>commandes@schindlerparts.ma</td><td class="num">1 BC en cours</td></tr>
|
||||
<tr><td><b>Lubmaroc</b><span class="sous">Consommables</span></td><td>Fournisseur</td><td><span class="num">05 22 11 22 33</span></td><td class="num">—</td></tr>
|
||||
<tr><td><b>Atlas Property Management</b><span class="sous">Syndic</span></td><td>Client / syndic</td><td>Mme Zerhouni · <span class="num">06 61 98 76 54</span></td><td>Tour Atlas</td></tr>
|
||||
<tr><td><b>Syndic Al Manar</b></td><td>Client / syndic</td><td>M. Alami (gardien référent)</td><td>Résidence Al Manar</td></tr>
|
||||
</tbody>
|
||||
</table></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════ 7 · BIBLIOTHÈQUE ═══════════ -->
|
||||
<p class="note-ecran" data-note="bibliotheque" hidden><b>Intention :</b> notices, certificats et photos — <b>rattachés</b> à un appareil ou un OT, stockés MinIO derrière FileStorage. Types fermés, taille plafonnée, aperçu léger. La carte « Documents » des fiches devient réelle.</p>
|
||||
<section class="ecran" id="bibliotheque">
|
||||
<div class="app"><aside class="nav">
|
||||
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||
<div class="groupe">Ressources</div>
|
||||
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3.3 8.3L12 13l8.7-4.7M12 13v9"/></svg>Stock & achats</a>
|
||||
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 21a7 7 0 1 1 6-11l6 6v3h-3l-1-1-2 1-1-2-2 1z" transform="rotate(180 12 12)"/><circle cx="9" cy="9" r="1.5"/></svg>Tiers</a>
|
||||
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Fichiers</a>
|
||||
</aside>
|
||||
<div class="contenu">
|
||||
<div class="topbar">
|
||||
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher un document…</div>
|
||||
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-4)">NB</span>
|
||||
<div class="qui"><b>Nadia Berrada</b><br><small>Gestionnaire ▾</small></div></div>
|
||||
</div>
|
||||
<div class="page">
|
||||
<div class="entete-page">
|
||||
<h1>Bibliothèque</h1><span class="filajout">36 documents · 214 Mo</span>
|
||||
<div class="actions"><button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 17V3M6 9l6-6 6 6M5 21h14"/></svg>Téléverser</button></div>
|
||||
</div>
|
||||
<div class="filtres">
|
||||
<span class="select">Type : <b>Tous</b> ▾</span>
|
||||
<span class="select">Rattaché à : <b>Tout le parc</b> ▾</span>
|
||||
</div>
|
||||
<div class="zone-photo" style="padding:22px">📁 Glissez un fichier ici — PDF, JPG, PNG · 20 Mo max · rattachez-le à un appareil ou un OT</div>
|
||||
<div class="docs">
|
||||
<div class="doc"><div class="vignette">📄</div><span class="type-doc">Notice</span><b>Notice Otis Gen2 Premier.pdf</b><span class="meta">Asc. A1 · 1,2 Mo · mars 2020</span></div>
|
||||
<div class="doc"><div class="vignette">📄</div><span class="type-doc" style="color:var(--safran);background:var(--safran-doux)">Certificat</span><b>Certificat parachute 2026.pdf</b><span class="meta">Asc. A1 · 340 Ko · juil. 2026</span></div>
|
||||
<div class="doc"><div class="vignette">🖼</div><span class="type-doc" style="color:var(--graf-3);background:var(--st-termine-fond)">Photo</span><b>photo-guide-frottement.jpg</b><span class="meta">OT-2026-0341 · 2,1 Mo · hier</span></div>
|
||||
<div class="doc"><div class="vignette">📄</div><span class="type-doc">Notice</span><b>Schindler 3300 — manuel.pdf</b><span class="meta">Asc. B1 · B2 · 4,8 Mo</span></div>
|
||||
</div>
|
||||
<div class="carte" style="border-style:dashed;color:var(--encre-2);font-size:13px">
|
||||
Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) — le rattachement propre commence ici.
|
||||
</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,13 +116,133 @@ 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) |
|
||||
| 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) |
|
||||
|
||||
## R3 — Gestion
|
||||
|
||||
```prisma
|
||||
enum PartnerKind { SUPPLIER CLIENT } // fournisseur / syndic
|
||||
enum PurchaseOrderStatus { DRAFT SENT RECEIVED CANCELLED }
|
||||
enum StockMovementKind { RECEIPT ENTRY CONSUMPTION ADJUSTMENT }
|
||||
enum DocumentKind { NOTICE CERTIFICATE PHOTO OTHER }
|
||||
|
||||
model Partner { id, name @unique, kind, contact…, isActive }
|
||||
|
||||
model Part { // AUCUNE colonne de quantité : le stock EST la
|
||||
id, reference @unique // somme des mouvements (décision v1 éprouvée)
|
||||
designation, threshold Int // seuil d'alerte
|
||||
lastUnitPrice Decimal? // dernier prix d'achat (mis à jour à la réception)
|
||||
supplierId? → Partner
|
||||
}
|
||||
|
||||
model StockMovement { // + entrée / − sortie, TOUJOURS tracé
|
||||
id, partId (cascade), kind, quantity Int (signé)
|
||||
unitPrice Decimal? // FIGÉ (réception : PU du BC ; consommation : lastUnitPrice)
|
||||
reason String? // ADJUSTMENT : motif REQUIS (service)
|
||||
workOrderId? → WorkOrder // consommation
|
||||
purchaseOrderId? → PurchaseOrder // réception
|
||||
byId? → User
|
||||
}
|
||||
|
||||
model PurchaseOrder { // DRAFT → SENT → RECEIVED ; annulable avant réception
|
||||
id, reference @unique, status, supplierId → Partner
|
||||
lines PurchaseOrderLine[] // partId, quantity, unitPrice Decimal
|
||||
// la RÉCEPTION crée un mouvement RECEIPT par ligne et met à jour lastUnitPrice
|
||||
}
|
||||
|
||||
model LaborTime { // heures × taux FIGÉ à la saisie
|
||||
id, workOrderId (cascade), userId → User, minutes Int, hourlyRate Decimal, note?
|
||||
}
|
||||
// User reçoit hourlyRate Decimal? (taux COURANT, administrable)
|
||||
|
||||
model Document { // MinIO derrière FileStorage — jamais d'accès direct
|
||||
id, kind, fileName, storageKey @unique, size, contentType
|
||||
assetId? / workOrderId? // rattachement REQUIS à l'un des deux (service)
|
||||
}
|
||||
```
|
||||
|
||||
**Invariants R3** :
|
||||
|
||||
| Invariant | Où il vit |
|
||||
| --- | --- |
|
||||
| Stock = Σ mouvements ; jamais de saisie directe de quantité | par construction (pas de colonne) |
|
||||
| Un ajustement exige un motif ; le stock ne devient jamais négatif | service parts (+ tests) |
|
||||
| Consommation : PU figé = `lastUnitPrice` au moment T ; stock suffisant exigé | service (transaction) |
|
||||
| Réception : BC `SENT` uniquement ; crée les RECEIPT + met à jour `lastUnitPrice` | service (transaction) |
|
||||
| Main-d'œuvre : taux figé = `User.hourlyRate` au moment T (refus si non défini) | service |
|
||||
| Coût total d'un OT = Σ consommations + Σ main-d'œuvre — IMMUABLE après coup | dérivé des lignes figées |
|
||||
| Document : rattaché à un appareil OU un OT ; types fermés ; 20 Mo max | service documents (R3.2) |
|
||||
|
||||
## À 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`,
|
||||
`Partner`, `LaborTime` (taux figé), `Document`.
|
||||
- **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile).
|
||||
- **R5** : tables d'embeddings pgvector (côté service IA).
|
||||
|
||||
|
||||
@@ -4,6 +4,234 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-17 — Pr. Daaif (+ Claude) — R3 CLOSE : tag `release/r3`
|
||||
|
||||
**Actions**
|
||||
|
||||
- Recette R3 prononcée par le référent après la revue pixel corrigée (8 écarts + recherche globale, artefact à jour, CI verte sur `460ef4a`).
|
||||
- Tag annoté **`release/r3`** posé et poussé.
|
||||
|
||||
**Décisions**
|
||||
|
||||
- Le tag précède le redéploiement (webhook CD toujours absent) : le déploiement Dokploy se fait manuellement depuis ce tag, puis vérification en ligne — même séquence que R1/R2.
|
||||
|
||||
**Prochaine étape** : redéployer sur Dokploy (migration `r3_recette_fixes` + seed au boot), vérifier en ligne (rattachements Tiers, recherche ⌘K, période des stats), puis ouvrir **R4 Mobile** (Expo — design d'abord, maquettes avant tout code).
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-17 — Pr. Daaif (+ Claude) — R3.4 : revue pixel, corrections de recette, recherche globale
|
||||
|
||||
**Actions**
|
||||
|
||||
- **Revue pixel R3** (artefact partagé, captures maquette ↔ appli côte à côte, 1440 px, thème clair) : 7/7 écrans structurellement fidèles, 8 écarts relevés. **Arbitrage du référent : tout corriger + activer la recherche.**
|
||||
- **Stock** : filtre Fournisseur, « Entrée de stock » depuis la liste (choix de la pièce dans la modale), pièces sous le seuil en tête. **Fiche pièce** : fournisseur → lien vers Tiers. **Statistiques** : « Période » devient un vrai sélecteur 3/6/12 mois (`months` au contrat, libellés et barres suivent). **Tiers** : les syndics affichent leurs sites — nouveau lien site→client/syndic (migration `r3_recette_fixes`, `Location.partnerId`, garde « CLIENT seulement »), éditable sur la fiche site, seedé (Tour Atlas, Résidence Al Manar). **Bibliothèque** : filtre « Rattaché à » + zone de glisser-déposer (la modale s'ouvre préremplie, le rattachement reste obligatoire).
|
||||
- **Recherche globale ⌘K active** : `GET /search` (73e opération au contrat) — chaque famille (OT/ascenseurs/sites) n'est interrogée que si le rôle a la permission `view` du domaine, les OT respectent « voir autre » ; topbar avec debounce 200 ms, résultats groupés, navigation flèches + Entrée. 8 tests API dédiés (scoping vérifié rôle par rôle).
|
||||
- Écart « regroupement des tiers » retiré : le tri type-puis-nom existait déjà (constat d'écran, pas de code).
|
||||
- Vérifié : 18/18 contrôles en navigateur réel (zéro erreur console), **14/14 Playwright** (dont nouveau parcours « recette corrigée »), **74 tests API** (13 suites). Artefact de revue mis à jour (statut « corrigé », captures fraîches).
|
||||
|
||||
**Décisions**
|
||||
|
||||
- « Bons de commande » reste dans l'entête du Stock **en plus** de la maquette : c'est le seul accès à l'écran BC — à entériner en recette (ou déplacer si le référent préfère).
|
||||
- La liste des tiers restant sous `PURCHASE_ORDERS.view`, le champ syndic de la fiche site n'est éditable que si le rôle voit les tiers (sinon lecture seule).
|
||||
|
||||
**Prochaine étape** : validation finale du référent sur l'artefact → déploiement Dokploy → tag `release/r3` → R4 Mobile (design d'abord).
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R3.3+ : aperçu et ouverture des documents (retour de pré-recette)
|
||||
|
||||
**Actions**
|
||||
|
||||
- **Retour du référent** : les documents téléversés n'avaient ni aperçu ni ouverture (seul « Télécharger » existait). Corrigé dans la vignette (`carte-documents.tsx`) : les **images affichent leur vrai aperçu** (le fichier exige le jeton → récupération du blob puis URL objet révoquée au démontage, jamais de `<img src>` nu) ; les PDF gardent leur pictogramme.
|
||||
- **« Ouvrir »** : nouveau bouton (et clic sur la vignette) — onglet ouvert **dans le geste utilisateur** (sinon bloqueurs de pop-up) puis pointé sur le blob ; le navigateur affiche PDF et images dans sa visionneuse. « Télécharger » inchangé.
|
||||
- Vérifié en navigateur réel (Playwright, appli lancée) : vignette image réellement chargée (`naturalWidth > 0`), ouverture image et PDF en onglet `blob:` (PDF rendu dans la visionneuse Chrome — capture), téléchargement intact, suppression OK, zéro erreur console. Typecheck + vitest verts.
|
||||
|
||||
**Décisions**
|
||||
|
||||
- L'aperçu télécharge le fichier entier (pas de miniature côté serveur) : acceptable au volume actuel ; si la bibliothèque grossit, générer des miniatures à l'upload (noté pour le durcissement).
|
||||
|
||||
**Prochaine étape** : recette R3 avec le référent (revue pixel), déploiement Dokploy, tag `release/r3` → puis R4 Mobile (design d'abord).
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R3.3 : les écrans web de la gestion — R3 prête pour recette
|
||||
|
||||
**Actions**
|
||||
|
||||
- **7 écrans fidèles à `maquette-r3.html`** : Stock (alerte sous seuil, strie ambre, « Préparer le BC » sur la ligne), Fiche pièce (« les mouvements SONT le stock », entrée/ajustement motivé), Bons de commande (liste + panneau, envoi/annulation/**réception → entrées de stock**), création de BC **préremplie** depuis l'alerte (fournisseur de la pièce, quantité pour repasser le seuil + 5, dernier PU), Tiers, Bibliothèque (upload rattaché à un appareil, filtre par type), Statistiques (4 KPI, pannes par organe **depuis les bilans codés**, coûts par mois, top équipements).
|
||||
- **La fiche OT s'achève** : carte « Pièces & main-d'œuvre » réelle (consommer une pièce — prix figé annoncé AVANT le clic, impact stock affiché ; saisir du temps au taux figé) + carte « Documents » réelle (photo d'intervention téléversée/retéléchargée). La fiche ascenseur gagne la même carte Documents ; le tableau de bord allume « Pannes par organe » ; **Personnes affiche et édite le taux horaire**.
|
||||
- Nav « Ressources »/« Statistiques » activée par la matrice (PARTS/PURCHASE_ORDERS/ASSETS/ANALYTICS.view) ; captures des 9 écrans comparées aux maquettes (bi-thème), zéro erreur console.
|
||||
- **Recette R3 automatisée** (2 parcours Playwright, données E2E autonomes purgées au setup) : tiers → pièce → sous seuil → BC prérempli → réception → **l'alerte s'éteint** ; OT : consommation + 1 h 30 → **380 MAD** exacts ; upload/download MinIO réel ; statistiques lues par la Vue seule. **13/13 e2e verts, 10,7 s.**
|
||||
|
||||
**Décisions**
|
||||
|
||||
- Le formulaire BC prérempli attend la fin du refetch avant d'initialiser ses lignes (le cache TanStack portait un stock périmé — attrapé par la recette, pas par l'œil).
|
||||
- `cleanup-e2e` purge désormais les entités R3 (mouvements liés aux OT E2E supprimés AVANT les OT, sinon ils deviennent orphelins et faussent les stocks seedés d'un run à l'autre).
|
||||
|
||||
**Prochaine étape** : recette R3 avec le référent (revue pixel), déploiement Dokploy, tag `release/r3` → puis R4 Mobile (design d'abord).
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R3.2 : bibliothèque de documents + analytics
|
||||
|
||||
**Actions**
|
||||
|
||||
- **`FileStorage` gagne ses vraies opérations** (put/stream/remove ; bucket créé au démarrage — aucune étape manuelle au déploiement) ; MinIO reste confiné à son implémentation (règle ESLint).
|
||||
- **Bibliothèque** : upload multipart (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT **requis**, permission d'édition sur la cible), liste filtrable, **téléchargement streamé par l'API** (MinIO jamais exposé — topologie du runbook respectée), suppression. Test e2e : les octets téléchargés sont IDENTIQUES aux octets envoyés.
|
||||
- **Analytics** (`GET /analytics/summary`, permission ANALYTICS) : tout est dérivé du réel — coûts par mois depuis les mouvements/main-d'œuvre figés, **pannes par organe depuis les bilans codés**, taux de préventif (grilles terminées/générées), durée moyenne de résolution, top équipements en coût.
|
||||
- Générateur OpenAPI étendu (query params, multipart, réponse binaire) — 71 opérations. **CI : service MinIO ajouté** (jobs api + e2e, image bitnami).
|
||||
- **58 tests verts** (92 % / 73,9 %) ; test de régression du tri des « Interventions récentes » rendu **déterministe** (les positions absolues étaient instables sous 12 suites parallèles) — 6 runs complets consécutifs verts.
|
||||
|
||||
**Prochaine étape** : R3.3 — écrans web (stock, fiche pièce, BC, coûts sur fiche OT, statistiques, tiers, bibliothèque, taux dans Personnes), puis recette R3 + déploiement + tag.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R3.1 : socle backend de la gestion
|
||||
|
||||
**Actions**
|
||||
|
||||
- **Maquettes R3 et les 4 décisions VALIDÉES par le référent** → lancement du socle.
|
||||
- **Modèle** (migration `r3_gestion`, 7 tables) : Partner, Part (SANS colonne de quantité), StockMovement (signé, tracé, PU figé), PurchaseOrder/Line, LaborTime (taux figé), Document (préparé pour R3.2) ; `User.hourlyRate` (taux courant, administrable dans Personnes).
|
||||
- **API** (66 opérations au contrat) : tiers, pièces (stock = Σ mouvements calculé en `groupBy`, alerte sous seuil), entrée/ajustement (motif requis, **stock jamais négatif** — vérifié en transaction), BC (Brouillon→Envoyé→Reçu ; **la réception crée les RECEIPT et met à jour `lastUnitPrice`**), consommation sur OT (stock suffisant + **prix figé**) et main-d'œuvre (**taux figé**, refus motivé si taux non défini) ; `WorkOrderDetail.costs` (lignes + total immuables) ; coûts verrouillés après clôture/annulation (409).
|
||||
- **Seed** : 5 tiers, 5 pièces de la maquette (P-0113 sous seuil via son histoire de mouvements), BC reçu + BC envoyé, taux horaires, et **la carte maquette rejouée : OT-0341 = 505 MAD** (240 + 85 + 180) — vérifiée par test.
|
||||
- **55 tests verts** (92 % / 74,9 %) dont la recette officielle : consommer sous seuil → alerte → BC → réception → réappro, prix d'hier intact sur l'OT pendant que le prix courant change ; hausse du taux d'Ahmed sans effet sur les OT passés.
|
||||
|
||||
**Leçon majeure (durcissement)**
|
||||
|
||||
- Les références « max+1 » (OT/DEM/BC/P) étaient une **course sous charge parallèle** (500 sporadiques malgré les retries). Remplacées par des **séquences Postgres** (`nextval`) initialisées au max existant : la classe de bugs disparaît — 3 runs Jest complets consécutifs verts. La numérotation ne se remet pas à zéro chaque année (l'unicité prime), consigné.
|
||||
|
||||
**Prochaine étape** : R3.2 — bibliothèque de documents (upload multipart 20 Mo → `FileStorage.putObject`, téléchargement streamé via l'API — MinIO jamais exposé) + analytics (`GET /analytics/summary`), puis R3.3 écrans web.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R3 ouverte : maquettes de la gestion à valider
|
||||
|
||||
**Actions**
|
||||
|
||||
- **R3 « Gestion » ouverte — design d'abord** : `maquette-r3.html`, **7 écrans** dans le moule validé : Stock (dérivé des mouvements, alertes sous seuil avec « Préparer le BC »), Fiche pièce (les mouvements SONT le stock), Bons de commande (Brouillon → Envoyé → Reçu ; la réception crée les entrées), Coûts sur OT (la carte validée R0 devient réelle : consommation à **prix figé**, main-d'œuvre à **taux figé**), Statistiques (coûts, pannes par organe alimenté par les bilans codés, taux de préventif, top équipements — palette CVD), Tiers (annuaire minimal fournisseurs/syndics), Bibliothèque (documents typés rattachés appareil/OT, MinIO via FileStorage — futur corpus RAG R5).
|
||||
- Rendu vérifié (7 écrans + thème sombre, zéro erreur).
|
||||
|
||||
**Décisions (proposées à la validation)**
|
||||
|
||||
- **Stock strictement dérivé des mouvements** (décision v1 éprouvée) : aucune saisie directe de quantité ; les ajustements d'inventaire sont des mouvements motivés.
|
||||
- **Prix figé à la consommation** (dernier prix d'achat) et **taux horaire figé** par personne (champ géré dans Personnes) : le coût d'un OT ne bouge plus jamais.
|
||||
- La réception d'un BC crée les mouvements d'entrée et fige le PU.
|
||||
- Documents : types fermés (Notice / Certificat / Photo / Autre), 20 Mo max, rattachement obligatoire (appareil ou OT).
|
||||
|
||||
**⛔ Bloquant** : validation des maquettes R3 par le référent avant toute ligne de code R3.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — 🏁 R2 CLOSE : recettée, déployée, taguée
|
||||
|
||||
**Actions**
|
||||
|
||||
- **Recette R2 prononcée par le référent** (une anomalie détectée et corrigée en cours de recette — voir entrée précédente : tri des « Interventions récentes »).
|
||||
- **Déploiement vérifié en ligne** : portail public `/q/A1` opérationnel (résolution QR 200), 13 OT avec l'urgence en tête, préventif de juillet généré sur l'instance (8 grilles, 7 premiers contrôles).
|
||||
- DoD complète → **tag `release/r2`**. Le carnet papier est remplacé : signalement QR sans compte → OT → bilan codé → clôture gardée → suivi, plus préventif idempotent et compteurs.
|
||||
|
||||
**Prochaine étape** : R3 Gestion — design d'abord : maquettes stock/BC/tiers/coûts OT/analytics/bibliothèque à produire et faire valider.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — Recette R2 : anomalie du référent corrigée
|
||||
|
||||
**Anomalie (recette du référent)** : un OT créé, assigné à Ahmed, traité et clôturé apparaissait dans le tableau de bord d'Ahmed mais PAS dans celui de Salma ni de l'administrateur.
|
||||
|
||||
**Diagnostic** : la liste API triait **par statut d'abord** (les Terminés relégués en queue) et le tableau de bord affiche les 5 premières lignes. Ahmed, sans « voir autre », a une liste courte → son OT clôturé restait dans le top 5 ; Salma et l'admin voient tout le parc → l'OT clôturé était éjecté des « Interventions récentes ». (La liste OT filtrait aussi « actifs » par défaut, conforme à la maquette — c'est le tableau de bord qui trompait.)
|
||||
|
||||
**Correctif** : la liste trie par **dernière activité** (`updatedAt` desc) — un OT qui vient d'être clôturé remonte en tête pour tout le monde ; les urgences « personne bloquée » **actives** restent épinglées au sommet (un OT d'urgence annulé ne squattait plus la tête, corrigé au passage). Reproduit avant / vérifié après sur le scénario exact du référent ; **test de régression** ajouté à la recette e2e (position de l'OT clôturé dans la liste de Salma ≤ nombre d'urgences actives). 50 tests API + 11 Playwright verts.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.4 : portail public QR — l'étiquette prend vie
|
||||
|
||||
**Actions**
|
||||
|
||||
- **Trois routes publiques `/portal`** (les seules routes @Public métier de l'API), **throttlées** (`@nestjs/throttler` : 10 signalements/min, 60 lectures/min — première surface sans compte) : résolution du QR (`référence → appareil`), signalement (retourne un **jeton de suivi opaque**), suivi par `référence + jeton` (pas de jeton, pas de lecture — aucune énumération possible).
|
||||
- **Suivi sans compte** : `Request.publicToken` (migration `r2_portail`) ; le téléphone du gardien garde `[référence, jeton]` en localStorage (10 max) ; étapes sans jargon **Reçu → Intervention → Résolu** dérivées de l'OT lié ; un rejet s'affiche « Sans suite : {motif} ».
|
||||
- **Page `/q/{réf}`** fidèle à l'écran 6 validé R0 (mobile d'abord) : équipement prérempli « détecté par le QR », gros interrupteur personne bloquée, description, photo annoncée (upload → R3), messages d'erreur en français métier (QR inconnu, throttling).
|
||||
- **e2e Playwright** : LA boucle produit — le gardien signale sans compte sur `/q/A1`, l'équipe traite (approbation → démarrage → bilan → clôture via API), le gardien recharge et voit ✓ Résolu. 11 tests Playwright verts, 50 tests API, 52 opérations au contrat. Générateur OpenAPI : les paramètres non-`id` ne sont plus typés uuid.
|
||||
|
||||
**Décisions**
|
||||
|
||||
- Le portail n'expose JAMAIS de liste : lecture uniquement par jeton individuel.
|
||||
- Throttling au niveau du contrôleur portail seulement (le reste de l'API est derrière JWT).
|
||||
|
||||
**R2 est fonctionnellement complète** (OT, bilan codé, demandes, portail QR, préventif, compteurs). Prochaine étape : recette R2 avec le référent (revue pixel ↔ maquettes R0+R2), déploiement, tag `release/r2`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.3 : écrans web de l'exploitation
|
||||
|
||||
**Actions**
|
||||
|
||||
- **7 écrans/évolutions fidèles aux maquettes validées** : Liste OT (filtres, strie rouge, « immédiat »), **Fiche OT** (boutons de transition issus d'`allowedTransitions`, clôture grisée avec l'explication de la garde, bilan codé 6 selects alimentés par les référentiels, checklist Fait→N-A→à faire au clic, activité chronologique + commentaires, assignation, annulation motivée), Nouvel OT (interrupteur « personne bloquée » qui force la priorité), Demandes (table + panneau d'approbation, rejet en modale à motif), Préventif (4 tuiles réelles, bouton générer avec résumé « regénérer ne double rien », gabarits administrables), Compteurs (saisie + historique), **Tableau de bord réel** (bandeau urgence cliquable, KPIs, OT par statut, interventions récentes ; coûts et pannes par organe annoncés R3).
|
||||
- **L'urgence traverse l'app** : chip pulsante dans la topbar (rafraîchie 60 s), badges de nav (urgences OT, demandes à traiter), bandeau dashboard, tête de liste. Fiche appareil : historique réel + accès compteurs. Accueil dédié aux rôles sans exploitation (Demandeur).
|
||||
- **Trou de conception débusqué par l'e2e** : le Demandeur n'a pas `ASSETS.view` → son sélecteur d'équipement était vide. Correctif : `GET /assets/options` (authentification seule — les références sont affichées en cabine). 49 opérations au contrat.
|
||||
- **Deux fragilités réelles corrigées dans la génération du préventif** : collision de référence (course avec une création d'OT) désormais RETENTÉE au lieu de sauter silencieusement un appareil ; appareil supprimé entre lecture et écriture toléré (P2003). 3 runs Jest complets consécutifs verts.
|
||||
- **9 tests Playwright verts** dont la recette R2 officielle : Karim signale → Salma approuve+assigne → OT → démarrage → clôture bloquée visible → bilan → clôture → la demande de Karim passe « Résolue » ; + préventif idempotent via l'UI, urgence bout-en-bout, compteur croissant. 50 tests API (94,6 % / 78,9 %).
|
||||
|
||||
**Prochaine étape** : R2.4 — portail public QR (`/q/{ref}` → signalement prérempli sans compte, suivi sans jargon — écran validé R0), puis recette R2 + déploiement + tag.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.2 : préventif (génération idempotente) + compteurs
|
||||
|
||||
**Actions**
|
||||
|
||||
- **Modèle** (migration `r2_preventif_compteurs`) : `Asset.underContract` (contrat préventif, maquette fiche appareil) et `WorkOrder.periodKey` avec **unicité `[assetId, periodKey]` : l'idempotence de la génération est EN BASE**, pas seulement dans le code — deux générations concurrentes ne peuvent pas doubler une grille.
|
||||
- **Génération mensuelle** (`POST /preventive/generate`, 200 idempotent) : une grille par appareil sous contrat ; tâches dues par périodicité **ancrée sur la mise en service** (mensuelles toujours dues) ; appareil sans historique préventif → OT « **Premier contrôle** » avec toutes les tâches ; échéance = fin de mois ; événement GENERATED tracé. Déclenchement manuel en R2 (bouton maquette) — l'automatisation cron/BullMQ viendra avec le durcissement production.
|
||||
- Gabarits administrables (ajout/renommage/désactivation — un gabarit désactivé sort des générations suivantes) ; `GET /preventive/status` (générées, terminées, en retard, premiers contrôles du mois).
|
||||
- **Compteurs** : `GET /assets/{id}/meters` (les 2 compteurs, relevés récents d'abord) ; `POST /assets/{id}/meter-readings` — **relevé strictement croissant**, refus motivé sinon.
|
||||
- Contrat : +7 opérations (48). **50 tests verts** (couverture 94,7 % / 78 %) : idempotence (8 grilles seed, jamais 16), premier contrôle vs grille normale, mois anniversaire (mars 2031 pour A1 : les 3/6/12 mois tombent ensemble), gabarit désactivé exclu, compteur qui refuse de redescendre. Smoke test prod : juillet 2026 généré (9 grilles, 8 premiers contrôles), 2ᵉ appel à zéro.
|
||||
|
||||
**Leçon**
|
||||
|
||||
- Les specs Jest partagent la base en parallèle : les assertions e2e doivent porter sur **leurs propres données** (ici les 8 appareils du seed), jamais sur des comptages globaux.
|
||||
|
||||
**Prochaine étape** : R2.3 — écrans web de l'exploitation (liste/fiche OT, demandes+approbation, nouvel OT, préventif, checklist, compteurs, chip urgence + bandeau tableau de bord).
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.1 : socle backend de l'exploitation
|
||||
|
||||
**Actions**
|
||||
|
||||
- **Modèle R2** (doc + migration `r2_exploitation`, 10 tables) : WorkOrder (référence séquentielle, horodatages par état), WorkOrderEvent (activité), Request (1-1 vers OT, motif de rejet), ReferenceValue (référentiels du bilan par champ), InterventionReport (6 FK nommées), TaskTemplate/ChecklistItem, Meter/MeterReading.
|
||||
- **Contrat** : 15 opérations (41 total) — la **table des transitions** et les champs requis du bilan vivent dans `@siop/shared` (une seule loi pour l'API et l'UI) ; la fiche OT expose `allowedTransitions` et `closureBlockers` (messages métier).
|
||||
- **API** : machine à états stricte (transition → DONE refusée si bilan incomplet OU checklist avec tâche sans réponse — messages « quoi faire », charte §7) ; approbation de demande = création d'OT lié en 1-1 (double traitement → 409) ; rejet à motif obligatoire ; **scoping « voir autre »** appliqué aux listes ET aux accès directs (404, pas de fuite) ; valeurs de bilan validées champ par champ.
|
||||
- **Seed** : 31 valeurs de référentiels (6 champs), 8 gabarits de préventif (parachute réglementaire), 4 OT et 4 demandes rejouant la maquette, compteurs d'A1.
|
||||
- **45 tests verts** (couverture 95 % / 79 % branches) dont la **recette officielle rejouée** : demande Karim → approbation Salma → OT assigné Ahmed → démarrage → clôture refusée sans bilan → bilan codé → clôture → Karim voit sa demande résolue. Smoke test build prod.
|
||||
|
||||
**Décisions**
|
||||
|
||||
- `POST` de transition/rejet répondent **200** (le contrat prime sur le défaut 201 de Nest — c'est le contrat qui a raison).
|
||||
- La priorité « personne bloquée » trie en tête côté API : aucun client ne peut l'oublier.
|
||||
|
||||
**Prochaine étape** : R2.2 — génération mensuelle du préventif (BullMQ, idempotente, premier contrôle) + API compteurs, puis R2.3 écrans web.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — R2 ouverte : maquettes des écrans manquants à valider
|
||||
|
||||
**Actions**
|
||||
|
||||
- **R2 « Exploitation » ouverte — design d'abord.** Les écrans cœur sont déjà validés depuis R0 (tableau de bord, liste OT, fiche OT avec bilan codé 6 champs et garde de clôture, portail demandeur) ; `maquette-r2.html` complète avec les **5 écrans manquants** : Demandes (file + panneau d'approbation : approuver → OT 1-1, rejeter avec motif requis), Nouvel OT (interrupteur « personne bloquée » explicite), Préventif (gabarits à périodicité — parachute marqué réglementaire — génération du mois **idempotente**, premiers contrôles), OT préventif (checklist Fait/N-A, clôture bloquée si tâche sans réponse), Compteurs (relevés croissants, historique).
|
||||
- Rendu vérifié (5 écrans + thème sombre, zéro erreur).
|
||||
|
||||
**Décisions (proposées à la validation)**
|
||||
|
||||
- Statuts de DEMANDE distincts (Reçue / Approuvée→OT lié / Rejetée / Résolue) réutilisant la sémantique chromatique des statuts OT ; à trancher : est-ce une entorse à « réservés » de la charte ou une extension cohérente ?
|
||||
- Un rejet de demande **exige un motif** (lisible côté portail, sans jargon).
|
||||
- Génération mensuelle : automatique le 1ᵉʳ à 06 h + bouton manuel ; **regénérer ne crée que le manquant** ; appareil sans historique → OT « premier contrôle » (toutes les tâches).
|
||||
- Relevé de compteur strictement croissant (refus sinon).
|
||||
|
||||
**⛔ Bloquant** : validation des maquettes R2 par le référent avant toute ligne de code R2.
|
||||
**→ Levé le 16/07/2026 : maquettes R2 et les 4 décisions VALIDÉES par le référent** (statuts de demande = extension cohérente, actée). Lancement R2.1 (socle backend exploitation).
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-16 — Pr. Daaif (+ Claude) — 🏁 R1 CLOSE : recettée, déployée, taguée
|
||||
|
||||
**Actions**
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user