9 Commits

Author SHA1 Message Date
pr-daaif
3d787a2d39 docs(r3): clôture — journal et état d'avancement pour le tag release/r3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:27:58 +01:00
pr-daaif
460ef4a80e feat(r3.4): corrections de recette (8 écarts) + recherche globale ⌘K
Arbitrage du référent sur la revue pixel : tout corriger, activer la
recherche.

- Stock : filtre fournisseur, sous-seuil en tête, « Entrée de stock »
  depuis la liste ; fiche pièce : fournisseur → lien Tiers.
- Statistiques : période 3/6/12 mois (paramètre months au contrat).
- Tiers : rattachements syndic→site (migration r3_recette_fixes,
  Location.partnerId gardé CLIENT), éditable sur la fiche site, seedé.
- Bibliothèque : filtre « Rattaché à » + glisser-déposer (modale
  préremplie, rattachement toujours requis).
- Recherche globale : GET /search (73 opérations) — familles OT/
  ascenseurs/sites filtrées par la matrice, « voir autre » respecté ;
  topbar ⌘K, debounce, résultats groupés, navigation clavier.

74 tests API (8 nouveaux sur le scoping de la recherche), 14/14
Playwright dont un parcours « recette corrigée », 18/18 contrôles en
navigateur réel, zéro erreur console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 02:14:43 +01:00
pr-daaif
c46a97ed08 feat(r3.3): aperçu réel des documents + ouverture dans le navigateur
Retour de pré-recette : les fichiers téléversés n'avaient ni aperçu ni
ouverture. Les images affichent leur vraie vignette (blob authentifié →
URL objet révoquée au démontage — un <img src> nu ne porte pas le jeton) ;
« Ouvrir » (bouton + clic vignette) affiche PDF et images dans un nouvel
onglet, ouvert dans le geste utilisateur pour passer les bloqueurs de
pop-up. « Télécharger » inchangé.

Vérifié en navigateur réel : vignette chargée, onglets blob (PDF rendu
dans la visionneuse), download intact, zéro erreur console.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:44:22 +01:00
pr-daaif
c5382a5538 feat(r3.3): écrans web de la gestion — stock, achats, coûts OT, documents, statistiques
- Stock : alertes sous seuil (strie ambre), « Préparer le BC » sur la ligne ;
  fiche pièce où les mouvements SONT le stock (entrée / ajustement motivé)
- Bons de commande : liste + panneau (envoi, annulation, réception → entrées
  de stock à PU figé) ; création préremplie depuis l'alerte de seuil
- Fiche OT : carte « Pièces & main-d'œuvre » réelle (consommation à prix figé
  annoncé avant le clic, temps au taux figé) + carte Documents (upload/download) ;
  même carte Documents sur la fiche ascenseur
- Statistiques : 4 KPI, pannes par organe (bilans codés), coûts par mois,
  top équipements ; le tableau de bord allume « Pannes par organe »
- Tiers, bibliothèque (upload rattaché, filtre par type), taux horaire
  éditable dans Personnes ; nav Ressources/Pilotage activée par la matrice
- Recette R3 Playwright (2 parcours autonomes) ; cleanup-e2e étendu aux
  entités R3 ; le formulaire BC attend la fin du refetch (stock périmé en cache)

13/13 e2e verts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:16:56 +01:00
pr-daaif
a4e98c2000 fix(ci): MinIO via docker run — bitnami/minio n'existe plus, l'image officielle exige une commande
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:25:47 +01:00
pr-daaif
2ffdd13cf4 feat(r3.2): bibliothèque de documents (FileStorage réel) + analytics
- FileStorage : putObject/getObjectStream/removeObject, bucket créé au
  démarrage — MinIO confiné à son implémentation (règle ESLint intacte)
- documents : 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é),
  suppression ; e2e : octets téléchargés identiques aux octets envoyés
- analytics : GET /analytics/summary dérivé du réel — coûts/mois
  (mouvements + main-d'œuvre figés), pannes par organe (bilans codés),
  taux de préventif, durée moyenne de résolution, top équipements
- générateur OpenAPI : query params, multipart, réponse binaire (71 ops)
- CI : service MinIO (bitnami) sur les jobs api et e2e
- test de régression du tri « Interventions récentes » rendu déterministe
  (positions absolues instables sous 12 suites parallèles) ; 58 tests,
  6 runs complets consécutifs verts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:24:04 +01:00
pr-daaif
32eb20b5a0 fix(web): connexion — signaler clairement une API injoignable
Le formulaire email/mot de passe s'affichait seul, sans explication, quand
l'API locale n'était pas démarrée (la liste démo était masquée comme si
DEMO_MODE était absent). Une erreur réseau affiche désormais « L'API ne
répond pas — démarrez-la avec pnpm dev » ; le 404 (pas de mode démo)
reste silencieux, c'est le comportement ADR-002.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 21:11:46 +01:00
pr-daaif
dfdf8f7c18 feat(r3.1): socle backend gestion — stock dérivé, BC, coûts figés sur OT
- migration r3_gestion (7 tables) : Partner, Part (SANS colonne de
  quantité), StockMovement (signé, tracé, PU figé), PurchaseOrder/Line,
  LaborTime (taux figé), Document (R3.2) ; User.hourlyRate administrable
- API (66 opérations) : tiers ; pièces (stock = Σ mouvements, alerte sous
  seuil) ; entrée/ajustement (motif requis, stock jamais négatif, 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É) ; main-d'œuvre (TAUX FIGÉ, refus si taux non défini) ;
  WorkOrderDetail.costs ; coûts verrouillés après clôture (409)
- seed : tiers/pièces/BC/taux de la maquette — OT-0341 = 505 MAD (testé)
- durcissement : références OT/DEM/BC/P par SÉQUENCES Postgres (nextval)
  — fin des courses « max+1 » (500 sporadiques sous charge parallèle) ;
  3 runs Jest complets consécutifs verts
- 55 tests (92 % stmts / 74,9 % branches) dont la recette officielle :
  consommer sous seuil → BC → réception → réappro, prix/taux figés prouvés

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 20:42:03 +01:00
pr-daaif
d5041c6f05 docs(r3): kickoff design-first — maquettes de la gestion (7 écrans) à valider
- maquette-r3.html (moule validé) : stock DÉRIVÉ des mouvements (alertes
  sous seuil → préparer le BC), fiche pièce (les mouvements sont le stock),
  bons de commande (réception → entrées, PU figé), coûts sur OT (pièces à
  prix figé, main-d'œuvre à taux figé), statistiques (pannes par organe
  depuis les bilans codés, coûts, top équipements — palette CVD), tiers,
  bibliothèque (documents typés rattachés, MinIO via FileStorage)
- 4 décisions soumises (stock jamais saisi, prix/taux figés, réception
  créatrice de mouvements, types de documents fermés)
- rendu vérifié (7 écrans, bi-thème, zéro erreur)
- BLOQUANT : validation du référent avant tout code applicatif R3

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:16:00 +01:00
82 changed files with 11296 additions and 780 deletions

View File

@@ -81,8 +81,16 @@ jobs:
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
REDIS_URL: redis://localhost:6379 REDIS_URL: redis://localhost:6379
JWT_SECRET: ci-only-secret-0123456789abcdef JWT_SECRET: ci-only-secret-0123456789abcdef
MINIO_ENDPOINT: localhost
MINIO_ACCESS_KEY: siop
MINIO_SECRET_KEY: siop-minio
steps: steps:
- uses: actions/checkout@v5 - 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: pnpm/action-setup@v4
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
with: with:
@@ -134,8 +142,16 @@ jobs:
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
REDIS_URL: redis://localhost:6379 REDIS_URL: redis://localhost:6379
JWT_SECRET: ci-only-secret-0123456789abcdef JWT_SECRET: ci-only-secret-0123456789abcdef
MINIO_ENDPOINT: localhost
MINIO_ACCESS_KEY: siop
MINIO_SECRET_KEY: siop-minio
steps: steps:
- uses: actions/checkout@v5 - 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: pnpm/action-setup@v4
- uses: actions/setup-node@v5 - uses: actions/setup-node@v5
with: with:

View File

@@ -48,6 +48,11 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demandeOT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %). - **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demandeOT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
- **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production. - **R2.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.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API.
- **R2.4 — portail public QR** : `/q/{réf}` sans compte (écran validé R0, mobile d'abord), 3 routes publiques `/portal` throttlées, suivi par jeton opaque (`Request.publicToken`, jamais de liste exposée), étapes Reçu Intervention Résolu dérivées de l'OT ; boucle produit complète en e2e (11 Playwright verts, 52 opérations). - 🏁 **R2 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é).
- 🔄 **R2reste pour clore** : recette R2 avec le référent (revue pixel maquettes R0+R2, sur `pnpm dev` ou l'instance en ligne après déploiement), tag `release/r2` ouverture R3 Gestion (stock, BC, tiers, main-d'œuvre, analytics, bibliothèque maquettes à produire d'abord). - **R3maquettes 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 syndicsite 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 R0R5. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.

View File

@@ -40,6 +40,7 @@
"@nestjs/testing": "^11.1.0", "@nestjs/testing": "^11.1.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/multer": "^2.2.0",
"@types/node": "^24.0.0", "@types/node": "^24.0.0",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"jest": "^29.7.0", "jest": "^29.7.0",

View File

@@ -6,6 +6,39 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function main() { 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.request.deleteMany({ where: { description: { contains: 'E2E' } } });
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } }); await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } }); await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });

View File

@@ -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;

View File

@@ -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));

View File

@@ -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;

View File

@@ -56,6 +56,12 @@ model User {
requests Request[] requests Request[]
checklistDone ChecklistItem[] checklistDone ChecklistItem[]
meterReadings MeterReading[] 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -101,11 +107,15 @@ model Location {
longitude Float? longitude Float?
// + colonne PostGIS générée (voir migration r1_referentiel) : // + colonne PostGIS générée (voir migration r1_referentiel) :
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED // 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[] assets Asset[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([parentId]) @@index([parentId])
@@index([partnerId])
} }
model Asset { model Asset {
@@ -127,6 +137,7 @@ model Asset {
workOrders WorkOrder[] workOrders WorkOrder[]
requests Request[] requests Request[]
meters Meter[] meters Meter[]
documents Document[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -225,6 +236,9 @@ model WorkOrder {
checklist ChecklistItem[] checklist ChecklistItem[]
report InterventionReport? report InterventionReport?
request Request? request Request?
stockMovements StockMovement[]
laborTimes LaborTime[]
documents Document[]
// Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST // Grille du mois : « AAAA-MM » — l'unicité [assetId, periodKey] EST
// l'idempotence de la génération (les OT correctifs restent à null). // l'idempotence de la génération (les OT correctifs restent à null).
periodKey String? periodKey String?
@@ -358,3 +372,148 @@ model MeterReading {
@@index([meterId]) @@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])
}

View File

@@ -134,6 +134,7 @@ export async function seed(prisma: PrismaClient): Promise<void> {
await seedUsers(prisma, roleIds, passwordHash); await seedUsers(prisma, roleIds, passwordHash);
await seedReferentiel(prisma); await seedReferentiel(prisma);
await seedExploitation(prisma); await seedExploitation(prisma);
await seedGestion(prisma);
} }
async function seedUsers( async function seedUsers(
@@ -580,6 +581,188 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
} }
} }
// ————— 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 linventaire 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 */ /* c8 ignore start — wrapper CLI */
if (require.main === module) { if (require.main === module) {
const prisma = new PrismaClient(); const prisma = new PrismaClient();

View 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);
}
}

View 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 {}

View 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,
};
}
}

View File

@@ -1,6 +1,9 @@
import { DynamicModule, Module } from '@nestjs/common'; import { DynamicModule, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AnalyticsModule } from './analytics/analytics.module';
import { SearchModule } from './search/search.module';
import { AssetsModule } from './assets/assets.module'; import { AssetsModule } from './assets/assets.module';
import { DocumentsModule } from './documents/documents.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { DemoAuthModule } from './auth/demo/demo-auth.module'; import { DemoAuthModule } from './auth/demo/demo-auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { JwtAuthGuard } from './auth/jwt-auth.guard';
@@ -10,7 +13,10 @@ import { FilesModule } from './files/files.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { LocationsModule } from './locations/locations.module'; import { LocationsModule } from './locations/locations.module';
import { MetersModule } from './meters/meters.module'; import { MetersModule } from './meters/meters.module';
import { PartnersModule } from './partners/partners.module';
import { PartsModule } from './parts/parts.module';
import { PortalModule } from './portal/portal.module'; import { PortalModule } from './portal/portal.module';
import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module';
import { PreventiveModule } from './preventive/preventive.module'; import { PreventiveModule } from './preventive/preventive.module';
import { ReferenceValuesModule } from './reference-values/reference-values.module'; import { ReferenceValuesModule } from './reference-values/reference-values.module';
import { RequestsModule } from './requests/requests.module'; import { RequestsModule } from './requests/requests.module';
@@ -50,6 +56,13 @@ export class AppModule {
PreventiveModule, PreventiveModule,
MetersModule, MetersModule,
PortalModule, PortalModule,
// R3 — gestion
PartnersModule,
PartsModule,
PurchaseOrdersModule,
DocumentsModule,
AnalyticsModule,
SearchModule,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404 // ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []), ...(demoModeEnabled() ? [DemoAuthModule] : []),
], ],

View File

@@ -13,6 +13,7 @@ const EnvSchema = z.object({
MINIO_USE_SSL: z.string().optional(), MINIO_USE_SSL: z.string().optional(),
MINIO_ACCESS_KEY: z.string().default('siop'), MINIO_ACCESS_KEY: z.string().default('siop'),
MINIO_SECRET_KEY: z.string().default('siop-minio'), MINIO_SECRET_KEY: z.string().default('siop-minio'),
MINIO_BUCKET: z.string().default('siop2'),
}); });
export type Env = z.infer<typeof EnvSchema>; export type Env = z.infer<typeof EnvSchema>;

View 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);
}
}

View 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 {}

View 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(),
};
}
}

View File

@@ -1,12 +1,15 @@
/** /**
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé * 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 * ailleurs que dans son implémentation (règle ESLint) — le reste de l'API
* interface (règle lint à venir en R0.12). R0 : santé seulement ; * dépend de cette interface. R3 : la bibliothèque de documents l'utilise ;
* les opérations arrivent avec les photos/documents (R2-R3). * les téléchargements sont STREAMÉS par l'API (MinIO n'est pas exposé).
*/ */
export const FILE_STORAGE = Symbol('FILE_STORAGE'); export const FILE_STORAGE = Symbol('FILE_STORAGE');
export interface FileStorage { export interface FileStorage {
/** Lève une exception si le stockage est injoignable. */ /** Lève une exception si le stockage est injoignable. */
healthCheck(): Promise<void>; healthCheck(): Promise<void>;
putObject(key: string, body: Buffer, contentType: string): Promise<void>;
getObjectStream(key: string): Promise<NodeJS.ReadableStream>;
removeObject(key: string): Promise<void>;
} }

View File

@@ -1,15 +1,18 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import * as Minio from 'minio'; import * as Minio from 'minio';
import { loadEnv } from '../config/env'; import { loadEnv } from '../config/env';
import type { FileStorage } from './file-storage'; 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() @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 client: Minio.Client;
private readonly bucket: string;
constructor() { constructor() {
const env = loadEnv(); const env = loadEnv();
this.bucket = env.MINIO_BUCKET;
this.client = new Minio.Client({ this.client = new Minio.Client({
endPoint: env.MINIO_ENDPOINT, endPoint: env.MINIO_ENDPOINT,
port: env.MINIO_PORT, 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> { async healthCheck(): Promise<void> {
await this.client.listBuckets(); 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);
}
} }

View File

@@ -21,9 +21,16 @@ type LocationRow = {
guardianPhone: string | null; guardianPhone: string | null;
latitude: number | null; latitude: number | null;
longitude: number | null; longitude: number | null;
partnerId: string | null;
partner: { name: string } | null;
_count: { assets: number }; _count: { assets: number };
}; };
const locationInclude = {
partner: { select: { name: true } },
_count: { select: { assets: true } },
} as const;
@Injectable() @Injectable()
export class LocationsService { export class LocationsService {
constructor(private readonly prisma: PrismaService) {} 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. */ /** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
async list(): Promise<LocationsResponse> { async list(): Promise<LocationsResponse> {
const rows: LocationRow[] = await this.prisma.location.findMany({ const rows: LocationRow[] = await this.prisma.location.findMany({
include: { _count: { select: { assets: true } } }, include: locationInclude,
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
const childAssets = new Map<string, number>(); const childAssets = new Map<string, number>();
@@ -52,9 +59,10 @@ export class LocationsService {
async create(dto: LocationCreate): Promise<LocationDto> { async create(dto: LocationCreate): Promise<LocationDto> {
await this.assertDepth(dto.parentId); await this.assertDepth(dto.parentId);
await this.assertClientPartner(dto.partnerId);
const created = await this.prisma.location.create({ const created = await this.prisma.location.create({
data: dto, data: dto,
include: { _count: { select: { assets: true } } }, include: locationInclude,
}); });
return this.toDto(created, 0); return this.toDto(created, 0);
} }
@@ -76,10 +84,11 @@ export class LocationsService {
} }
await this.assertDepth(dto.parentId); await this.assertDepth(dto.parentId);
} }
await this.assertClientPartner(dto.partnerId);
const updated = await this.prisma.location.update({ const updated = await this.prisma.location.update({
where: { id }, where: { id },
data: dto, data: dto,
include: { _count: { select: { assets: true } } }, include: locationInclude,
}); });
return this.toDto(updated, updated._count.assets); 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 { private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
return { return {
id: row.id, id: row.id,
@@ -107,6 +126,8 @@ export class LocationsService {
guardianPhone: row.guardianPhone, guardianPhone: row.guardianPhone,
latitude: row.latitude, latitude: row.latitude,
longitude: row.longitude, longitude: row.longitude,
partnerId: row.partnerId,
partnerName: row.partner?.name ?? null,
assetCount, assetCount,
}; };
} }

View 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);
}
}

View 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 {}

View 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),
};
}
}

View 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);
}
}

View 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 {}

View 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 dinventaire 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(),
};
}
}

View File

@@ -76,14 +76,9 @@ export class PortalService {
} }
private async nextReference(): Promise<string> { private async nextReference(): Promise<string> {
const annee = new Date().getFullYear(); const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
const dernier = await this.prisma.request.findFirst({ SELECT nextval('request_ref_seq')`;
where: { reference: { startsWith: `DEM-${annee}-` } }, return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
} }
private toStatus(row: Row): PortalRequestStatus { private toStatus(row: Row): PortalRequestStatus {

View File

@@ -210,14 +210,9 @@ export class PreventiveService {
} }
private async nextReference(): Promise<string> { private async nextReference(): Promise<string> {
const annee = new Date().getFullYear(); const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
const dernier = await this.prisma.workOrder.findFirst({ SELECT nextval('work_order_ref_seq')`;
where: { reference: { startsWith: `OT-${annee}-` } }, return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `OT-${annee}-${String(n).padStart(4, '0')}`;
} }
private toDto(row: TemplateRow): TaskTemplateDto { private toDto(row: TemplateRow): TaskTemplateDto {

View 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);
}
}

View 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 {}

View 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(),
};
}
}

View File

@@ -131,14 +131,9 @@ export class RequestsService {
} }
private async nextReference(): Promise<string> { private async nextReference(): Promise<string> {
const annee = new Date().getFullYear(); const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
const dernier = await this.prisma.request.findFirst({ SELECT nextval('request_ref_seq')`;
where: { reference: { startsWith: `DEM-${annee}-` } }, return `DEM-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
orderBy: { reference: 'desc' },
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `DEM-${annee}-${String(n).padStart(4, '0')}`;
} }
private requesterLabel(row: RequestRow): string { private requesterLabel(row: RequestRow): string {

View 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);
}
}

View 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 {}

View 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 },
],
};
}
}

View File

@@ -98,6 +98,7 @@ export class UsersService {
phone: dto.phone, phone: dto.phone,
roleId: dto.roleId, roleId: dto.roleId,
isActive: dto.isActive, isActive: dto.isActive,
hourlyRate: dto.hourlyRate,
teams: dto.teamIds teams: dto.teamIds
? { set: dto.teamIds.map((id) => ({ id })) } ? { set: dto.teamIds.map((id) => ({ id })) }
: undefined, : undefined,
@@ -136,6 +137,7 @@ export class UsersService {
? 'active' ? 'active'
: 'invited', : 'invited',
isDemo: row.isDemo, isDemo: row.isDemo,
hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate),
}; };
} }
} }

View File

@@ -13,12 +13,16 @@ import {
AssigneesUpdateSchema, AssigneesUpdateSchema,
ChecklistPatchSchema, ChecklistPatchSchema,
CommentCreateSchema, CommentCreateSchema,
ConsumePartSchema,
LaborTimeCreateSchema,
ReportUpsertSchema, ReportUpsertSchema,
TransitionRequestSchema, TransitionRequestSchema,
WorkOrderCreateSchema, WorkOrderCreateSchema,
type AssigneesUpdate, type AssigneesUpdate,
type ChecklistPatch, type ChecklistPatch,
type CommentCreate, type CommentCreate,
type ConsumePart,
type LaborTimeCreate,
type ReportUpsert, type ReportUpsert,
type TransitionRequest, type TransitionRequest,
type WorkOrderCreate, type WorkOrderCreate,
@@ -97,6 +101,26 @@ export class WorkOrdersController {
return this.workOrders.upsertReport(id, body, user); 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') @Patch(':id/checklist/:itemId')
@RequirePermission('WORK_ORDERS', 'edit') @RequirePermission('WORK_ORDERS', 'edit')
patchChecklist( patchChecklist(

View File

@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PartsModule } from '../parts/parts.module';
import { WorkOrdersController } from './work-orders.controller'; import { WorkOrdersController } from './work-orders.controller';
import { WorkOrdersService } from './work-orders.service'; import { WorkOrdersService } from './work-orders.service';
@Module({ @Module({
imports: [PartsModule],
controllers: [WorkOrdersController], controllers: [WorkOrdersController],
providers: [WorkOrdersService], providers: [WorkOrdersService],
exports: [WorkOrdersService], exports: [WorkOrdersService],

View File

@@ -23,6 +23,7 @@ import {
type WorkOrderSummary, type WorkOrderSummary,
} from '@siop/shared'; } from '@siop/shared';
import type { AuthenticatedUser } from '../auth/current-user.decorator'; import type { AuthenticatedUser } from '../auth/current-user.decorator';
import { PartsService } from '../parts/parts.service';
import { PermissionsService } from '../permissions/permissions.service'; import { PermissionsService } from '../permissions/permissions.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@@ -33,6 +34,13 @@ const detailInclude = {
request: { include: { requestedBy: true } }, request: { include: { requestedBy: true } },
events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } }, events: { include: { by: true }, orderBy: { createdAt: 'desc' as const } },
checklist: { include: { doneBy: true }, orderBy: { label: 'asc' 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: { report: {
include: { include: {
doorState: true, doorState: true,
@@ -71,6 +79,7 @@ export class WorkOrdersService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissions: PermissionsService, private readonly permissions: PermissionsService,
private readonly parts: PartsService,
) {} ) {}
/** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */ /** Invariant « voir autre » : sans le droit, on ne voit que SES OT. */
@@ -177,14 +186,10 @@ export class WorkOrdersService {
} }
private async nextReference(): Promise<string> { private async nextReference(): Promise<string> {
const annee = new Date().getFullYear(); // Séquence Postgres : insensible à la concurrence (jamais de collision)
const dernier = await this.prisma.workOrder.findFirst({ const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
where: { reference: { startsWith: `OT-${annee}-` } }, SELECT nextval('work_order_ref_seq')`;
orderBy: { reference: 'desc' }, return `OT-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
select: { reference: true },
});
const n = dernier ? Number(dernier.reference.split('-')[2]) + 1 : 1;
return `OT-${annee}-${String(n).padStart(4, '0')}`;
} }
/** Machine à états stricte + garde de clôture. */ /** Machine à états stricte + garde de clôture. */
@@ -337,6 +342,65 @@ export class WorkOrdersService {
}; };
} }
// ————— 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 ————— // ————— mapping —————
private toSummary( private toSummary(
@@ -387,6 +451,22 @@ export class WorkOrdersService {
} }
const versValeur = (v: { id: string; label: string } | null) => const versValeur = (v: { id: string; label: string } | null) =>
v ? { id: v.id, label: v.label } : 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 { return {
...this.toSummary(row), ...this.toSummary(row),
description: row.description, description: row.description,
@@ -430,6 +510,16 @@ export class WorkOrdersService {
componentConcerned: versValeur(report.componentConcerned), componentConcerned: versValeur(report.componentConcerned),
} }
: null, : 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], allowedTransitions: WORK_ORDER_TRANSITIONS[row.status as WorkOrderStatus],
closureBlockers: blockers, closureBlockers: blockers,
}; };

View 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);
});
});

View File

@@ -140,12 +140,20 @@ describe('Exploitation (e2e)', () => {
.set(auth(salma)) .set(auth(salma))
.expect(200); .expect(200);
const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id); const position = listeSalma.workOrders.findIndex((w: { id: string }) => w.id === id);
const urgencesActives = listeSalma.workOrders.filter(
(w: { priority: string; status: string }) =>
w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
).length;
expect(position).toBeGreaterThanOrEqual(0); expect(position).toBeGreaterThanOrEqual(0);
expect(position).toBeLessThanOrEqual(urgencesActives); // 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 // 7. Terminal : plus aucune transition
await http() await http()

View 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 dun 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);
});
});

View 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 lOT dun 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 naccepte quun 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);
});
});

View 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();
});

View File

@@ -2,8 +2,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Route, Routes } from 'react-router-dom';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Coquille } from '@/layout/coquille'; import { Coquille } from '@/layout/coquille';
import PageAchats from '@/pages/achats';
import PageActivation from '@/pages/activation'; import PageActivation from '@/pages/activation';
import PageAscenseurs from '@/pages/ascenseurs'; import PageAscenseurs from '@/pages/ascenseurs';
import PageBibliotheque from '@/pages/bibliotheque';
import PageCategories from '@/pages/categories'; import PageCategories from '@/pages/categories';
import PageCompteurs from '@/pages/compteurs'; import PageCompteurs from '@/pages/compteurs';
import PageConnexion from '@/pages/connexion'; import PageConnexion from '@/pages/connexion';
@@ -12,7 +14,9 @@ import PageDesign from '@/pages/design';
import PageEtiquette from '@/pages/etiquette'; import PageEtiquette from '@/pages/etiquette';
import PageFicheAscenseur from '@/pages/fiche-ascenseur'; import PageFicheAscenseur from '@/pages/fiche-ascenseur';
import PageFicheOT from '@/pages/fiche-ot'; import PageFicheOT from '@/pages/fiche-ot';
import PageFichePiece from '@/pages/fiche-piece';
import PageFicheSite from '@/pages/fiche-site'; import PageFicheSite from '@/pages/fiche-site';
import PageNouvelAchat from '@/pages/nouvel-achat';
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur'; import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
import PageNouvelOT from '@/pages/nouvel-ot'; import PageNouvelOT from '@/pages/nouvel-ot';
import PageOrdresTravail from '@/pages/ordres-travail'; import PageOrdresTravail from '@/pages/ordres-travail';
@@ -20,7 +24,10 @@ import PagePersonnes from '@/pages/personnes';
import PagePortail from '@/pages/portail'; import PagePortail from '@/pages/portail';
import PagePreventif from '@/pages/preventif'; import PagePreventif from '@/pages/preventif';
import PageSites from '@/pages/sites'; import PageSites from '@/pages/sites';
import PageStatistiques from '@/pages/statistiques';
import PageStock from '@/pages/stock';
import PageTableauDeBord from '@/pages/tableau-de-bord'; import PageTableauDeBord from '@/pages/tableau-de-bord';
import PageTiers from '@/pages/tiers';
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } }, defaultOptions: { queries: { refetchOnWindowFocus: false } },
@@ -53,6 +60,13 @@ export default function App() {
<Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} /> <Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} />
<Route path="/demandes" element={dansCoquille(<PageDemandes />)} /> <Route path="/demandes" element={dansCoquille(<PageDemandes />)} />
<Route path="/preventif" element={dansCoquille(<PagePreventif />)} /> <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> </Routes>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>

268
apps/web/src/api/gestion.ts Normal file
View 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) } },
}),
),
});
}

View 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 } } })),
});
}

View File

@@ -7,6 +7,7 @@ import type {
CategoryUpdate, CategoryUpdate,
InvitationCreate, InvitationCreate,
LocationCreate, LocationCreate,
LocationUpdate,
TeamCreate, TeamCreate,
UserUpdate, UserUpdate,
} from '@siop/shared'; } from '@siop/shared';
@@ -98,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() { export function useCreateAsset() {
const invalidate = useInvalidate('assets', 'locations'); const invalidate = useInvalidate('assets', 'locations');
return useMutation({ return useMutation({

File diff suppressed because it is too large Load Diff

View File

@@ -21,7 +21,9 @@ export function useMe() {
} }
/** ADR-002 : la liste n'existe qu'en DEMO_MODE — un 404 signifie « pas de /** 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() { export function useDemoAccounts() {
return useQuery({ return useQuery({
queryKey: ['demo-accounts'], queryKey: ['demo-accounts'],
@@ -29,8 +31,9 @@ export function useDemoAccounts() {
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
queryFn: async () => { queryFn: async () => {
const { data, response } = await api.GET('/auth/demo-accounts'); const { data, response } = await api.GET('/auth/demo-accounts');
if (response.status === 404) return null; if (response.status === 404) return null; // pas de mode démo : normal
return data?.accounts ?? null; if (!data) throw new Error(`API : ${response.status}`);
return data.accounts;
}, },
}); });
} }

View 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>
);
}

View 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>
);
}

View File

@@ -6,6 +6,7 @@ import { getToken } from '@/api/client';
import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth'; import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth';
import { Avatar, initialsOf } from '@/components/avatar'; import { Avatar, initialsOf } from '@/components/avatar';
import { Logo } from '@/components/logo'; import { Logo } from '@/components/logo';
import { RechercheGlobale } from '@/components/recherche-globale';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import type { ObjectCategory, PermissionRight } from '@siop/shared'; import type { ObjectCategory, PermissionRight } from '@siop/shared';
import { api } from '@/api/client'; import { api } from '@/api/client';
@@ -18,7 +19,6 @@ import {
IcoOrdresDeTravail, IcoOrdresDeTravail,
IcoPersonnes, IcoPersonnes,
IcoPreventif, IcoPreventif,
IcoRecherche,
IcoSites, IcoSites,
IcoStatistiques, IcoStatistiques,
IcoStock, IcoStock,
@@ -66,15 +66,15 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
{ {
groupe: 'Ressources', groupe: 'Ressources',
liens: [ liens: [
{ libelle: 'Stock & achats', icone: IcoStock, release: 'R3' }, { libelle: 'Stock & achats', icone: IcoStock, route: '/stock', permission: ['PARTS', 'view'] },
{ libelle: 'Tiers', icone: IcoTiers, release: 'R3' }, { libelle: 'Tiers', icone: IcoTiers, route: '/tiers', permission: ['PURCHASE_ORDERS', 'view'] },
{ libelle: 'Fichiers', icone: IcoFichiers, release: 'R3' }, { libelle: 'Fichiers', icone: IcoFichiers, route: '/bibliotheque', permission: ['ASSETS', 'view'] },
], ],
}, },
{ {
groupe: 'Pilotage', groupe: 'Pilotage',
liens: [ 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'] }, { libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] },
], ],
}, },
@@ -185,10 +185,7 @@ export function Coquille({ children }: { children: ReactNode }) {
<div className="contenu"> <div className="contenu">
<header className="topbar"> <header className="topbar">
<div className="recherche" aria-hidden="true"> <RechercheGlobale />
<IcoRecherche />
Rechercher un OT, un ascenseur, un site&ensp;K
</div>
{urgences.length > 0 ? ( {urgences.length > 0 ? (
<button <button
type="button" type="button"

View File

@@ -10,6 +10,8 @@ const loc = (over: Partial<LocationDto>): LocationDto => ({
city: null, city: null,
guardianName: null, guardianName: null,
guardianPhone: null, guardianPhone: null,
partnerId: null,
partnerName: null,
latitude: null, latitude: null,
longitude: null, longitude: null,
assetCount: 0, assetCount: 0,

View 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>
);
}

View 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>
);
}

View File

@@ -11,7 +11,7 @@ export default function PageConnexion() {
const navigate = useNavigate(); const navigate = useNavigate();
const login = useLogin(); const login = useLogin();
const demoLogin = useDemoLogin(); const demoLogin = useDemoLogin();
const { data: demoAccounts } = useDemoAccounts(); const { data: demoAccounts, isError: apiInjoignable } = useDemoAccounts();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -33,6 +33,12 @@ export default function PageConnexion() {
<div className="conn"> <div className="conn">
<div className="carte-conn"> <div className="carte-conn">
<Logo sousTitre="Maintenance d'ascenseurs" /> <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 <form
onSubmit={surConnexion} onSubmit={surConnexion}
className="flex flex-col gap-[14px]" className="flex flex-col gap-[14px]"

View File

@@ -11,6 +11,7 @@ import {
useUpdateAsset, useUpdateAsset,
} from '@/api/referentiel'; } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions'; import { usePermissions } from '@/auth/use-permissions';
import { CarteDocuments } from '@/components/carte-documents';
import { StatutOT } from '@/components/chips-ot'; import { StatutOT } from '@/components/chips-ot';
import { StatutEquipement } from '@/components/statut-equipement'; import { StatutEquipement } from '@/components/statut-equipement';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -18,7 +19,7 @@ import { Modale } from '@/components/ui/modale';
import { formatDateFr } from '@/lib/lieux'; import { formatDateFr } from '@/lib/lieux';
/** Fiche appareil — écran validé en R0 (maquette-web, écran 5) ; historique /** 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() { export default function PageFicheAscenseur() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -135,12 +136,7 @@ export default function PageFicheAscenseur() {
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<CarteHistorique assetId={asset.id} /> <CarteHistorique assetId={asset.id} />
<div className="carte" style={{ borderStyle: 'dashed' }}> <CarteDocuments cible={{ assetId: asset.id }} peutEditer={can('ASSETS', 'edit')} />
<h2>Documents</h2>
<p className="text-encre-3">
Notices et certificats rejoindront la bibliothèque en R3.
</p>
</div>
</div> </div>
</div> </div>
<NouvelOrgane assetId={asset.id} ouverte={modaleOrgane} surFermeture={setModaleOrgane} /> <NouvelOrgane assetId={asset.id} ouverte={modaleOrgane} surFermeture={setModaleOrgane} />

View File

@@ -17,9 +17,12 @@ import {
useUpsertReport, useUpsertReport,
useWorkOrder, useWorkOrder,
} from '@/api/exploitation'; } from '@/api/exploitation';
import { useAddLaborTime, useConsumePart, useParts } from '@/api/gestion';
import { useUsers } from '@/api/referentiel'; import { useUsers } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions'; import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar'; 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 { PrioriteOT, StatutOT } from '@/components/chips-ot';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { formatDateFr } from '@/lib/lieux'; import { formatDateFr } from '@/lib/lieux';
@@ -91,14 +94,8 @@ export default function PageFicheOT() {
<CarteBilan ot={ot} peutEditer={peutEditer} /> <CarteBilan ot={ot} peutEditer={peutEditer} />
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="carte" style={{ borderStyle: 'dashed' }}> <CarteCouts ot={ot} peutEditer={peutEditer} />
<h2>Pièces & main-d'œuvre</h2> <CarteDocuments cible={{ workOrderId: ot.id }} peutEditer={peutEditer} />
<p className="text-encre-3">Consommations et coûts arrivent avec la gestion (R3).</p>
</div>
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Documents</h2>
<p className="text-encre-3">Photos et pièces jointes arrivent avec la bibliothèque (R3).</p>
</div>
</div> </div>
</div> </div>
</> </>
@@ -298,6 +295,180 @@ function CarteChecklist({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: b
); );
} }
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, lalerte sallumera.'
: '.'}
</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 }[] = [ const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail['report']> & string; dto: string }[] = [
{ champ: 'DOOR_STATE', cle: 'doorState', dto: 'doorStateId' }, { champ: 'DOOR_STATE', cle: 'doorState', dto: 'doorStateId' },
{ champ: 'CABIN_POSITION', cle: 'cabinPosition', dto: 'cabinPositionId' }, { champ: 'CABIN_POSITION', cle: 'cabinPosition', dto: 'cabinPositionId' },

View 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 à linventaire' : '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>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom'; import { Link, useNavigate, useParams } from 'react-router-dom';
import { 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 { usePermissions } from '@/auth/use-permissions';
import { CarteGeo } from '@/components/carte-geo'; import { CarteGeo } from '@/components/carte-geo';
import { StatutEquipement } from '@/components/statut-equipement'; import { StatutEquipement } from '@/components/statut-equipement';
@@ -56,6 +57,14 @@ export default function PageFicheSite() {
<span className="num"> · {site.guardianPhone}</span> <span className="num"> · {site.guardianPhone}</span>
) : null} ) : null}
</dd> </dd>
<dt>Client / syndic</dt>
<dd>
<ChampSyndic
siteId={site.id}
partnerId={site.partnerId}
partnerName={site.partnerName}
/>
</dd>
<dt>Appareils</dt> <dt>Appareils</dt>
<dd className="num">{site.assetCount}</dd> <dd className="num">{site.assetCount}</dd>
</dl> </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({ function NouvelleZone({
siteId, siteId,
ouverte, ouverte,

View 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>
</>
);
}

View File

@@ -6,6 +6,7 @@ import {
useResendInvitation, useResendInvitation,
useRoles, useRoles,
useTeams, useTeams,
useUpdateUser,
useUsers, useUsers,
} from '@/api/referentiel'; } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions'; import { usePermissions } from '@/auth/use-permissions';
@@ -44,6 +45,7 @@ export default function PagePersonnes() {
<th>Personne</th> <th>Personne</th>
<th>Rôle</th> <th>Rôle</th>
<th>Équipe</th> <th>Équipe</th>
<th>Taux horaire</th>
<th>Statut</th> <th>Statut</th>
<th></th> <th></th>
</tr> </tr>
@@ -66,6 +68,13 @@ export default function PagePersonnes() {
</td> </td>
<td>{u.role.name}</td> <td>{u.role.name}</td>
<td>{u.teams.map((t) => t.name).join(', ') || '—'}</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> <td>
{u.status === 'active' ? ( {u.status === 'active' ? (
<span className="st termine">Actif</span> <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({ function ModaleInvitation({
ouverte, ouverte,
surFermeture, surFermeture,

View 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>
</>
);
}

View 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>
);
}

View File

@@ -5,6 +5,7 @@ import {
type WorkOrderStatus, type WorkOrderStatus,
} from '@siop/shared'; } from '@siop/shared';
import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation'; import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation';
import { useAnalyticsSummary } from '@/api/gestion';
import { useMe } from '@/auth/use-auth'; import { useMe } from '@/auth/use-auth';
import { usePermissions } from '@/auth/use-permissions'; import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar'; import { Avatar } from '@/components/avatar';
@@ -18,15 +19,16 @@ const ST_COULEURS: Record<string, string> = {
DONE: 'var(--st-termine)', DONE: 'var(--st-termine)',
}; };
/** Écran 2 validé R0 — alimenté par les données réelles de R2. /** Écran 2 validé R0 — données réelles R2, pannes par organe réelles R3. */
* (Coûts → R3 ; pannes par organe → analytics R3.) */
export default function PageTableauDeBord() { export default function PageTableauDeBord() {
const navigate = useNavigate(); const navigate = useNavigate();
const { can, chargé } = usePermissions(); const { can, chargé } = usePermissions();
const peutVoirOT = can('WORK_ORDERS', 'view'); const peutVoirOT = can('WORK_ORDERS', 'view');
const peutVoirStats = can('ANALYTICS', 'view');
const { data: workOrders } = useWorkOrders(peutVoirOT); const { data: workOrders } = useWorkOrders(peutVoirOT);
const { data: requests } = useRequests(); const { data: requests } = useRequests();
const { data: preventif } = usePreventiveStatus(peutVoirOT); const { data: preventif } = usePreventiveStatus(peutVoirOT);
const { data: stats } = useAnalyticsSummary(12, peutVoirStats);
if (!chargé) return null; if (!chargé) return null;
if (!peutVoirOT) return <AccueilSansExploitation />; if (!peutVoirOT) return <AccueilSansExploitation />;
@@ -120,12 +122,47 @@ export default function PageTableauDeBord() {
))} ))}
</div> </div>
</div> </div>
<div className="carte" style={{ borderStyle: 'dashed' }}> {peutVoirStats ? (
<h2>Pannes par organe · 12 mois</h2> <div className="carte graf">
<p className="text-encre-3"> <h2>
Ce graphique s'alimentera des bilans codés — il arrive avec les analytics (R3). 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> </p>
</div> </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>
<div className="carte"> <div className="carte">
<h2>Interventions récentes</h2> <h2>Interventions récentes</h2>

View 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>
);
}

View File

@@ -187,7 +187,38 @@ table {
.recherche svg { .recherche svg {
width: 15px; width: 15px;
height: 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 { .compte {
margin-left: auto; margin-left: auto;
display: flex; display: flex;
@@ -873,6 +904,61 @@ table {
color: var(--encre); text-align: right; 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 demandeur (écran 6 validé R0 — aucun compte, aucun jargon) ═══ */
.portail { .portail {
max-width: 430px; max-width: 430px;

View 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 &amp; 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 &amp; 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 &amp; 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></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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>

View File

@@ -186,10 +186,63 @@ model MeterReading { id, meterId (cascade), value Int, readById? → User, creat
| Relevé de compteur strictement croissant | service meters | | 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) | | 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) ## À venir (référence v1 éprouvée, sera réintroduit release par release)
- **R3** : `Part`/`StockMovement` (stock **dérivé des mouvements**), `PurchaseOrder`,
`Partner`, `LaborTime` (taux figé), `Document`.
- **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile). - **R4** : `WorkOrder.version` (verrou optimiste de la synchro mobile).
- **R5** : tables d'embeddings pgvector (côté service IA). - **R5** : tables d'embeddings pgvector (côté service IA).

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -38,9 +38,10 @@ for (const op of API_CONTRACT) {
operationId: op.operationId, operationId: op.operationId,
summary: op.summary, summary: op.summary,
tags: op.tags, tags: op.tags,
...(op.pathParams?.length ...(op.pathParams?.length || op.queryParams?.length
? { ? {
parameters: op.pathParams.map((name) => ({ parameters: [
...(op.pathParams ?? []).map((name) => ({
name, name,
in: 'path', in: 'path',
required: true, required: true,
@@ -50,6 +51,13 @@ for (const op of API_CONTRACT) {
? { type: 'string', format: 'uuid' } ? { type: 'string', format: 'uuid' }
: { type: 'string' }, : { type: 'string' },
})), })),
...(op.queryParams ?? []).map((q) => ({
name: q.name,
in: 'query',
required: q.required ?? false,
schema: { type: 'string' },
})),
],
} }
: {}), : {}),
...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }), ...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }),
@@ -70,6 +78,29 @@ for (const op of API_CONTRACT) {
}, },
} }
: {}), : {}),
...(op.multipartFields
? {
requestBody: {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
properties: Object.fromEntries(
Object.entries(op.multipartFields).map(([name, sorte]) => [
name,
sorte === 'file'
? { type: 'string', format: 'binary' }
: { type: 'string' },
]),
),
required: ['file'],
},
},
},
},
}
: {}),
responses, responses,
}, },
}; };

View File

@@ -44,6 +44,29 @@ import {
PortalRequestCreateSchema, PortalRequestCreateSchema,
PortalRequestStatusSchema, PortalRequestStatusSchema,
} from './schemas/portail'; } from './schemas/portail';
import {
AnalyticsSummarySchema,
DocumentSchema,
DocumentsResponseSchema,
} from './schemas/documents';
import { SearchResponseSchema } from './schemas/search';
import {
ConsumePartSchema,
LaborTimeCreateSchema,
PartCreateSchema,
PartDetailSchema,
PartnerCreateSchema,
PartnerSchema,
PartnersResponseSchema,
PartnerUpdateSchema,
PartsResponseSchema,
PartUpdateSchema,
PurchaseOrderCreateSchema,
PurchaseOrderSchema,
PurchaseOrdersResponseSchema,
PurchaseOrderTransitionSchema,
StockMovementCreateSchema,
} from './schemas/gestion';
import { import {
MeterReadingCreateSchema, MeterReadingCreateSchema,
MetersResponseSchema, MetersResponseSchema,
@@ -91,8 +114,14 @@ export interface ApiOperation {
isPublic?: boolean; isPublic?: boolean;
/** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */ /** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */
demoOnly?: boolean; demoOnly?: boolean;
/** Paramètres de chemin (`{id}` dans path) — tous UUID en R1. */ /** Paramètres de chemin (`{id}` dans path) — `id`/`…Id` = UUID. */
pathParams?: string[]; pathParams?: string[];
/** Paramètres de requête (?a=…&b=…). */
queryParams?: { name: string; required?: boolean; description?: string }[];
/** Upload multipart/form-data : champs déclarés ('file' = binaire). */
multipartFields?: Record<string, 'file' | 'string'>;
/** Réponse 200 binaire (téléchargement streamé). */
binaryResponse?: boolean;
request?: { name: string; schema: z.ZodType }; request?: { name: string; schema: z.ZodType };
responses: Record< responses: Record<
number, number,
@@ -419,6 +448,251 @@ export const API_CONTRACT: ApiOperation[] = [
404: { description: 'Inconnue' }, 404: { description: 'Inconnue' },
}, },
}, },
// ————— R3 · Bibliothèque & analytics —————
{
operationId: 'listDocuments',
method: 'get',
path: '/documents',
summary: 'Bibliothèque (filtrable par appareil ou OT)',
tags: ['documents'],
queryParams: [{ name: 'assetId' }, { name: 'workOrderId' }, { name: 'kind' }],
responses: {
200: { description: 'Liste', name: 'DocumentsResponse', schema: DocumentsResponseSchema },
},
},
{
operationId: 'uploadDocument',
method: 'post',
path: '/documents',
summary: 'Téléverser (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT requis)',
tags: ['documents'],
multipartFields: { file: 'file', kind: 'string', assetId: 'string', workOrderId: 'string' },
responses: {
201: { description: 'Document rangé', name: 'Document', schema: DocumentSchema },
400: { description: 'Type/taille refusé ou rattachement manquant' },
},
},
{
operationId: 'downloadDocument',
method: 'get',
path: '/documents/{id}/download',
summary: 'Télécharger — streamé par lAPI (MinIO jamais exposé)',
tags: ['documents'],
pathParams: ['id'],
binaryResponse: true,
responses: {
200: { description: 'Fichier' },
404: { description: 'Inconnu' },
},
},
{
operationId: 'deleteDocument',
method: 'delete',
path: '/documents/{id}',
summary: 'Supprimer (permission dédition sur la cible)',
tags: ['documents'],
pathParams: ['id'],
responses: {
204: { description: 'Supprimé' },
404: { description: 'Inconnu' },
},
},
{
operationId: 'getAnalyticsSummary',
method: 'get',
path: '/analytics/summary',
summary: 'Le tableau de la direction : coûts, pannes par organe (bilans), préventif, top équipements',
tags: ['analytics'],
queryParams: [{ name: 'months', description: 'Période en mois : 3, 6 ou 12 (défaut)' }],
responses: {
200: { description: 'Synthèse', name: 'AnalyticsSummary', schema: AnalyticsSummarySchema },
},
},
{
operationId: 'globalSearch',
method: 'get',
path: '/search',
summary: 'Recherche globale (OT, ascenseurs, sites) — chaque famille filtrée par la matrice',
tags: ['search'],
queryParams: [{ name: 'q', required: true, description: '2 caractères minimum' }],
responses: {
200: { description: 'Résultats (5 max par famille)', name: 'SearchResponse', schema: SearchResponseSchema },
},
},
// ————— R3 · Gestion (stock, achats, tiers, coûts) —————
{
operationId: 'listPartners',
method: 'get',
path: '/partners',
summary: 'Tiers (fournisseurs, syndics) — permission PURCHASE_ORDERS',
tags: ['partners'],
responses: {
200: { description: 'Liste', name: 'PartnersResponse', schema: PartnersResponseSchema },
},
},
{
operationId: 'createPartner',
method: 'post',
path: '/partners',
summary: 'Créer un tiers',
tags: ['partners'],
request: { name: 'PartnerCreate', schema: PartnerCreateSchema },
responses: {
201: { description: 'Créé', name: 'Partner', schema: PartnerSchema },
409: { description: 'Nom déjà utilisé' },
},
},
{
operationId: 'updatePartner',
method: 'patch',
path: '/partners/{id}',
summary: 'Modifier / (dés)activer un tiers',
tags: ['partners'],
pathParams: ['id'],
request: { name: 'PartnerUpdate', schema: PartnerUpdateSchema },
responses: {
200: { description: 'Mis à jour', name: 'Partner', schema: PartnerSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'listParts',
method: 'get',
path: '/parts',
summary: 'Stock de pièces — quantités DÉRIVÉES des mouvements',
tags: ['parts'],
responses: {
200: { description: 'Liste', name: 'PartsResponse', schema: PartsResponseSchema },
},
},
{
operationId: 'getPart',
method: 'get',
path: '/parts/{id}',
summary: 'Fiche pièce : les mouvements SONT le stock',
tags: ['parts'],
pathParams: ['id'],
responses: {
200: { description: 'Fiche', name: 'PartDetail', schema: PartDetailSchema },
404: { description: 'Inconnue' },
},
},
{
operationId: 'createPart',
method: 'post',
path: '/parts',
summary: 'Créer une pièce (référence P-#### générée)',
tags: ['parts'],
request: { name: 'PartCreate', schema: PartCreateSchema },
responses: {
201: { description: 'Créée', name: 'PartDetail', schema: PartDetailSchema },
},
},
{
operationId: 'updatePart',
method: 'patch',
path: '/parts/{id}',
summary: 'Modifier une pièce (désignation, seuil, fournisseur…)',
tags: ['parts'],
pathParams: ['id'],
request: { name: 'PartUpdate', schema: PartUpdateSchema },
responses: {
200: { description: 'Mise à jour', name: 'PartDetail', schema: PartDetailSchema },
404: { description: 'Inconnue' },
},
},
{
operationId: 'addStockMovement',
method: 'post',
path: '/parts/{id}/movements',
summary: 'Entrée manuelle (+) ou ajustement (± motif REQUIS) — jamais de saisie de stock',
tags: ['parts'],
pathParams: ['id'],
request: { name: 'StockMovementCreate', schema: StockMovementCreateSchema },
responses: {
201: { description: 'Mouvement tracé', name: 'PartDetail', schema: PartDetailSchema },
409: { description: 'Le stock ne peut pas devenir négatif' },
},
},
{
operationId: 'listPurchaseOrders',
method: 'get',
path: '/purchase-orders',
summary: 'Bons de commande',
tags: ['purchase-orders'],
responses: {
200: {
description: 'Liste',
name: 'PurchaseOrdersResponse',
schema: PurchaseOrdersResponseSchema,
},
},
},
{
operationId: 'getPurchaseOrder',
method: 'get',
path: '/purchase-orders/{id}',
summary: 'Fiche BC (lignes, total)',
tags: ['purchase-orders'],
pathParams: ['id'],
responses: {
200: { description: 'Fiche', name: 'PurchaseOrder', schema: PurchaseOrderSchema },
404: { description: 'Inconnu' },
},
},
{
operationId: 'createPurchaseOrder',
method: 'post',
path: '/purchase-orders',
summary: 'Créer un BC (brouillon)',
tags: ['purchase-orders'],
request: { name: 'PurchaseOrderCreate', schema: PurchaseOrderCreateSchema },
responses: {
201: { description: 'Créé', name: 'PurchaseOrder', schema: PurchaseOrderSchema },
400: { description: 'Fournisseur ou pièce invalide' },
},
},
{
operationId: 'transitionPurchaseOrder',
method: 'post',
path: '/purchase-orders/{id}/transition',
summary: 'Envoyer / réceptionner (→ entrées de stock, PU figés) / annuler',
tags: ['purchase-orders'],
pathParams: ['id'],
request: { name: 'PurchaseOrderTransition', schema: PurchaseOrderTransitionSchema },
responses: {
200: { description: 'État changé', name: 'PurchaseOrder', schema: PurchaseOrderSchema },
409: { description: 'Transition interdite' },
},
},
{
operationId: 'consumePart',
method: 'post',
path: '/work-orders/{id}/consume-part',
summary: 'Consommer une pièce sur lOT — stock décrémenté, PRIX FIGÉ',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'ConsumePart', schema: ConsumePartSchema },
responses: {
201: { description: 'Consommée', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
409: { description: 'Stock insuffisant ou prix inconnu' },
},
},
{
operationId: 'addLaborTime',
method: 'post',
path: '/work-orders/{id}/labor',
summary: 'Saisir de la main-dœuvre — TAUX FIGÉ à la saisie',
tags: ['work-orders'],
pathParams: ['id'],
request: { name: 'LaborTimeCreate', schema: LaborTimeCreateSchema },
responses: {
201: { description: 'Saisie', name: 'WorkOrderDetail', schema: WorkOrderDetailSchema },
409: { description: 'Taux horaire non défini pour cette personne' },
},
},
// ————— R2 · Portail public (QR cabine — aucun compte) ————— // ————— R2 · Portail public (QR cabine — aucun compte) —————
{ {
operationId: 'getPortalAsset', operationId: 'getPortalAsset',

View File

@@ -4,9 +4,12 @@ export * from './exploitation';
export * from './schemas/exploitation'; export * from './schemas/exploitation';
export * from './schemas/preventif'; export * from './schemas/preventif';
export * from './schemas/portail'; export * from './schemas/portail';
export * from './schemas/gestion';
export * from './schemas/documents';
export * from './schemas/auth'; export * from './schemas/auth';
export * from './schemas/users'; export * from './schemas/users';
export * from './schemas/users-admin'; export * from './schemas/users-admin';
export * from './schemas/referentiel'; export * from './schemas/referentiel';
export * from './schemas/health'; export * from './schemas/health';
export * from './schemas/search';
export * from './contract'; export * from './contract';

View File

@@ -0,0 +1,66 @@
import { z } from 'zod';
/** Bibliothèque de documents (R3) — types fermés, rattachement obligatoire. */
export const DOCUMENT_KINDS = ['NOTICE', 'CERTIFICATE', 'PHOTO', 'OTHER'] as const;
export type DocumentKind = (typeof DOCUMENT_KINDS)[number];
export const DOCUMENT_KIND_LABELS: Record<DocumentKind, string> = {
NOTICE: 'Notice',
CERTIFICATE: 'Certificat',
PHOTO: 'Photo',
OTHER: 'Autre',
};
export const DOCUMENT_MAX_BYTES = 20 * 1024 * 1024; // 20 Mo
export const DOCUMENT_CONTENT_TYPES = [
'application/pdf',
'image/jpeg',
'image/png',
] as const;
export const DocumentSchema = z.object({
id: z.uuid(),
kind: z.enum(DOCUMENT_KINDS),
fileName: z.string(),
size: z.number().int(),
contentType: z.string(),
assetReference: z.string().nullable(),
workOrderReference: z.string().nullable(),
uploadedByName: z.string().nullable(),
createdAt: z.iso.datetime(),
});
export type DocumentDto = z.infer<typeof DocumentSchema>;
export const DocumentsResponseSchema = z.object({
documents: z.array(DocumentSchema),
});
export type DocumentsResponse = z.infer<typeof DocumentsResponseSchema>;
// ————— Analytics (écran Statistiques, maquette R3) —————
export const ANALYTICS_PERIODS = [3, 6, 12] as const;
export type AnalyticsPeriod = (typeof ANALYTICS_PERIODS)[number];
export const AnalyticsSummarySchema = z.object({
months: z.number().int(), // période appliquée (3, 6 ou 12 mois)
monthCost: z.number(), // MAD, mois courant (pièces + main-d'œuvre)
previousMonthCost: z.number(),
closed: z.object({ total: z.number().int(), preventive: z.number().int() }),
preventiveRate: z.number().nullable(), // grilles terminées / générées (période)
avgResolutionDays: z.number().nullable(), // dépannages terminés (période)
failuresByComponent: z.array(
z.object({ label: z.string(), count: z.number().int() }),
),
costsByMonth: z.array(z.object({ month: z.string(), total: z.number() })), // un point par mois de la période
topAssets: z.array(
z.object({
reference: z.string(),
siteName: z.string(),
correctives: z.number().int(),
partsCost: z.number(),
laborCost: z.number(),
total: z.number(),
}),
),
});
export type AnalyticsSummary = z.infer<typeof AnalyticsSummarySchema>;

View File

@@ -7,6 +7,7 @@ import {
WORK_ORDER_STATUSES, WORK_ORDER_STATUSES,
WORK_ORDER_TYPES, WORK_ORDER_TYPES,
} from '../exploitation'; } from '../exploitation';
import { WorkOrderCostsSchema } from './gestion';
const PersonneSchema = z.object({ const PersonneSchema = z.object({
id: z.uuid(), id: z.uuid(),
@@ -80,6 +81,8 @@ export const WorkOrderDetailSchema = WorkOrderSummarySchema.extend({
events: z.array(WorkOrderEventSchema), events: z.array(WorkOrderEventSchema),
checklist: z.array(ChecklistItemSchema), checklist: z.array(ChecklistItemSchema),
report: InterventionReportSchema.nullable(), report: InterventionReportSchema.nullable(),
/** Coûts figés (R3) : consommations + main-d'œuvre. */
costs: WorkOrderCostsSchema,
/** Ce que la machine à états autorise depuis l'état courant. */ /** Ce que la machine à états autorise depuis l'état courant. */
allowedTransitions: z.array(z.enum(WORK_ORDER_STATUSES)), allowedTransitions: z.array(z.enum(WORK_ORDER_STATUSES)),
/** Ce qui bloque la clôture (vide = clôturable) — messages métier. */ /** Ce qui bloque la clôture (vide = clôturable) — messages métier. */

View File

@@ -0,0 +1,214 @@
import { z } from 'zod';
/** Vocabulaires & schémas de la gestion (R3). */
export const PARTNER_KINDS = ['SUPPLIER', 'CLIENT'] as const;
export type PartnerKind = (typeof PARTNER_KINDS)[number];
export const PARTNER_KIND_LABELS: Record<PartnerKind, string> = {
SUPPLIER: 'Fournisseur',
CLIENT: 'Client / syndic',
};
export const PURCHASE_ORDER_STATUSES = ['DRAFT', 'SENT', 'RECEIVED', 'CANCELLED'] as const;
export type PurchaseOrderStatus = (typeof PURCHASE_ORDER_STATUSES)[number];
export const PURCHASE_ORDER_STATUS_LABELS: Record<PurchaseOrderStatus, string> = {
DRAFT: 'Brouillon',
SENT: 'Envoyé',
RECEIVED: 'Reçu',
CANCELLED: 'Annulé',
};
export const STOCK_MOVEMENT_KINDS = ['RECEIPT', 'ENTRY', 'CONSUMPTION', 'ADJUSTMENT'] as const;
export type StockMovementKind = (typeof STOCK_MOVEMENT_KINDS)[number];
export const STOCK_MOVEMENT_KIND_LABELS: Record<StockMovementKind, string> = {
RECEIPT: 'Réception',
ENTRY: 'Entrée',
CONSUMPTION: 'Sortie (OT)',
ADJUSTMENT: 'Ajustement',
};
// ————— Tiers —————
export const PartnerSchema = z.object({
id: z.uuid(),
name: z.string(),
kind: z.enum(PARTNER_KINDS),
contactName: z.string().nullable(),
phone: z.string().nullable(),
email: z.string().nullable(),
city: z.string().nullable(),
isActive: z.boolean(),
openOrders: z.number().int(), // BC non reçus/annulés (fournisseur)
siteNames: z.array(z.string()), // sites gérés (client/syndic) — maquette « Rattachements »
});
export type PartnerDto = z.infer<typeof PartnerSchema>;
export const PartnersResponseSchema = z.object({ partners: z.array(PartnerSchema) });
export type PartnersResponse = z.infer<typeof PartnersResponseSchema>;
export const PartnerCreateSchema = z.object({
name: z.string().min(1).max(120),
kind: z.enum(PARTNER_KINDS),
contactName: z.string().max(120).optional(),
phone: z.string().max(40).optional(),
email: z.email().optional(),
city: z.string().max(80).optional(),
});
export type PartnerCreate = z.infer<typeof PartnerCreateSchema>;
export const PartnerUpdateSchema = PartnerCreateSchema.partial().extend({
isActive: z.boolean().optional(),
});
export type PartnerUpdate = z.infer<typeof PartnerUpdateSchema>;
// ————— Pièces & stock (dérivé des mouvements) —————
export const PartSchema = z.object({
id: z.uuid(),
reference: z.string(),
designation: z.string(),
threshold: z.number().int(),
lastUnitPrice: z.number().nullable(),
compatible: z.string().nullable(),
supplierId: z.uuid().nullable(),
supplierName: z.string().nullable(),
isActive: z.boolean(),
stock: z.number().int(), // Σ mouvements — calculé, jamais stocké
belowThreshold: z.boolean(),
});
export type PartDto = z.infer<typeof PartSchema>;
export const PartsResponseSchema = z.object({ parts: z.array(PartSchema) });
export type PartsResponse = z.infer<typeof PartsResponseSchema>;
export const PartCreateSchema = z.object({
designation: z.string().min(1).max(200),
threshold: z.number().int().min(0).optional(),
supplierId: z.uuid().optional(),
compatible: z.string().max(200).optional(),
initialPrice: z.number().positive().optional(), // premier PU connu
});
export type PartCreate = z.infer<typeof PartCreateSchema>;
export const PartUpdateSchema = z.object({
designation: z.string().min(1).max(200).optional(),
threshold: z.number().int().min(0).optional(),
supplierId: z.uuid().nullable().optional(),
compatible: z.string().max(200).nullable().optional(),
isActive: z.boolean().optional(),
});
export type PartUpdate = z.infer<typeof PartUpdateSchema>;
export const StockMovementSchema = z.object({
id: z.uuid(),
kind: z.enum(STOCK_MOVEMENT_KINDS),
quantity: z.number().int(),
unitPrice: z.number().nullable(),
reason: z.string().nullable(),
workOrderReference: z.string().nullable(),
purchaseOrderReference: z.string().nullable(),
byName: z.string().nullable(),
createdAt: z.iso.datetime(),
});
export type StockMovementDto = z.infer<typeof StockMovementSchema>;
export const PartDetailSchema = PartSchema.extend({
movements: z.array(StockMovementSchema), // récents d'abord — le stock EST cette liste
});
export type PartDetail = z.infer<typeof PartDetailSchema>;
/** Entrée manuelle (+) ou ajustement (±, motif requis). */
export const StockMovementCreateSchema = z.object({
kind: z.enum(['ENTRY', 'ADJUSTMENT']),
quantity: z.number().int().refine((q) => q !== 0, 'Quantité non nulle requise'),
reason: z.string().max(300).optional(),
});
export type StockMovementCreate = z.infer<typeof StockMovementCreateSchema>;
// ————— Bons de commande —————
export const PurchaseOrderLineSchema = z.object({
id: z.uuid(),
partId: z.uuid(),
partReference: z.string(),
designation: z.string(),
quantity: z.number().int(),
unitPrice: z.number(),
});
export const PurchaseOrderSchema = z.object({
id: z.uuid(),
reference: z.string(),
status: z.enum(PURCHASE_ORDER_STATUSES),
supplierId: z.uuid(),
supplierName: z.string(),
lines: z.array(PurchaseOrderLineSchema),
total: z.number(),
sentAt: z.iso.datetime().nullable(),
receivedAt: z.iso.datetime().nullable(),
createdAt: z.iso.datetime(),
});
export type PurchaseOrderDto = z.infer<typeof PurchaseOrderSchema>;
export const PurchaseOrdersResponseSchema = z.object({
purchaseOrders: z.array(PurchaseOrderSchema),
});
export type PurchaseOrdersResponse = z.infer<typeof PurchaseOrdersResponseSchema>;
export const PurchaseOrderCreateSchema = z.object({
supplierId: z.uuid(),
lines: z
.array(
z.object({
partId: z.uuid(),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
}),
)
.min(1),
});
export type PurchaseOrderCreate = z.infer<typeof PurchaseOrderCreateSchema>;
export const PurchaseOrderTransitionSchema = z.object({
to: z.enum(['SENT', 'RECEIVED', 'CANCELLED']),
});
export type PurchaseOrderTransition = z.infer<typeof PurchaseOrderTransitionSchema>;
// ————— Coûts sur OT —————
export const ConsumePartSchema = z.object({
partId: z.uuid(),
quantity: z.number().int().positive(),
});
export type ConsumePart = z.infer<typeof ConsumePartSchema>;
export const LaborTimeCreateSchema = z.object({
minutes: z.number().int().positive().max(24 * 60),
userId: z.uuid().optional(), // défaut : la personne connectée
note: z.string().max(200).optional(),
});
export type LaborTimeCreate = z.infer<typeof LaborTimeCreateSchema>;
export const WorkOrderCostsSchema = z.object({
parts: z.array(
z.object({
id: z.uuid(),
designation: z.string(),
quantity: z.number().int(),
unitPrice: z.number(),
total: z.number(),
}),
),
labor: z.array(
z.object({
id: z.uuid(),
displayName: z.string(),
minutes: z.number().int(),
hourlyRate: z.number(),
total: z.number(),
note: z.string().nullable(),
}),
),
total: z.number(),
});
export type WorkOrderCosts = z.infer<typeof WorkOrderCostsSchema>;

View File

@@ -41,6 +41,8 @@ export const LocationSchema = z.object({
guardianPhone: z.string().nullable(), guardianPhone: z.string().nullable(),
latitude: z.number().nullable(), latitude: z.number().nullable(),
longitude: z.number().nullable(), longitude: z.number().nullable(),
partnerId: z.uuid().nullable(), // client/syndic du site (R3 — rattachements Tiers)
partnerName: z.string().nullable(),
assetCount: z.number().int(), // appareils rattachés (zones incluses pour un site) assetCount: z.number().int(), // appareils rattachés (zones incluses pour un site)
}); });
export type LocationDto = z.infer<typeof LocationSchema>; export type LocationDto = z.infer<typeof LocationSchema>;
@@ -59,6 +61,7 @@ export const LocationCreateSchema = z.object({
guardianPhone: z.string().max(40).optional(), guardianPhone: z.string().max(40).optional(),
latitude: z.number().min(-90).max(90).optional(), latitude: z.number().min(-90).max(90).optional(),
longitude: z.number().min(-180).max(180).optional(), longitude: z.number().min(-180).max(180).optional(),
partnerId: z.uuid().nullable().optional(), // null = détacher le client/syndic
}); });
export type LocationCreate = z.infer<typeof LocationCreateSchema>; export type LocationCreate = z.infer<typeof LocationCreateSchema>;

View File

@@ -0,0 +1,37 @@
import { z } from 'zod';
import { WORK_ORDER_STATUSES } from '../exploitation';
/** Recherche globale (topbar ⌘K) — chaque famille de résultats n'apparaît
* que si l'appelant a la permission `view` du domaine ; les OT respectent
* en plus l'invariant « voir autre ». */
export const SEARCH_MIN_CHARS = 2;
export const SEARCH_MAX_PER_KIND = 5;
export const SearchResponseSchema = z.object({
workOrders: z.array(
z.object({
id: z.uuid(),
reference: z.string(),
title: z.string(),
status: z.enum(WORK_ORDER_STATUSES),
}),
),
assets: z.array(
z.object({
id: z.uuid(),
reference: z.string(),
brand: z.string(),
model: z.string().nullable(),
siteName: z.string(),
}),
),
sites: z.array(
z.object({
id: z.uuid(),
name: z.string(),
city: z.string().nullable(),
}),
),
});
export type SearchResponse = z.infer<typeof SearchResponseSchema>;

View File

@@ -14,6 +14,8 @@ export const UserAdminSchema = z.object({
teams: z.array(z.object({ id: z.uuid(), name: z.string() })), teams: z.array(z.object({ id: z.uuid(), name: z.string() })),
status: z.enum(USER_STATUSES), status: z.enum(USER_STATUSES),
isDemo: z.boolean(), isDemo: z.boolean(),
/** Taux horaire COURANT (MAD/h) — chaque saisie de main-d'œuvre fige le sien. */
hourlyRate: z.number().nullable(),
}); });
export type UserAdmin = z.infer<typeof UserAdminSchema>; export type UserAdmin = z.infer<typeof UserAdminSchema>;
@@ -31,6 +33,7 @@ export const UserUpdateSchema = z.object({
roleId: z.uuid().optional(), roleId: z.uuid().optional(),
teamIds: z.array(z.uuid()).optional(), // remplace l'affectation teamIds: z.array(z.uuid()).optional(), // remplace l'affectation
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
hourlyRate: z.number().positive().nullable().optional(), // R3 — taux courant
}); });
export type UserUpdate = z.infer<typeof UserUpdateSchema>; export type UserUpdate = z.infer<typeof UserUpdateSchema>;

10
pnpm-lock.yaml generated
View File

@@ -84,6 +84,9 @@ importers:
'@types/jest': '@types/jest':
specifier: ^29.5.14 specifier: ^29.5.14
version: 29.5.14 version: 29.5.14
'@types/multer':
specifier: ^2.2.0
version: 2.2.0
'@types/node': '@types/node':
specifier: ^24.0.0 specifier: ^24.0.0
version: 24.13.3 version: 24.13.3
@@ -1889,6 +1892,9 @@ packages:
'@types/ms@2.1.0': '@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@2.2.0':
resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==}
'@types/node@24.13.3': '@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
@@ -6346,6 +6352,10 @@ snapshots:
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
'@types/multer@2.2.0':
dependencies:
'@types/express': 5.0.6
'@types/node@24.13.3': '@types/node@24.13.3':
dependencies: dependencies:
undici-types: 7.18.2 undici-types: 7.18.2