mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Compare commits
9 Commits
release/r2
...
release/r3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d787a2d39 | ||
|
|
460ef4a80e | ||
|
|
c46a97ed08 | ||
|
|
c5382a5538 | ||
|
|
a4e98c2000 | ||
|
|
2ffdd13cf4 | ||
|
|
32eb20b5a0 | ||
|
|
dfdf8f7c18 | ||
|
|
d5041c6f05 |
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -81,8 +81,16 @@ jobs:
|
|||||||
DATABASE_URL: postgresql://siop:siop@localhost:5432/siop
|
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:
|
||||||
|
|||||||
@@ -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), demande→OT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
|
- ✅ **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demande→OT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
|
||||||
- ✅ **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production.
|
- ✅ **R2.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é).
|
||||||
- 🔄 **R2 — reste pour clore** : recette R2 avec le référent (revue pixel ↔ maquettes R0+R2, sur `pnpm dev` ou l'instance en ligne après déploiement), tag `release/r2` → ouverture R3 Gestion (stock, BC, tiers, main-d'œuvre, analytics, bibliothèque — maquettes à produire d'abord).
|
- ✅ **R3 — maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC → RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1).
|
||||||
|
- ✅ **R3.2 — bibliothèque + analytics** : `FileStorage` complet (put/stream/remove, bucket auto), upload multipart typé (20 Mo, rattachement requis, permission sur la cible), download **streamé par l'API**, octets vérifiés à l'identique en e2e ; `GET /analytics/summary` dérivé du réel (pannes par organe via bilans codés, coûts figés, taux préventif, top équipements) ; MinIO en CI ; 71 opérations, 58 tests (6 runs consécutifs verts).
|
||||||
|
- ✅ **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright).
|
||||||
|
- ✅ **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) → 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndic→site via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright.
|
||||||
|
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
|
||||||
|
- 🔄 **Reprise ici** : redéployer sur Dokploy et vérifier en ligne (migration `r3_recette_fixes` + seed au boot) → ouverture R4 Mobile (Expo, design d'abord — maquettes avant tout code).
|
||||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 } } });
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PartnerKind" AS ENUM ('SUPPLIER', 'CLIENT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "PurchaseOrderStatus" AS ENUM ('DRAFT', 'SENT', 'RECEIVED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "StockMovementKind" AS ENUM ('RECEIPT', 'ENTRY', 'CONSUMPTION', 'ADJUSTMENT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DocumentKind" AS ENUM ('NOTICE', 'CERTIFICATE', 'PHOTO', 'OTHER');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "hourlyRate" DECIMAL(8,2);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Partner" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"kind" "PartnerKind" NOT NULL,
|
||||||
|
"contactName" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"city" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Partner_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Part" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"designation" TEXT NOT NULL,
|
||||||
|
"threshold" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastUnitPrice" DECIMAL(10,2),
|
||||||
|
"compatible" TEXT,
|
||||||
|
"supplierId" UUID,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Part_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "StockMovement" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"partId" UUID NOT NULL,
|
||||||
|
"kind" "StockMovementKind" NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"unitPrice" DECIMAL(10,2),
|
||||||
|
"reason" TEXT,
|
||||||
|
"workOrderId" UUID,
|
||||||
|
"purchaseOrderId" UUID,
|
||||||
|
"byId" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PurchaseOrder" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"status" "PurchaseOrderStatus" NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"supplierId" UUID NOT NULL,
|
||||||
|
"sentAt" TIMESTAMP(3),
|
||||||
|
"receivedAt" TIMESTAMP(3),
|
||||||
|
"cancelledAt" TIMESTAMP(3),
|
||||||
|
"createdById" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PurchaseOrder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PurchaseOrderLine" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"purchaseOrderId" UUID NOT NULL,
|
||||||
|
"partId" UUID NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"unitPrice" DECIMAL(10,2) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PurchaseOrderLine_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LaborTime" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"workOrderId" UUID NOT NULL,
|
||||||
|
"userId" UUID NOT NULL,
|
||||||
|
"minutes" INTEGER NOT NULL,
|
||||||
|
"hourlyRate" DECIMAL(8,2) NOT NULL,
|
||||||
|
"note" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "LaborTime_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Document" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"kind" "DocumentKind" NOT NULL,
|
||||||
|
"fileName" TEXT NOT NULL,
|
||||||
|
"storageKey" TEXT NOT NULL,
|
||||||
|
"size" INTEGER NOT NULL,
|
||||||
|
"contentType" TEXT NOT NULL,
|
||||||
|
"assetId" UUID,
|
||||||
|
"workOrderId" UUID,
|
||||||
|
"uploadedById" UUID,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Document_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Partner_name_key" ON "Partner"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Part_reference_key" ON "Part"("reference");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StockMovement_partId_idx" ON "StockMovement"("partId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StockMovement_workOrderId_idx" ON "StockMovement"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PurchaseOrder_reference_key" ON "PurchaseOrder"("reference");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "PurchaseOrderLine_purchaseOrderId_idx" ON "PurchaseOrderLine"("purchaseOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LaborTime_workOrderId_idx" ON "LaborTime"("workOrderId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Document_storageKey_key" ON "Document"("storageKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Document_assetId_idx" ON "Document"("assetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Document_workOrderId_idx" ON "Document"("workOrderId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Part" ADD CONSTRAINT "Part_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_byId_fkey" FOREIGN KEY ("byId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_supplierId_fkey" FOREIGN KEY ("supplierId") REFERENCES "Partner"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_purchaseOrderId_fkey" FOREIGN KEY ("purchaseOrderId") REFERENCES "PurchaseOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PurchaseOrderLine" ADD CONSTRAINT "PurchaseOrderLine_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LaborTime" ADD CONSTRAINT "LaborTime_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Document" ADD CONSTRAINT "Document_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Document" ADD CONSTRAINT "Document_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Document" ADD CONSTRAINT "Document_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Références OT/DEM/BC/P : séquences Postgres — la concurrence ne peut plus
|
||||||
|
-- produire de collision (leçon des générations parallèles).
|
||||||
|
-- La numérotation ne se remet pas à zéro chaque année : l'unicité prime.
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "work_order_ref_seq";
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "request_ref_seq";
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "purchase_order_ref_seq";
|
||||||
|
CREATE SEQUENCE IF NOT EXISTS "part_ref_seq";
|
||||||
|
|
||||||
|
SELECT setval('work_order_ref_seq',
|
||||||
|
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "WorkOrder"), 1000));
|
||||||
|
SELECT setval('request_ref_seq',
|
||||||
|
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Request"), 1000));
|
||||||
|
SELECT setval('purchase_order_ref_seq',
|
||||||
|
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "PurchaseOrder"), 1000));
|
||||||
|
SELECT setval('part_ref_seq',
|
||||||
|
GREATEST((SELECT COALESCE(MAX(SUBSTRING("reference" FROM '\d+$')::int), 0) FROM "Part"), 1000));
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Location" ADD COLUMN "partnerId" UUID;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Location_partnerId_idx" ON "Location"("partnerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Location" ADD CONSTRAINT "Location_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 l’inventaire initial',
|
||||||
|
byId: nadia.id,
|
||||||
|
createdAt: new Date(Date.now() - 60 * 86400e3),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// P-0113 : consommations + ajustement → stock 2 (sous le seuil, maquette)
|
||||||
|
const ot341 = await prisma.workOrder.findUnique({
|
||||||
|
where: { reference: `OT-${annee}-0341` },
|
||||||
|
});
|
||||||
|
await prisma.stockMovement.createMany({
|
||||||
|
data: [
|
||||||
|
{ partId: partIds.get('P-0113')!, kind: 'ADJUSTMENT', quantity: -1, reason: 'Inventaire — pièce endommagée', byId: nadia.id },
|
||||||
|
{ partId: partIds.get('P-0113')!, kind: 'CONSUMPTION', quantity: -2, unitPrice: 85, byId: ahmed.id },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// P-0087 : consommation sur OT-0341 (carte maquette : 2 × 120 = 240 MAD)
|
||||||
|
if (ot341) {
|
||||||
|
await prisma.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId: partIds.get('P-0087')!,
|
||||||
|
kind: 'CONSUMPTION',
|
||||||
|
quantity: -2,
|
||||||
|
unitPrice: 120,
|
||||||
|
workOrderId: ot341.id,
|
||||||
|
byId: ahmed.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId: partIds.get('P-0042')!,
|
||||||
|
kind: 'CONSUMPTION',
|
||||||
|
quantity: -1,
|
||||||
|
unitPrice: 85,
|
||||||
|
workOrderId: ot341.id,
|
||||||
|
byId: ahmed.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// main-d'œuvre : 1 h 30 × 120 MAD/h = 180 (total maquette : 505 MAD)
|
||||||
|
await prisma.laborTime.create({
|
||||||
|
data: { workOrderId: ot341.id, userId: ahmed.id, minutes: 90, hourlyRate: 120 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* c8 ignore start — wrapper CLI */
|
/* c8 ignore start — wrapper CLI */
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|||||||
14
apps/api/src/analytics/analytics.controller.ts
Normal file
14
apps/api/src/analytics/analytics.controller.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
|
||||||
|
@Controller('analytics')
|
||||||
|
export class AnalyticsController {
|
||||||
|
constructor(private readonly analytics: AnalyticsService) {}
|
||||||
|
|
||||||
|
@Get('summary')
|
||||||
|
@RequirePermission('ANALYTICS', 'view')
|
||||||
|
summary(@Query('months') months?: string) {
|
||||||
|
return this.analytics.summary(months);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/analytics/analytics.module.ts
Normal file
9
apps/api/src/analytics/analytics.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AnalyticsController } from './analytics.controller';
|
||||||
|
import { AnalyticsService } from './analytics.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AnalyticsController],
|
||||||
|
providers: [AnalyticsService],
|
||||||
|
})
|
||||||
|
export class AnalyticsModule {}
|
||||||
201
apps/api/src/analytics/analytics.service.ts
Normal file
201
apps/api/src/analytics/analytics.service.ts
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ANALYTICS_PERIODS, type AnalyticsPeriod, type AnalyticsSummary } from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const debutMois = (decalage: number): Date => {
|
||||||
|
const d = new Date();
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() - decalage, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Le tableau de la direction — tout est DÉRIVÉ des données réelles :
|
||||||
|
* mouvements (prix figés), main-d'œuvre (taux figés), bilans codés. */
|
||||||
|
@Injectable()
|
||||||
|
export class AnalyticsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async summary(monthsRaw?: string): Promise<AnalyticsSummary> {
|
||||||
|
// Période demandée par l'écran (3, 6 ou 12 mois) — 12 par défaut.
|
||||||
|
const months: AnalyticsPeriod = ANALYTICS_PERIODS.find(
|
||||||
|
(p) => p === Number(monthsRaw),
|
||||||
|
) ?? 12;
|
||||||
|
const depuisPeriode = debutMois(months - 1);
|
||||||
|
|
||||||
|
const [consommations, mainOeuvre, clos12m, grilles12m, correctifsClos, pannes, topDonnees] =
|
||||||
|
await Promise.all([
|
||||||
|
this.prisma.stockMovement.findMany({
|
||||||
|
where: { kind: 'CONSUMPTION', createdAt: { gte: depuisPeriode } },
|
||||||
|
select: { quantity: true, unitPrice: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.laborTime.findMany({
|
||||||
|
where: { createdAt: { gte: depuisPeriode } },
|
||||||
|
select: { minutes: true, hourlyRate: true, createdAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.workOrder.groupBy({
|
||||||
|
by: ['type'],
|
||||||
|
where: { status: 'DONE', completedAt: { gte: depuisPeriode } },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.workOrder.findMany({
|
||||||
|
where: { periodKey: { not: null }, createdAt: { gte: depuisPeriode } },
|
||||||
|
select: { status: true },
|
||||||
|
}),
|
||||||
|
this.prisma.workOrder.findMany({
|
||||||
|
where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuisPeriode } },
|
||||||
|
select: { createdAt: true, completedAt: true },
|
||||||
|
}),
|
||||||
|
this.prisma.interventionReport.groupBy({
|
||||||
|
by: ['componentConcernedId'],
|
||||||
|
where: {
|
||||||
|
componentConcernedId: { not: null },
|
||||||
|
workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuisPeriode } },
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
this.prisma.stockMovement.findMany({
|
||||||
|
where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuisPeriode } },
|
||||||
|
select: {
|
||||||
|
quantity: true,
|
||||||
|
unitPrice: true,
|
||||||
|
workOrder: {
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
asset: {
|
||||||
|
select: {
|
||||||
|
reference: true,
|
||||||
|
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Coûts par mois (6 derniers) — pièces + main-d'œuvre, prix/taux figés
|
||||||
|
const cleMois = (d: Date) => d.toISOString().slice(0, 7);
|
||||||
|
const parMois = new Map<string, number>();
|
||||||
|
for (let i = months - 1; i >= 0; i--) parMois.set(cleMois(debutMois(i)), 0);
|
||||||
|
for (const c of consommations) {
|
||||||
|
const cle = cleMois(c.createdAt);
|
||||||
|
if (parMois.has(cle)) {
|
||||||
|
parMois.set(cle, parMois.get(cle)! + -c.quantity * Number(c.unitPrice ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const l of mainOeuvre) {
|
||||||
|
const cle = cleMois(l.createdAt);
|
||||||
|
if (parMois.has(cle)) {
|
||||||
|
parMois.set(cle, parMois.get(cle)! + (l.minutes / 60) * Number(l.hourlyRate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const costsByMonth = [...parMois.entries()].map(([month, total]) => ({
|
||||||
|
month,
|
||||||
|
total: Math.round(total * 100) / 100,
|
||||||
|
}));
|
||||||
|
const moisCourant = cleMois(new Date());
|
||||||
|
const moisPrecedent = cleMois(debutMois(1));
|
||||||
|
|
||||||
|
// Pannes par organe : depuis les bilans codés
|
||||||
|
const labels = await this.prisma.referenceValue.findMany({
|
||||||
|
where: { id: { in: pannes.map((p) => p.componentConcernedId!).filter(Boolean) } },
|
||||||
|
});
|
||||||
|
const labelDe = new Map(labels.map((l) => [l.id, l.label]));
|
||||||
|
const failuresByComponent = pannes
|
||||||
|
.map((p) => ({
|
||||||
|
label: labelDe.get(p.componentConcernedId!) ?? '—',
|
||||||
|
count: p._count._all,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 6);
|
||||||
|
|
||||||
|
// Top équipements en coût (pièces via OT + main-d'œuvre)
|
||||||
|
const laborParOT = await this.prisma.laborTime.findMany({
|
||||||
|
where: { createdAt: { gte: depuisPeriode } },
|
||||||
|
select: {
|
||||||
|
minutes: true,
|
||||||
|
hourlyRate: true,
|
||||||
|
workOrder: {
|
||||||
|
select: {
|
||||||
|
type: true,
|
||||||
|
asset: {
|
||||||
|
select: {
|
||||||
|
reference: true,
|
||||||
|
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
interface Cumul { siteName: string; correctives: number; partsCost: number; laborCost: number }
|
||||||
|
const parAppareil = new Map<string, Cumul>();
|
||||||
|
const cumul = (ref: string, siteName: string): Cumul => {
|
||||||
|
if (!parAppareil.has(ref)) {
|
||||||
|
parAppareil.set(ref, { siteName, correctives: 0, partsCost: 0, laborCost: 0 });
|
||||||
|
}
|
||||||
|
return parAppareil.get(ref)!;
|
||||||
|
};
|
||||||
|
for (const m of topDonnees) {
|
||||||
|
const asset = m.workOrder!.asset;
|
||||||
|
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||||
|
c.partsCost += -m.quantity * Number(m.unitPrice ?? 0);
|
||||||
|
}
|
||||||
|
for (const l of laborParOT) {
|
||||||
|
const asset = l.workOrder.asset;
|
||||||
|
const c = cumul(asset.reference, asset.location.parent?.name ?? asset.location.name);
|
||||||
|
c.laborCost += (l.minutes / 60) * Number(l.hourlyRate);
|
||||||
|
}
|
||||||
|
const correctivesParAppareil = await this.prisma.workOrder.groupBy({
|
||||||
|
by: ['assetId'],
|
||||||
|
where: { type: 'CORRECTIVE', createdAt: { gte: depuisPeriode } },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const assetsRefs = await this.prisma.asset.findMany({
|
||||||
|
where: { id: { in: correctivesParAppareil.map((c) => c.assetId) } },
|
||||||
|
select: { id: true, reference: true },
|
||||||
|
});
|
||||||
|
const refDe = new Map(assetsRefs.map((a) => [a.id, a.reference]));
|
||||||
|
for (const c of correctivesParAppareil) {
|
||||||
|
const ref = refDe.get(c.assetId);
|
||||||
|
if (ref && parAppareil.has(ref)) parAppareil.get(ref)!.correctives = c._count._all;
|
||||||
|
}
|
||||||
|
const topAssets = [...parAppareil.entries()]
|
||||||
|
.map(([reference, c]) => ({
|
||||||
|
reference,
|
||||||
|
siteName: c.siteName,
|
||||||
|
correctives: c.correctives,
|
||||||
|
partsCost: Math.round(c.partsCost * 100) / 100,
|
||||||
|
laborCost: Math.round(c.laborCost * 100) / 100,
|
||||||
|
total: Math.round((c.partsCost + c.laborCost) * 100) / 100,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.total - a.total)
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const totalClos = clos12m.reduce((s, g) => s + g._count._all, 0);
|
||||||
|
const preventifsClos = clos12m.find((g) => g.type === 'PREVENTIVE')?._count._all ?? 0;
|
||||||
|
const grillesTerminees = grilles12m.filter((g) => g.status === 'DONE').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
months,
|
||||||
|
monthCost: costsByMonth.find((c) => c.month === moisCourant)?.total ?? 0,
|
||||||
|
previousMonthCost: costsByMonth.find((c) => c.month === moisPrecedent)?.total ?? 0,
|
||||||
|
closed: { total: totalClos, preventive: preventifsClos },
|
||||||
|
preventiveRate: grilles12m.length
|
||||||
|
? Math.round((grillesTerminees / grilles12m.length) * 100) / 100
|
||||||
|
: null,
|
||||||
|
avgResolutionDays: correctifsClos.length
|
||||||
|
? Math.round(
|
||||||
|
(correctifsClos.reduce(
|
||||||
|
(s, w) => s + (w.completedAt!.getTime() - w.createdAt.getTime()),
|
||||||
|
0,
|
||||||
|
) /
|
||||||
|
correctifsClos.length /
|
||||||
|
86400e3) *
|
||||||
|
10,
|
||||||
|
) / 10
|
||||||
|
: null,
|
||||||
|
failuresByComponent,
|
||||||
|
costsByMonth,
|
||||||
|
topAssets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { DynamicModule, Module } from '@nestjs/common';
|
import { 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] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
77
apps/api/src/documents/documents.controller.ts
Normal file
77
apps/api/src/documents/documents.controller.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
StreamableFile,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { DOCUMENT_MAX_BYTES } from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { DocumentsService } from './documents.service';
|
||||||
|
|
||||||
|
@Controller('documents')
|
||||||
|
export class DocumentsController {
|
||||||
|
constructor(private readonly documents: DocumentsService) {}
|
||||||
|
|
||||||
|
/** Bibliothèque interne — authentification seule (les cartes « Documents »
|
||||||
|
* des fiches la consomment). */
|
||||||
|
@Get()
|
||||||
|
list(
|
||||||
|
@Query('assetId') assetId?: string,
|
||||||
|
@Query('workOrderId') workOrderId?: string,
|
||||||
|
@Query('kind') kind?: string,
|
||||||
|
) {
|
||||||
|
return this.documents.list({ assetId, workOrderId, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseInterceptors(
|
||||||
|
FileInterceptor('file', { limits: { fileSize: DOCUMENT_MAX_BYTES } }),
|
||||||
|
)
|
||||||
|
upload(
|
||||||
|
@UploadedFile() file: Express.Multer.File | undefined,
|
||||||
|
@Body() body: { kind?: string; assetId?: string; workOrderId?: string },
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
if (!file) throw new BadRequestException('Aucun fichier reçu (champ « file »)');
|
||||||
|
return this.documents.upload(
|
||||||
|
{
|
||||||
|
buffer: file.buffer,
|
||||||
|
originalName: file.originalname,
|
||||||
|
contentType: file.mimetype,
|
||||||
|
size: file.size,
|
||||||
|
kind: body.kind ?? 'OTHER',
|
||||||
|
assetId: body.assetId || undefined,
|
||||||
|
workOrderId: body.workOrderId || undefined,
|
||||||
|
},
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/download')
|
||||||
|
async download(@Param('id', ParseUUIDPipe) id: string): Promise<StreamableFile> {
|
||||||
|
const { stream, fileName, contentType } = await this.documents.download(id);
|
||||||
|
return new StreamableFile(stream as never, {
|
||||||
|
type: contentType,
|
||||||
|
disposition: `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@HttpCode(204)
|
||||||
|
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthenticatedUser) {
|
||||||
|
return this.documents.remove(id, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/documents/documents.module.ts
Normal file
9
apps/api/src/documents/documents.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DocumentsController } from './documents.controller';
|
||||||
|
import { DocumentsService } from './documents.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [DocumentsController],
|
||||||
|
providers: [DocumentsService],
|
||||||
|
})
|
||||||
|
export class DocumentsModule {}
|
||||||
160
apps/api/src/documents/documents.service.ts
Normal file
160
apps/api/src/documents/documents.service.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
DOCUMENT_CONTENT_TYPES,
|
||||||
|
DOCUMENT_KINDS,
|
||||||
|
DOCUMENT_MAX_BYTES,
|
||||||
|
type DocumentDto,
|
||||||
|
type DocumentKind,
|
||||||
|
type DocumentsResponse,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { FILE_STORAGE, type FileStorage } from '../files/file-storage';
|
||||||
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const documentInclude = {
|
||||||
|
asset: { select: { reference: true } },
|
||||||
|
workOrder: { select: { reference: true } },
|
||||||
|
uploadedBy: { select: { displayName: true } },
|
||||||
|
} satisfies Prisma.DocumentInclude;
|
||||||
|
|
||||||
|
type Row = Prisma.DocumentGetPayload<{ include: typeof documentInclude }>;
|
||||||
|
|
||||||
|
export interface UploadInput {
|
||||||
|
buffer: Buffer;
|
||||||
|
originalName: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
kind: string;
|
||||||
|
assetId?: string;
|
||||||
|
workOrderId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocumentsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
@Inject(FILE_STORAGE) private readonly storage: FileStorage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(filtres: {
|
||||||
|
assetId?: string;
|
||||||
|
workOrderId?: string;
|
||||||
|
kind?: string;
|
||||||
|
}): Promise<DocumentsResponse> {
|
||||||
|
const rows = await this.prisma.document.findMany({
|
||||||
|
where: {
|
||||||
|
assetId: filtres.assetId || undefined,
|
||||||
|
workOrderId: filtres.workOrderId || undefined,
|
||||||
|
kind: (filtres.kind as DocumentKind) || undefined,
|
||||||
|
},
|
||||||
|
include: documentInclude,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return { documents: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload : types fermés, 20 Mo max, rattachement REQUIS, permission
|
||||||
|
* d'édition sur la CIBLE (appareil → ASSETS, OT → WORK_ORDERS). */
|
||||||
|
async upload(input: UploadInput, user: AuthenticatedUser): Promise<DocumentDto> {
|
||||||
|
if (!(DOCUMENT_KINDS as readonly string[]).includes(input.kind)) {
|
||||||
|
throw new BadRequestException('Type de document inconnu');
|
||||||
|
}
|
||||||
|
if (!(DOCUMENT_CONTENT_TYPES as readonly string[]).includes(input.contentType)) {
|
||||||
|
throw new BadRequestException('Format refusé — PDF, JPG ou PNG uniquement');
|
||||||
|
}
|
||||||
|
if (input.size > DOCUMENT_MAX_BYTES) {
|
||||||
|
throw new BadRequestException('Fichier trop lourd — 20 Mo maximum');
|
||||||
|
}
|
||||||
|
if (!input.assetId && !input.workOrderId) {
|
||||||
|
throw new BadRequestException('Rattachez le document à un appareil ou à un OT');
|
||||||
|
}
|
||||||
|
await this.assertCible(input.assetId, input.workOrderId, user, 'edit');
|
||||||
|
|
||||||
|
const storageKey = `documents/${randomUUID()}/${input.originalName.replace(/[^\w.\-()À-ſ ]/g, '_')}`;
|
||||||
|
await this.storage.putObject(storageKey, input.buffer, input.contentType);
|
||||||
|
const created = await this.prisma.document.create({
|
||||||
|
data: {
|
||||||
|
kind: input.kind as DocumentKind,
|
||||||
|
fileName: input.originalName,
|
||||||
|
storageKey,
|
||||||
|
size: input.size,
|
||||||
|
contentType: input.contentType,
|
||||||
|
assetId: input.assetId,
|
||||||
|
workOrderId: input.workOrderId,
|
||||||
|
uploadedById: user.userId,
|
||||||
|
},
|
||||||
|
include: documentInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async download(id: string): Promise<{
|
||||||
|
stream: NodeJS.ReadableStream;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
}> {
|
||||||
|
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException('Document inconnu');
|
||||||
|
return {
|
||||||
|
stream: await this.storage.getObjectStream(doc.storageKey),
|
||||||
|
fileName: doc.fileName,
|
||||||
|
contentType: doc.contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string, user: AuthenticatedUser): Promise<void> {
|
||||||
|
const doc = await this.prisma.document.findUnique({ where: { id } });
|
||||||
|
if (!doc) throw new NotFoundException('Document inconnu');
|
||||||
|
await this.assertCible(doc.assetId ?? undefined, doc.workOrderId ?? undefined, user, 'edit');
|
||||||
|
await this.prisma.document.delete({ where: { id } });
|
||||||
|
await this.storage.removeObject(doc.storageKey).catch(() => {
|
||||||
|
// le stockage peut être momentanément injoignable — la base fait foi
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCible(
|
||||||
|
assetId: string | undefined,
|
||||||
|
workOrderId: string | undefined,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
right: 'edit',
|
||||||
|
): Promise<void> {
|
||||||
|
if (assetId) {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||||
|
if (!asset) throw new BadRequestException('Appareil inconnu');
|
||||||
|
if (!(await this.permissions.can(user.roleId, 'ASSETS', right))) {
|
||||||
|
throw new ForbiddenException('Droit manquant : ASSETS.edit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (workOrderId) {
|
||||||
|
const ot = await this.prisma.workOrder.findUnique({ where: { id: workOrderId } });
|
||||||
|
if (!ot) throw new BadRequestException('OT inconnu');
|
||||||
|
if (!(await this.permissions.can(user.roleId, 'WORK_ORDERS', right))) {
|
||||||
|
throw new ForbiddenException('Droit manquant : WORK_ORDERS.edit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: Row): DocumentDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
kind: row.kind,
|
||||||
|
fileName: row.fileName,
|
||||||
|
size: row.size,
|
||||||
|
contentType: row.contentType,
|
||||||
|
assetReference: row.asset?.reference ?? null,
|
||||||
|
workOrderReference: row.workOrder?.reference ?? null,
|
||||||
|
uploadedByName: row.uploadedBy?.displayName ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
|
* 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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
45
apps/api/src/partners/partners.controller.ts
Normal file
45
apps/api/src/partners/partners.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PartnerCreateSchema,
|
||||||
|
PartnerUpdateSchema,
|
||||||
|
type PartnerCreate,
|
||||||
|
type PartnerUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { PartnersService } from './partners.service';
|
||||||
|
|
||||||
|
/** Les tiers vivent sous la permission PURCHASE_ORDERS (décision R3). */
|
||||||
|
@Controller('partners')
|
||||||
|
export class PartnersController {
|
||||||
|
constructor(private readonly partners: PartnersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.partners.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(PartnerCreateSchema)) body: PartnerCreate) {
|
||||||
|
return this.partners.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(PartnerUpdateSchema)) body: PartnerUpdate,
|
||||||
|
) {
|
||||||
|
return this.partners.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/partners/partners.module.ts
Normal file
9
apps/api/src/partners/partners.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PartnersController } from './partners.controller';
|
||||||
|
import { PartnersService } from './partners.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PartnersController],
|
||||||
|
providers: [PartnersService],
|
||||||
|
})
|
||||||
|
export class PartnersModule {}
|
||||||
91
apps/api/src/partners/partners.service.ts
Normal file
91
apps/api/src/partners/partners.service.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
PartnerCreate,
|
||||||
|
PartnerDto,
|
||||||
|
PartnersResponse,
|
||||||
|
PartnerUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const partnerInclude = {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
purchaseOrders: { where: { status: { in: ['DRAFT', 'SENT'] } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Rattachements de la maquette : sites (jamais les zones) gérés par un client/syndic.
|
||||||
|
sites: {
|
||||||
|
where: { parentId: null },
|
||||||
|
select: { name: true },
|
||||||
|
orderBy: { name: 'asc' as const },
|
||||||
|
},
|
||||||
|
} satisfies Prisma.PartnerInclude;
|
||||||
|
|
||||||
|
type Row = Prisma.PartnerGetPayload<{ include: typeof partnerInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PartnersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<PartnersResponse> {
|
||||||
|
const rows = await this.prisma.partner.findMany({
|
||||||
|
include: partnerInclude,
|
||||||
|
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
return { partners: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: PartnerCreate): Promise<PartnerDto> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.partner.create({
|
||||||
|
data: dto,
|
||||||
|
include: partnerInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom de tiers existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: PartnerUpdate): Promise<PartnerDto> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.partner.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: partnerInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Tiers inconnu');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom de tiers existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: Row): PartnerDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
kind: row.kind,
|
||||||
|
contactName: row.contactName,
|
||||||
|
phone: row.phone,
|
||||||
|
email: row.email,
|
||||||
|
city: row.city,
|
||||||
|
isActive: row.isActive,
|
||||||
|
openOrders: row._count.purchaseOrders,
|
||||||
|
siteNames: row.sites.map((s) => s.name),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
66
apps/api/src/parts/parts.controller.ts
Normal file
66
apps/api/src/parts/parts.controller.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PartCreateSchema,
|
||||||
|
PartUpdateSchema,
|
||||||
|
StockMovementCreateSchema,
|
||||||
|
type PartCreate,
|
||||||
|
type PartUpdate,
|
||||||
|
type StockMovementCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { PartsService } from './parts.service';
|
||||||
|
|
||||||
|
@Controller('parts')
|
||||||
|
export class PartsController {
|
||||||
|
constructor(private readonly parts: PartsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('PARTS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.parts.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('PARTS', 'view')
|
||||||
|
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.parts.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('PARTS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(PartCreateSchema)) body: PartCreate) {
|
||||||
|
return this.parts.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('PARTS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(PartUpdateSchema)) body: PartUpdate,
|
||||||
|
) {
|
||||||
|
return this.parts.update(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/movements')
|
||||||
|
@RequirePermission('PARTS', 'edit')
|
||||||
|
addMovement(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(StockMovementCreateSchema)) body: StockMovementCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.parts.addMovement(id, body, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/parts/parts.module.ts
Normal file
10
apps/api/src/parts/parts.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PartsController } from './parts.controller';
|
||||||
|
import { PartsService } from './parts.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PartsController],
|
||||||
|
providers: [PartsService],
|
||||||
|
exports: [PartsService],
|
||||||
|
})
|
||||||
|
export class PartsModule {}
|
||||||
231
apps/api/src/parts/parts.service.ts
Normal file
231
apps/api/src/parts/parts.service.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
PartCreate,
|
||||||
|
PartDetail,
|
||||||
|
PartDto,
|
||||||
|
PartsResponse,
|
||||||
|
PartUpdate,
|
||||||
|
StockMovementCreate,
|
||||||
|
StockMovementDto,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const partInclude = { supplier: true } satisfies Prisma.PartInclude;
|
||||||
|
type PartRow = Prisma.PartGetPayload<{ include: typeof partInclude }>;
|
||||||
|
|
||||||
|
const mvtInclude = {
|
||||||
|
workOrder: { select: { reference: true } },
|
||||||
|
purchaseOrder: { select: { reference: true } },
|
||||||
|
by: { select: { displayName: true } },
|
||||||
|
} satisfies Prisma.StockMovementInclude;
|
||||||
|
type MvtRow = Prisma.StockMovementGetPayload<{ include: typeof mvtInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PartsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Le stock EST la somme des mouvements — calculé, jamais stocké. */
|
||||||
|
private async stocks(partIds?: string[]): Promise<Map<string, number>> {
|
||||||
|
const grouped = await this.prisma.stockMovement.groupBy({
|
||||||
|
by: ['partId'],
|
||||||
|
where: partIds ? { partId: { in: partIds } } : undefined,
|
||||||
|
_sum: { quantity: true },
|
||||||
|
});
|
||||||
|
return new Map(grouped.map((g) => [g.partId, g._sum.quantity ?? 0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(): Promise<PartsResponse> {
|
||||||
|
const rows = await this.prisma.part.findMany({
|
||||||
|
include: partInclude,
|
||||||
|
orderBy: { reference: 'asc' },
|
||||||
|
});
|
||||||
|
const stocks = await this.stocks();
|
||||||
|
return { parts: rows.map((r) => this.toDto(r, stocks.get(r.id) ?? 0)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<PartDetail> {
|
||||||
|
const row = await this.prisma.part.findUnique({ where: { id }, include: partInclude });
|
||||||
|
if (!row) throw new NotFoundException('Pièce inconnue');
|
||||||
|
const [stocks, movements] = await Promise.all([
|
||||||
|
this.stocks([id]),
|
||||||
|
this.prisma.stockMovement.findMany({
|
||||||
|
where: { partId: id },
|
||||||
|
include: mvtInclude,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
...this.toDto(row, stocks.get(id) ?? 0),
|
||||||
|
movements: movements.map((m) => this.toMovementDto(m)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: PartCreate): Promise<PartDetail> {
|
||||||
|
if (dto.supplierId) await this.assertSupplier(dto.supplierId);
|
||||||
|
for (let essai = 0; ; essai++) {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.part.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
designation: dto.designation,
|
||||||
|
threshold: dto.threshold ?? 0,
|
||||||
|
supplierId: dto.supplierId,
|
||||||
|
compatible: dto.compatible,
|
||||||
|
lastUnitPrice: dto.initialPrice,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return this.get(created.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002' &&
|
||||||
|
essai < 3
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: PartUpdate): Promise<PartDetail> {
|
||||||
|
if (dto.supplierId) await this.assertSupplier(dto.supplierId);
|
||||||
|
try {
|
||||||
|
await this.prisma.part.update({ where: { id }, data: dto, select: { id: true } });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Pièce inconnue');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entrée manuelle ou ajustement — TOUJOURS un mouvement tracé. */
|
||||||
|
async addMovement(
|
||||||
|
id: string,
|
||||||
|
dto: StockMovementCreate,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<PartDetail> {
|
||||||
|
const part = await this.prisma.part.findUnique({ where: { id } });
|
||||||
|
if (!part) throw new NotFoundException('Pièce inconnue');
|
||||||
|
if (dto.kind === 'ENTRY' && dto.quantity <= 0) {
|
||||||
|
throw new BadRequestException('Une entrée manuelle est positive');
|
||||||
|
}
|
||||||
|
if (dto.kind === 'ADJUSTMENT' && !dto.reason?.trim()) {
|
||||||
|
throw new BadRequestException('Un ajustement d’inventaire exige un motif');
|
||||||
|
}
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const somme = await tx.stockMovement.aggregate({
|
||||||
|
where: { partId: id },
|
||||||
|
_sum: { quantity: true },
|
||||||
|
});
|
||||||
|
const stock = somme._sum.quantity ?? 0;
|
||||||
|
if (stock + dto.quantity < 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Refusé : le stock deviendrait négatif (${stock} ${dto.quantity > 0 ? '+' : ''}${dto.quantity})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await tx.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId: id,
|
||||||
|
kind: dto.kind,
|
||||||
|
quantity: dto.quantity,
|
||||||
|
reason: dto.reason,
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Consommation d'OT — stock suffisant exigé, PRIX FIGÉ au moment T.
|
||||||
|
* Appelé par WorkOrdersService dans le périmètre d'un OT vérifié. */
|
||||||
|
async consume(
|
||||||
|
partId: string,
|
||||||
|
quantity: number,
|
||||||
|
workOrderId: string,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<{ designation: string; unitPrice: number }> {
|
||||||
|
const part = await this.prisma.part.findUnique({ where: { id: partId } });
|
||||||
|
if (!part || !part.isActive) throw new BadRequestException('Pièce inconnue ou désactivée');
|
||||||
|
if (part.lastUnitPrice === null) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Aucun prix connu pour cette pièce — réceptionnez un BC ou renseignez un prix initial',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const somme = await tx.stockMovement.aggregate({
|
||||||
|
where: { partId },
|
||||||
|
_sum: { quantity: true },
|
||||||
|
});
|
||||||
|
const stock = somme._sum.quantity ?? 0;
|
||||||
|
if (stock < quantity) {
|
||||||
|
throw new ConflictException(`Stock insuffisant : ${stock} en stock, ${quantity} demandé`);
|
||||||
|
}
|
||||||
|
await tx.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId,
|
||||||
|
kind: 'CONSUMPTION',
|
||||||
|
quantity: -quantity,
|
||||||
|
unitPrice: part.lastUnitPrice,
|
||||||
|
workOrderId,
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { designation: part.designation, unitPrice: Number(part.lastUnitPrice) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertSupplier(supplierId: string): Promise<void> {
|
||||||
|
const supplier = await this.prisma.partner.findUnique({ where: { id: supplierId } });
|
||||||
|
if (!supplier || supplier.kind !== 'SUPPLIER') {
|
||||||
|
throw new BadRequestException('Fournisseur inconnu');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
|
SELECT nextval('part_ref_seq')`;
|
||||||
|
return `P-${String(nextval).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: PartRow, stock: number): PartDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
designation: row.designation,
|
||||||
|
threshold: row.threshold,
|
||||||
|
lastUnitPrice: row.lastUnitPrice === null ? null : Number(row.lastUnitPrice),
|
||||||
|
compatible: row.compatible,
|
||||||
|
supplierId: row.supplierId,
|
||||||
|
supplierName: row.supplier?.name ?? null,
|
||||||
|
isActive: row.isActive,
|
||||||
|
stock,
|
||||||
|
belowThreshold: stock < row.threshold,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toMovementDto(m: MvtRow): StockMovementDto {
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
kind: m.kind,
|
||||||
|
quantity: m.quantity,
|
||||||
|
unitPrice: m.unitPrice === null ? null : Number(m.unitPrice),
|
||||||
|
reason: m.reason,
|
||||||
|
workOrderReference: m.workOrder?.reference ?? null,
|
||||||
|
purchaseOrderReference: m.purchaseOrder?.reference ?? null,
|
||||||
|
byName: m.by?.displayName ?? null,
|
||||||
|
createdAt: m.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal file
59
apps/api/src/purchase-orders/purchase-orders.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
PurchaseOrderCreateSchema,
|
||||||
|
PurchaseOrderTransitionSchema,
|
||||||
|
type PurchaseOrderCreate,
|
||||||
|
type PurchaseOrderTransition,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { PurchaseOrdersService } from './purchase-orders.service';
|
||||||
|
|
||||||
|
@Controller('purchase-orders')
|
||||||
|
export class PurchaseOrdersController {
|
||||||
|
constructor(private readonly purchaseOrders: PurchaseOrdersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.purchaseOrders.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'view')
|
||||||
|
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.purchaseOrders.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'create')
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(PurchaseOrderCreateSchema)) body: PurchaseOrderCreate,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.purchaseOrders.create(body, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/transition')
|
||||||
|
@HttpCode(200) // le contrat : 200, l'état change
|
||||||
|
@RequirePermission('PURCHASE_ORDERS', 'edit')
|
||||||
|
transition(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(PurchaseOrderTransitionSchema)) body: PurchaseOrderTransition,
|
||||||
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
) {
|
||||||
|
return this.purchaseOrders.transition(id, body, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal file
9
apps/api/src/purchase-orders/purchase-orders.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PurchaseOrdersController } from './purchase-orders.controller';
|
||||||
|
import { PurchaseOrdersService } from './purchase-orders.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PurchaseOrdersController],
|
||||||
|
providers: [PurchaseOrdersService],
|
||||||
|
})
|
||||||
|
export class PurchaseOrdersModule {}
|
||||||
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal file
170
apps/api/src/purchase-orders/purchase-orders.service.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
PurchaseOrderCreate,
|
||||||
|
PurchaseOrderDto,
|
||||||
|
PurchaseOrdersResponse,
|
||||||
|
PurchaseOrderStatus,
|
||||||
|
PurchaseOrderTransition,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const poInclude = {
|
||||||
|
supplier: true,
|
||||||
|
lines: { include: { part: true } },
|
||||||
|
} satisfies Prisma.PurchaseOrderInclude;
|
||||||
|
|
||||||
|
type Row = Prisma.PurchaseOrderGetPayload<{ include: typeof poInclude }>;
|
||||||
|
|
||||||
|
/** Machine à états du BC : la réception est le seul chemin vers le stock. */
|
||||||
|
const TRANSITIONS: Record<PurchaseOrderStatus, PurchaseOrderStatus[]> = {
|
||||||
|
DRAFT: ['SENT', 'CANCELLED'],
|
||||||
|
SENT: ['RECEIVED', 'CANCELLED'],
|
||||||
|
RECEIVED: [],
|
||||||
|
CANCELLED: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PurchaseOrdersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<PurchaseOrdersResponse> {
|
||||||
|
const rows = await this.prisma.purchaseOrder.findMany({
|
||||||
|
include: poInclude,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
return { purchaseOrders: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<PurchaseOrderDto> {
|
||||||
|
const row = await this.prisma.purchaseOrder.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: poInclude,
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('BC inconnu');
|
||||||
|
return this.toDto(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: PurchaseOrderCreate, user: AuthenticatedUser): Promise<PurchaseOrderDto> {
|
||||||
|
const supplier = await this.prisma.partner.findUnique({
|
||||||
|
where: { id: dto.supplierId },
|
||||||
|
});
|
||||||
|
if (!supplier || supplier.kind !== 'SUPPLIER' || !supplier.isActive) {
|
||||||
|
throw new BadRequestException('Fournisseur inconnu ou inactif');
|
||||||
|
}
|
||||||
|
const parts = await this.prisma.part.findMany({
|
||||||
|
where: { id: { in: dto.lines.map((l) => l.partId) } },
|
||||||
|
});
|
||||||
|
if (parts.length !== new Set(dto.lines.map((l) => l.partId)).size) {
|
||||||
|
throw new BadRequestException('Pièce inconnue dans les lignes');
|
||||||
|
}
|
||||||
|
for (let essai = 0; ; essai++) {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.purchaseOrder.create({
|
||||||
|
data: {
|
||||||
|
reference: await this.nextReference(),
|
||||||
|
supplierId: dto.supplierId,
|
||||||
|
createdById: user.userId,
|
||||||
|
lines: { create: dto.lines },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return this.get(created.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002' &&
|
||||||
|
essai < 3
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async transition(
|
||||||
|
id: string,
|
||||||
|
dto: PurchaseOrderTransition,
|
||||||
|
user: AuthenticatedUser,
|
||||||
|
): Promise<PurchaseOrderDto> {
|
||||||
|
const row = await this.prisma.purchaseOrder.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: poInclude,
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('BC inconnu');
|
||||||
|
if (!TRANSITIONS[row.status].includes(dto.to)) {
|
||||||
|
throw new ConflictException(`Transition interdite : ${row.status} → ${dto.to}`);
|
||||||
|
}
|
||||||
|
if (dto.to === 'RECEIVED') {
|
||||||
|
// LA règle : la réception crée les entrées de stock et FIGE les PU
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
for (const line of row.lines) {
|
||||||
|
await tx.stockMovement.create({
|
||||||
|
data: {
|
||||||
|
partId: line.partId,
|
||||||
|
kind: 'RECEIPT',
|
||||||
|
quantity: line.quantity,
|
||||||
|
unitPrice: line.unitPrice,
|
||||||
|
purchaseOrderId: row.id,
|
||||||
|
byId: user.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.part.update({
|
||||||
|
where: { id: line.partId },
|
||||||
|
data: { lastUnitPrice: line.unitPrice },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.purchaseOrder.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: 'RECEIVED', receivedAt: new Date() },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.prisma.purchaseOrder.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: dto.to,
|
||||||
|
sentAt: dto.to === 'SENT' ? new Date() : undefined,
|
||||||
|
cancelledAt: dto.to === 'CANCELLED' ? new Date() : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextReference(): Promise<string> {
|
||||||
|
const [{ nextval }] = await this.prisma.$queryRaw<[{ nextval: bigint }]>`
|
||||||
|
SELECT nextval('purchase_order_ref_seq')`;
|
||||||
|
return `BC-${new Date().getFullYear()}-${String(nextval).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: Row): PurchaseOrderDto {
|
||||||
|
const lines = row.lines.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
partId: l.partId,
|
||||||
|
partReference: l.part.reference,
|
||||||
|
designation: l.part.designation,
|
||||||
|
quantity: l.quantity,
|
||||||
|
unitPrice: Number(l.unitPrice),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
status: row.status,
|
||||||
|
supplierId: row.supplierId,
|
||||||
|
supplierName: row.supplier.name,
|
||||||
|
lines,
|
||||||
|
total: lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0),
|
||||||
|
sentAt: row.sentAt?.toISOString() ?? null,
|
||||||
|
receivedAt: row.receivedAt?.toISOString() ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
15
apps/api/src/search/search.controller.ts
Normal file
15
apps/api/src/search/search.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import { SearchService } from './search.service';
|
||||||
|
|
||||||
|
/** Authentification seule (pas de @RequirePermission) : chaque famille de
|
||||||
|
* résultats est filtrée par la matrice DANS le service. */
|
||||||
|
@Controller('search')
|
||||||
|
export class SearchController {
|
||||||
|
constructor(private readonly search: SearchService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
global(@CurrentUser() user: AuthenticatedUser, @Query('q') q = '') {
|
||||||
|
return this.search.search(user, q);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/search/search.module.ts
Normal file
9
apps/api/src/search/search.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SearchController } from './search.controller';
|
||||||
|
import { SearchService } from './search.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SearchController],
|
||||||
|
providers: [SearchService],
|
||||||
|
})
|
||||||
|
export class SearchModule {}
|
||||||
105
apps/api/src/search/search.service.ts
Normal file
105
apps/api/src/search/search.service.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import { SEARCH_MAX_PER_KIND, SEARCH_MIN_CHARS, type SearchResponse } from '@siop/shared';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
/** Recherche globale de la topbar (⌘K). Chaque famille n'est interrogée que
|
||||||
|
* si le rôle a la permission `view` du domaine ; les OT respectent en plus
|
||||||
|
* l'invariant « voir autre » (même règle que la liste des OT). */
|
||||||
|
@Injectable()
|
||||||
|
export class SearchService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async search(user: AuthenticatedUser, q: string): Promise<SearchResponse> {
|
||||||
|
const terme = q.trim();
|
||||||
|
const vide: SearchResponse = { workOrders: [], assets: [], sites: [] };
|
||||||
|
if (terme.length < SEARCH_MIN_CHARS) return vide;
|
||||||
|
|
||||||
|
const [voitOT, voitParc, voitSites] = await Promise.all([
|
||||||
|
this.permissions.can(user.roleId, 'WORK_ORDERS', 'view'),
|
||||||
|
this.permissions.can(user.roleId, 'ASSETS', 'view'),
|
||||||
|
this.permissions.can(user.roleId, 'LOCATIONS', 'view'),
|
||||||
|
]);
|
||||||
|
const contient = (champ: string): Prisma.StringFilter => ({
|
||||||
|
contains: champ,
|
||||||
|
mode: 'insensitive',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [workOrders, assets, sites] = await Promise.all([
|
||||||
|
voitOT
|
||||||
|
? this.prisma.workOrder.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ OR: [{ reference: contient(terme) }, { title: contient(terme) }] },
|
||||||
|
await this.scopeOT(user),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, reference: true, title: true, status: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: SEARCH_MAX_PER_KIND,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
voitParc
|
||||||
|
? this.prisma.asset.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ reference: contient(terme) },
|
||||||
|
{ brand: contient(terme) },
|
||||||
|
{ model: contient(terme) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
reference: true,
|
||||||
|
brand: true,
|
||||||
|
model: true,
|
||||||
|
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
orderBy: { reference: 'asc' },
|
||||||
|
take: SEARCH_MAX_PER_KIND,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
voitSites
|
||||||
|
? this.prisma.location.findMany({
|
||||||
|
where: {
|
||||||
|
parentId: null,
|
||||||
|
OR: [{ name: contient(terme) }, { city: contient(terme) }],
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, city: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
take: SEARCH_MAX_PER_KIND,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workOrders,
|
||||||
|
assets: assets.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
reference: a.reference,
|
||||||
|
brand: a.brand,
|
||||||
|
model: a.model,
|
||||||
|
siteName: a.location.parent?.name ?? a.location.name,
|
||||||
|
})),
|
||||||
|
sites,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Même invariant que WorkOrdersService.scope — sans le droit « voir
|
||||||
|
* autre », on ne trouve que SES OT. */
|
||||||
|
private async scopeOT(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
|
||||||
|
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
|
||||||
|
if (viewOther) return {};
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ assignees: { some: { id: user.userId } } },
|
||||||
|
{ createdById: user.userId },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,7 @@ export class UsersService {
|
|||||||
phone: dto.phone,
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
141
apps/api/test/documents-analytics.e2e-spec.ts
Normal file
141
apps/api/test/documents-analytics.e2e-spec.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* E2E R3.2 — bibliothèque (upload MinIO via FileStorage, download streamé,
|
||||||
|
* refus typés, permission sur la cible) + analytics (dérivé du réel).
|
||||||
|
* Nécessite MinIO (infra locale / service CI).
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
const PNG_1PX = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('Bibliothèque & analytics (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let nadia: string;
|
||||||
|
let karim: string; // Demandeur : aucune permission d'édition
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
const suffix = `doc-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken as string;
|
||||||
|
};
|
||||||
|
nadia = await login('Gestionnaire');
|
||||||
|
karim = await login('Demandeur');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.document.deleteMany({ where: { fileName: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upload → liste filtrée → download identique → suppression', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
|
||||||
|
const envoye = await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.field('kind', 'CERTIFICATE')
|
||||||
|
.field('assetId', a1.id)
|
||||||
|
.attach('file', PNG_1PX, { filename: `certificat-${suffix}.png`, contentType: 'image/png' })
|
||||||
|
.expect(201);
|
||||||
|
expect(envoye.body.assetReference).toBe('A1');
|
||||||
|
expect(envoye.body.size).toBe(PNG_1PX.length);
|
||||||
|
|
||||||
|
const liste = await http()
|
||||||
|
.get(`/documents?assetId=${a1.id}&kind=CERTIFICATE`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.expect(200);
|
||||||
|
expect(
|
||||||
|
liste.body.documents.some((d: { fileName: string }) => d.fileName.includes(suffix)),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const telecharge = await http()
|
||||||
|
.get(`/documents/${envoye.body.id}/download`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.buffer(true)
|
||||||
|
.parse((res, cb) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (c) => chunks.push(c));
|
||||||
|
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
expect(Buffer.compare(telecharge.body as Buffer, PNG_1PX)).toBe(0); // octets identiques
|
||||||
|
|
||||||
|
await http().delete(`/documents/${envoye.body.id}`).set(auth(nadia)).expect(204);
|
||||||
|
await http().get(`/documents/${envoye.body.id}/download`).set(auth(nadia)).expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refus typés : format, rattachement manquant, cible inconnue, permission', async () => {
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(nadia));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.field('kind', 'OTHER')
|
||||||
|
.field('assetId', a1.id)
|
||||||
|
.attach('file', Buffer.from('binaire'), { filename: `x-${suffix}.exe`, contentType: 'application/octet-stream' })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.field('kind', 'PHOTO')
|
||||||
|
.attach('file', PNG_1PX, { filename: `orphelin-${suffix}.png`, contentType: 'image/png' })
|
||||||
|
.expect(400); // aucun rattachement
|
||||||
|
await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.field('kind', 'PHOTO')
|
||||||
|
.field('assetId', '00000000-0000-4000-8000-000000000000')
|
||||||
|
.attach('file', PNG_1PX, { filename: `fantome-${suffix}.png`, contentType: 'image/png' })
|
||||||
|
.expect(400);
|
||||||
|
// Karim (Demandeur) : lecture de la bibliothèque OK, édition refusée
|
||||||
|
await http().get('/documents').set(auth(karim)).expect(200);
|
||||||
|
await http()
|
||||||
|
.post('/documents')
|
||||||
|
.set(auth(karim))
|
||||||
|
.field('kind', 'PHOTO')
|
||||||
|
.field('assetId', a1.id)
|
||||||
|
.attach('file', PNG_1PX, { filename: `interdit-${suffix}.png`, contentType: 'image/png' })
|
||||||
|
.expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('analytics : le tableau de la direction est dérivé du réel (bilans, coûts figés)', async () => {
|
||||||
|
const res = await http().get('/analytics/summary').set(auth(nadia)).expect(200);
|
||||||
|
const s = res.body;
|
||||||
|
expect(s.costsByMonth).toHaveLength(12); // période par défaut
|
||||||
|
// Le seed a consommé 325 MAD de pièces + 180 de MO sur OT-0341 ce mois-ci
|
||||||
|
expect(s.monthCost).toBeGreaterThanOrEqual(505);
|
||||||
|
// Pannes par organe : OT-0332 (seed) a un bilan « Guides »
|
||||||
|
expect(
|
||||||
|
s.failuresByComponent.some((f: { label: string }) => f.label === 'Guides'),
|
||||||
|
).toBe(true);
|
||||||
|
// Top équipements : A1 porte les coûts du seed
|
||||||
|
expect(s.topAssets.some((t: { reference: string }) => t.reference === 'A1')).toBe(true);
|
||||||
|
expect(s.closed.total).toBeGreaterThanOrEqual(1);
|
||||||
|
// Karim (Demandeur, sans ANALYTICS) → 403
|
||||||
|
await http().get('/analytics/summary').set(auth(karim)).expect(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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()
|
||||||
|
|||||||
351
apps/api/test/gestion.e2e-spec.ts
Normal file
351
apps/api/test/gestion.e2e-spec.ts
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
/**
|
||||||
|
* E2E R3 — gestion : la recette officielle (consommer sous seuil → BC →
|
||||||
|
* réception → réappro ; coût complet d'un OT) et les invariants comptables
|
||||||
|
* (stock dérivé jamais négatif, prix et taux FIGÉS, ajustement motivé).
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Gestion (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let nadia: string; // Gestionnaire
|
||||||
|
let salma: string; // Dispatcher
|
||||||
|
let ahmed: string; // Technicien (PARTS view seulement)
|
||||||
|
let admin: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
const suffix = `G3-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken as string;
|
||||||
|
};
|
||||||
|
nadia = await login('Gestionnaire');
|
||||||
|
salma = await login('Dispatcher');
|
||||||
|
ahmed = await login('Technicien');
|
||||||
|
admin = await login('Administrateur');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.stockMovement.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ part: { designation: { contains: suffix } } },
|
||||||
|
{ workOrder: { title: { contains: suffix } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||||
|
await prisma.purchaseOrder.deleteMany({
|
||||||
|
where: { lines: { some: { part: { designation: { contains: suffix } } } } },
|
||||||
|
});
|
||||||
|
await prisma.part.deleteMany({ where: { designation: { contains: suffix } } });
|
||||||
|
await prisma.partner.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recette R3 : consommer sous seuil → BC → réception → réappro (prix figés)', async () => {
|
||||||
|
// Pièce dédiée au test : stock 6, seuil 4, PU initial 100
|
||||||
|
const piece = await http()
|
||||||
|
.post('/parts')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ designation: `Pièce ${suffix}`, threshold: 4, initialPrice: 100 })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post(`/parts/${piece.body.id}/movements`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ kind: 'ENTRY', quantity: 6 })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// OT + consommation de 3 → stock 3, SOUS le seuil
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const ot = await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({
|
||||||
|
title: `Réparation ${suffix}`,
|
||||||
|
type: 'CORRECTIVE',
|
||||||
|
assetId: assets.assets[0].id,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
const apresConso = await http()
|
||||||
|
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ partId: piece.body.id, quantity: 3 })
|
||||||
|
.expect(201);
|
||||||
|
expect(apresConso.body.costs.parts[0]).toMatchObject({
|
||||||
|
quantity: 3,
|
||||||
|
unitPrice: 100,
|
||||||
|
total: 300,
|
||||||
|
});
|
||||||
|
const sousSeuil = await http().get(`/parts/${piece.body.id}`).set(auth(nadia));
|
||||||
|
expect(sousSeuil.body.stock).toBe(3);
|
||||||
|
expect(sousSeuil.body.belowThreshold).toBe(true);
|
||||||
|
|
||||||
|
// BC : brouillon → envoyé → reçu (PU 110) → stock 13, plus d'alerte
|
||||||
|
const { body: partners } = await http().get('/partners').set(auth(nadia));
|
||||||
|
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
|
||||||
|
const bc = await http()
|
||||||
|
.post('/purchase-orders')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({
|
||||||
|
supplierId: fournisseur.id,
|
||||||
|
lines: [{ partId: piece.body.id, quantity: 10, unitPrice: 110 }],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect(bc.body.status).toBe('DRAFT');
|
||||||
|
expect(bc.body.total).toBe(1100);
|
||||||
|
await http()
|
||||||
|
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ to: 'SENT' })
|
||||||
|
.expect(200);
|
||||||
|
const recu = await http()
|
||||||
|
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ to: 'RECEIVED' })
|
||||||
|
.expect(200);
|
||||||
|
expect(recu.body.receivedAt).toBeTruthy();
|
||||||
|
|
||||||
|
const apresReception = await http().get(`/parts/${piece.body.id}`).set(auth(nadia));
|
||||||
|
expect(apresReception.body.stock).toBe(13);
|
||||||
|
expect(apresReception.body.belowThreshold).toBe(false);
|
||||||
|
expect(apresReception.body.lastUnitPrice).toBe(110); // nouveau prix courant
|
||||||
|
expect(
|
||||||
|
apresReception.body.movements.some(
|
||||||
|
(m: { kind: string; purchaseOrderReference: string | null }) =>
|
||||||
|
m.kind === 'RECEIPT' && m.purchaseOrderReference === bc.body.reference,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
// PRIX FIGÉ : la consommation passée reste à 100, malgré le nouveau PU 110
|
||||||
|
const otRelu = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma));
|
||||||
|
expect(otRelu.body.costs.parts[0].unitPrice).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coût complet d’un OT : pièces + main-d’œuvre — le TAUX est figé à la saisie', async () => {
|
||||||
|
const { body: users } = await http().get('/users').set(auth(admin));
|
||||||
|
const ahmedUser = users.users.find(
|
||||||
|
(u: { email: string }) => u.email === 'technicien@demo.siop.ma',
|
||||||
|
);
|
||||||
|
const tauxInitial = ahmedUser.hourlyRate; // 120 (seed)
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const ot = await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ title: `Coût complet ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// Salma (sans taux défini) tente de saisir pour elle-même → refus motivé
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${ot.body.id}/labor`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ minutes: 30 })
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
// 1 h 30 d'Ahmed au taux courant
|
||||||
|
const avecMO = await http()
|
||||||
|
.post(`/work-orders/${ot.body.id}/labor`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ minutes: 90, userId: ahmedUser.id })
|
||||||
|
.expect(201);
|
||||||
|
expect(avecMO.body.costs.labor[0].hourlyRate).toBe(tauxInitial);
|
||||||
|
expect(avecMO.body.costs.labor[0].total).toBe(
|
||||||
|
Math.round((90 / 60) * tauxInitial * 100) / 100,
|
||||||
|
);
|
||||||
|
const totalAvant = avecMO.body.costs.total;
|
||||||
|
|
||||||
|
// L'admin augmente le taux courant d'Ahmed → le coût de l'OT NE BOUGE PAS
|
||||||
|
await http()
|
||||||
|
.patch(`/users/${ahmedUser.id}`)
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ hourlyRate: tauxInitial + 30 })
|
||||||
|
.expect(200);
|
||||||
|
const otApres = await http().get(`/work-orders/${ot.body.id}`).set(auth(salma));
|
||||||
|
expect(otApres.body.costs.total).toBe(totalAvant);
|
||||||
|
// remise en l'état (le seed ne réécrit pas les taux existants)
|
||||||
|
await http()
|
||||||
|
.patch(`/users/${ahmedUser.id}`)
|
||||||
|
.set(auth(admin))
|
||||||
|
.send({ hourlyRate: tauxInitial })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invariants : motif requis, stock jamais négatif, prix inconnu refusé, coûts figés après clôture', async () => {
|
||||||
|
const piece = await http()
|
||||||
|
.post('/parts')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ designation: `Sans prix ${suffix}`, threshold: 0 })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
// ajustement sans motif → 400 ; stock négatif → 409
|
||||||
|
await http()
|
||||||
|
.post(`/parts/${piece.body.id}/movements`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ kind: 'ADJUSTMENT', quantity: -1 })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.post(`/parts/${piece.body.id}/movements`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ kind: 'ADJUSTMENT', quantity: -1, reason: 'test' })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.post(`/parts/${piece.body.id}/movements`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ kind: 'ENTRY', quantity: -3 })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
// consommation sans prix connu → 409 ; stock insuffisant → 409
|
||||||
|
await http()
|
||||||
|
.post(`/parts/${piece.body.id}/movements`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ kind: 'ENTRY', quantity: 5 })
|
||||||
|
.expect(201);
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const ot = await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ title: `Invariants ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ partId: piece.body.id, quantity: 2 })
|
||||||
|
.expect(409); // pas de prix connu
|
||||||
|
// Technicien : PARTS en lecture seule
|
||||||
|
await http()
|
||||||
|
.post('/parts')
|
||||||
|
.set(auth(ahmed))
|
||||||
|
.send({ designation: `Interdit ${suffix}` })
|
||||||
|
.expect(403);
|
||||||
|
// BC : réception d'un brouillon interdite
|
||||||
|
const { body: partners } = await http().get('/partners').set(auth(nadia));
|
||||||
|
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
|
||||||
|
const bc = await http()
|
||||||
|
.post('/purchase-orders')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ supplierId: fournisseur.id, lines: [{ partId: piece.body.id, quantity: 1, unitPrice: 10 }] })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ to: 'RECEIVED' })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.post(`/purchase-orders/${bc.body.id}/transition`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ to: 'CANCELLED' })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refus propres : doublons, inconnus, transitions interdites, coûts figés après clôture', async () => {
|
||||||
|
const GHOST = '00000000-0000-4000-8000-000000000000';
|
||||||
|
// tiers : doublon 409, 404, désactivation
|
||||||
|
const tiers = await http()
|
||||||
|
.post('/partners')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/partners')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ name: `Tiers ${suffix}`, kind: 'SUPPLIER' })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.patch(`/partners/${tiers.body.id}`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ isActive: false })
|
||||||
|
.expect(200);
|
||||||
|
await http().patch(`/partners/${GHOST}`).set(auth(nadia)).send({ city: 'X' }).expect(404);
|
||||||
|
// pièce : fournisseur inconnu 400, 404, désactivée non consommable
|
||||||
|
await http()
|
||||||
|
.post('/parts')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ designation: `Fournisseur fantôme ${suffix}`, supplierId: GHOST })
|
||||||
|
.expect(400);
|
||||||
|
await http().patch(`/parts/${GHOST}`).set(auth(nadia)).send({ threshold: 1 }).expect(404);
|
||||||
|
await http().get(`/parts/${GHOST}`).set(auth(nadia)).expect(404);
|
||||||
|
const desactivee = await http()
|
||||||
|
.post('/parts')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ designation: `Désactivée ${suffix}`, initialPrice: 10 })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.patch(`/parts/${desactivee.body.id}`)
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ isActive: false })
|
||||||
|
.expect(200);
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const ot = await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ title: `Refus ${suffix}`, type: 'CORRECTIVE', assetId: assets.assets[0].id })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ partId: desactivee.body.id, quantity: 1 })
|
||||||
|
.expect(400);
|
||||||
|
// BC : fournisseur inactif 400, pièce inconnue 400, 404, transition sur terminal 409
|
||||||
|
await http()
|
||||||
|
.post('/purchase-orders')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ supplierId: tiers.body.id, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] })
|
||||||
|
.expect(400); // désactivé plus haut
|
||||||
|
await http()
|
||||||
|
.post('/purchase-orders')
|
||||||
|
.set(auth(nadia))
|
||||||
|
.send({ supplierId: GHOST, lines: [{ partId: desactivee.body.id, quantity: 1, unitPrice: 5 }] })
|
||||||
|
.expect(400);
|
||||||
|
await http().get(`/purchase-orders/${GHOST}`).set(auth(nadia)).expect(404);
|
||||||
|
// main-d'œuvre : personne inconnue 400 ; OT annulé → coûts figés 409
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${ot.body.id}/labor`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ minutes: 10, userId: GHOST })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${ot.body.id}/transition`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ to: 'CANCELLED', comment: 'test' })
|
||||||
|
.expect(200);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${ot.body.id}/consume-part`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ partId: desactivee.body.id, quantity: 1 })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.post(`/work-orders/${ot.body.id}/labor`)
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({ minutes: 10 })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le seed rejoue la carte maquette : OT-0341 coûte 505 MAD', async () => {
|
||||||
|
const annee = new Date().getFullYear();
|
||||||
|
const { body } = await http().get('/work-orders').set(auth(salma));
|
||||||
|
const ot341 = body.workOrders.find(
|
||||||
|
(w: { reference: string }) => w.reference === `OT-${annee}-0341`,
|
||||||
|
);
|
||||||
|
const detail = await http().get(`/work-orders/${ot341.id}`).set(auth(salma)).expect(200);
|
||||||
|
expect(detail.body.costs.total).toBe(505); // 240 + 85 + 180
|
||||||
|
expect(detail.body.costs.labor[0].hourlyRate).toBe(120);
|
||||||
|
});
|
||||||
|
});
|
||||||
141
apps/api/test/recherche.e2e-spec.ts
Normal file
141
apps/api/test/recherche.e2e-spec.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* E2E R3 (recette) — recherche globale : chaque famille de résultats est
|
||||||
|
* filtrée par la matrice, les OT respectent « voir autre » ; au passage,
|
||||||
|
* rattachements Tiers (siteNames) et période analytics paramétrable.
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Recherche globale & corrections de recette R3 (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let yasmine: string; // Administrateur
|
||||||
|
let salma: string; // Dispatcher (voir autre)
|
||||||
|
let youssef: string; // Technicien limité : SES OT, ASSETS view, pas de LOCATIONS
|
||||||
|
let karim: string; // Demandeur : aucune des trois familles
|
||||||
|
let otId: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||||
|
const suffix = `rech-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken as string;
|
||||||
|
};
|
||||||
|
yasmine = await login('Administrateur');
|
||||||
|
salma = await login('Dispatcher');
|
||||||
|
youssef = await login('Technicien limité');
|
||||||
|
karim = await login('Demandeur');
|
||||||
|
|
||||||
|
// Un OT au titre unique, assigné à Ahmed (PAS à Youssef).
|
||||||
|
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||||
|
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
const ahmed = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
|
||||||
|
const cree = await http()
|
||||||
|
.post('/work-orders')
|
||||||
|
.set(auth(salma))
|
||||||
|
.send({
|
||||||
|
title: `Recherche E2E ${suffix}`,
|
||||||
|
type: 'CORRECTIVE',
|
||||||
|
priority: 'LOW',
|
||||||
|
assetId: a1.id,
|
||||||
|
assigneeIds: [ahmed.id],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
otId = cree.body.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatcher (voir autre) : OT par titre, appareil par référence, site insensible à la casse', async () => {
|
||||||
|
const parTitre = await http().get(`/search?q=${suffix}`).set(auth(salma)).expect(200);
|
||||||
|
expect(parTitre.body.workOrders.map((w: { id: string }) => w.id)).toContain(otId);
|
||||||
|
|
||||||
|
const parRef = await http().get('/search?q=A1').set(auth(salma)).expect(200);
|
||||||
|
expect(parRef.body.assets.some((a: { reference: string }) => a.reference === 'A1')).toBe(true);
|
||||||
|
expect(parRef.body.assets[0].siteName).toBeTruthy();
|
||||||
|
|
||||||
|
const parSite = await http().get('/search?q=atlas').set(auth(salma)).expect(200);
|
||||||
|
expect(parSite.body.sites.some((s: { name: string }) => s.name === 'Tour Atlas')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('technicien limité : ne trouve pas l’OT d’un autre, ni les sites ; le parc oui', async () => {
|
||||||
|
const res = await http().get(`/search?q=${suffix}`).set(auth(youssef)).expect(200);
|
||||||
|
expect(res.body.workOrders).toHaveLength(0); // pas « voir autre »
|
||||||
|
|
||||||
|
const parc = await http().get('/search?q=A1').set(auth(youssef)).expect(200);
|
||||||
|
expect(parc.body.assets.length).toBeGreaterThan(0); // ASSETS view
|
||||||
|
const sites = await http().get('/search?q=atlas').set(auth(youssef)).expect(200);
|
||||||
|
expect(sites.body.sites).toHaveLength(0); // pas de LOCATIONS view
|
||||||
|
});
|
||||||
|
|
||||||
|
it('demandeur : 200 mais aucune famille (aucune permission view)', async () => {
|
||||||
|
const res = await http().get('/search?q=atlas').set(auth(karim)).expect(200);
|
||||||
|
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moins de 2 caractères : réponse vide, pas de requête inutile', async () => {
|
||||||
|
const res = await http().get('/search?q=a').set(auth(salma)).expect(200);
|
||||||
|
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sans jeton : 401 (API fermée par défaut)', async () => {
|
||||||
|
await http().get('/search?q=atlas').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tiers : les syndics portent leurs sites (« Rattachements » de la maquette)', async () => {
|
||||||
|
const { body } = await http().get('/partners').set(auth(yasmine)).expect(200);
|
||||||
|
const atlas = body.partners.find(
|
||||||
|
(p: { name: string }) => p.name === 'Atlas Property Management',
|
||||||
|
);
|
||||||
|
expect(atlas.siteNames).toEqual(['Tour Atlas']);
|
||||||
|
const fournisseur = body.partners.find((p: { name: string }) => p.name === 'Lubmaroc');
|
||||||
|
expect(fournisseur.siteNames).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('analytics : période 3/6/12 mois, 12 par défaut, valeur inconnue repliée sur 12', async () => {
|
||||||
|
const trois = await http().get('/analytics/summary?months=3').set(auth(yasmine)).expect(200);
|
||||||
|
expect(trois.body.months).toBe(3);
|
||||||
|
expect(trois.body.costsByMonth).toHaveLength(3);
|
||||||
|
|
||||||
|
const defaut = await http().get('/analytics/summary').set(auth(yasmine)).expect(200);
|
||||||
|
expect(defaut.body.months).toBe(12);
|
||||||
|
expect(defaut.body.costsByMonth).toHaveLength(12);
|
||||||
|
|
||||||
|
const inconnu = await http().get('/analytics/summary?months=7').set(auth(yasmine)).expect(200);
|
||||||
|
expect(inconnu.body.months).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sites : le rattachement n’accepte qu’un tiers Client / syndic', async () => {
|
||||||
|
const { body: partners } = await http().get('/partners').set(auth(yasmine));
|
||||||
|
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
|
||||||
|
const { body: locations } = await http().get('/locations').set(auth(yasmine));
|
||||||
|
const site = locations.locations.find(
|
||||||
|
(l: { parentId: string | null; name: string }) => !l.parentId && l.name === 'Anfa Place',
|
||||||
|
);
|
||||||
|
await http()
|
||||||
|
.patch(`/locations/${site.id}`)
|
||||||
|
.set(auth(yasmine))
|
||||||
|
.send({ partnerId: fournisseur.id })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
209
apps/web/e2e/parcours-r3.spec.ts
Normal file
209
apps/web/e2e/parcours-r3.spec.ts
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recette R3 (plan de releases) : pièce sous le seuil → BC prérempli →
|
||||||
|
* réception (le stock remonte, l'alerte s'éteint) ; coûts réels sur OT
|
||||||
|
* (prix figé + taux figé) ; document rattaché téléversé puis téléchargé ;
|
||||||
|
* statistiques lisibles par la Vue seule.
|
||||||
|
* Chaque test crée SES données (marquées E2E, purgées au global-setup).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
|
/** PNG 1×1 valide — assez pour tester upload/download réels via MinIO. */
|
||||||
|
const PNG_1PX = Buffer.from(
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
|
||||||
|
async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: nom }).click();
|
||||||
|
await expect(page.locator('.topbar')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changerDeCompte(page: import('@playwright/test').Page, nom: RegExp) {
|
||||||
|
await page.locator('.compte').click();
|
||||||
|
await page.getByRole('menuitem', { name: nom }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('recette R3 : tiers → pièce sous seuil → BC prérempli → réception → alerte éteinte', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const fournisseur = `E2E Fournisseur ${suffix}`;
|
||||||
|
const piece = `E2E Pièce ${suffix}`;
|
||||||
|
|
||||||
|
// 1 · Nadia (gestionnaire) crée le fournisseur
|
||||||
|
await connexionDemo(page, /Nadia Berrada/);
|
||||||
|
await page.getByRole('link', { name: 'Tiers' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouveau tiers' }).click();
|
||||||
|
await page.getByLabel('Nom *').fill(fournisseur);
|
||||||
|
await page.getByLabel('Type *').selectOption('SUPPLIER');
|
||||||
|
await page.getByRole('button', { name: 'Créer', exact: true }).click();
|
||||||
|
await expect(page.getByRole('cell', { name: fournisseur })).toBeVisible();
|
||||||
|
|
||||||
|
// 2 · Nouvelle pièce (seuil 5, prix initial 100) → fiche
|
||||||
|
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouvelle pièce' }).click();
|
||||||
|
await page.getByLabel('Désignation *').fill(piece);
|
||||||
|
await page.getByLabel("Seuil d'alerte").fill('5');
|
||||||
|
await page.getByLabel('Prix initial (MAD)').fill('100');
|
||||||
|
await page.getByLabel('Fournisseur', { exact: true }).selectOption({ label: fournisseur });
|
||||||
|
await page.getByRole('button', { name: 'Créer la pièce' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: piece })).toBeVisible();
|
||||||
|
|
||||||
|
// 3 · Entrée de 2 → stock 2 < seuil 5 : alerte
|
||||||
|
await page.getByRole('button', { name: 'Entrée de stock' }).click();
|
||||||
|
await page.getByLabel(/Quantité/).fill('2');
|
||||||
|
await page.getByRole('button', { name: 'Tracer le mouvement' }).click();
|
||||||
|
await expect(page.locator('.entete-page .stock-bas')).toHaveText('Sous le seuil');
|
||||||
|
await expect(page.locator('dd', { hasText: 'seuil : 5' })).toContainText('2');
|
||||||
|
|
||||||
|
// 4 · « Préparer le BC » → formulaire prérempli (fournisseur, qté 8, PU 100)
|
||||||
|
await page.getByRole('button', { name: 'Préparer le BC' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Nouveau bon de commande' })).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Fournisseur *')).toHaveValue(/.+/);
|
||||||
|
await expect(page.getByLabel('Quantité de la ligne 1')).toHaveValue('8'); // seuil − stock + 5
|
||||||
|
await expect(page.getByLabel('Prix unitaire de la ligne 1')).toHaveValue('100');
|
||||||
|
await page.getByRole('button', { name: 'Créer le BC (brouillon)' }).click();
|
||||||
|
|
||||||
|
// 5 · Envoyer puis réceptionner — le panneau suit le BC
|
||||||
|
const ligneBC = page.locator('tr', { hasText: fournisseur }).first();
|
||||||
|
await ligneBC.click();
|
||||||
|
await expect(page.locator('.panneau .st')).toHaveText('Brouillon');
|
||||||
|
await page.getByRole('button', { name: 'Envoyer au fournisseur' }).click();
|
||||||
|
await expect(page.locator('.panneau .st')).toHaveText('Envoyé');
|
||||||
|
await page.getByRole('button', { name: /Réceptionner/ }).click();
|
||||||
|
await expect(page.locator('.panneau .st')).toHaveText('Reçu');
|
||||||
|
|
||||||
|
// 6 · Le stock est remonté (2 + 8 = 10), l'alerte est éteinte
|
||||||
|
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||||
|
const lignePiece = page.locator('tr', { hasText: piece });
|
||||||
|
await expect(lignePiece.locator('td').nth(2)).toHaveText('10');
|
||||||
|
await expect(lignePiece.locator('.stock-bas')).toHaveCount(0);
|
||||||
|
|
||||||
|
// 7 · La fiche pièce raconte tout : entrée manuelle + réception à PU figé
|
||||||
|
await lignePiece.click();
|
||||||
|
await expect(page.locator('.mvt.entree')).toHaveCount(2);
|
||||||
|
await expect(page.getByText('100 MAD/u')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recette R3 : coûts réels sur OT (prix + taux figés) + document + statistiques', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const piece = `E2E PièceOT ${suffix}`;
|
||||||
|
const titreOT = `E2E ${suffix} — coûts`;
|
||||||
|
|
||||||
|
// 1 · Nadia crée une pièce avec du stock (10 à 100 MAD)
|
||||||
|
await connexionDemo(page, /Nadia Berrada/);
|
||||||
|
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouvelle pièce' }).click();
|
||||||
|
await page.getByLabel('Désignation *').fill(piece);
|
||||||
|
await page.getByLabel('Prix initial (MAD)').fill('100');
|
||||||
|
await page.getByRole('button', { name: 'Créer la pièce' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: piece })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Entrée de stock' }).click();
|
||||||
|
await page.getByLabel(/Quantité/).fill('10');
|
||||||
|
await page.getByRole('button', { name: 'Tracer le mouvement' }).click();
|
||||||
|
await expect(page.locator('dd', { hasText: 'seuil : 0' })).toContainText('10');
|
||||||
|
|
||||||
|
// 2 · Salma (dispatcher) crée l'OT et l'assigne à Ahmed
|
||||||
|
await changerDeCompte(page, /Salma Idrissi/);
|
||||||
|
await page.getByRole('link', { name: /Ordres de travail/ }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Nouvel OT' }).click();
|
||||||
|
await page.getByLabel('Objet *').fill(titreOT);
|
||||||
|
await page.getByLabel('Équipement *').selectOption({ index: 1 });
|
||||||
|
await page.getByLabel('Assigner à').selectOption({ label: 'Ahmed Benali' });
|
||||||
|
await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: titreOT })).toBeVisible();
|
||||||
|
|
||||||
|
// 3 · Ahmed (technicien) démarre, consomme 2 pièces, saisit 1 h 30
|
||||||
|
await changerDeCompte(page, /Ahmed Benali/);
|
||||||
|
await page.getByRole('link', { name: /Ordres de travail/ }).click();
|
||||||
|
await page.getByRole('cell', { name: titreOT }).click();
|
||||||
|
await page.getByRole('button', { name: 'Démarrer' }).click();
|
||||||
|
await expect(page.locator('.entete-page .st')).toHaveText('En cours');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '+ Consommer une pièce' }).click();
|
||||||
|
await page.getByLabel('Pièce *').selectOption({ label: `${piece} — stock : 10` });
|
||||||
|
await expect(page.getByText('Prix figé à la consommation')).toBeVisible();
|
||||||
|
await page.getByLabel('Quantité *').fill('2');
|
||||||
|
await page.getByRole('button', { name: /^Consommer/ }).click();
|
||||||
|
await expect(page.locator('.item-liste', { hasText: `${piece} × 2` })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: '+ Saisir du temps' }).click();
|
||||||
|
await page.getByLabel('Heures').fill('1');
|
||||||
|
await page.getByLabel('Minutes').fill('30');
|
||||||
|
await page.getByRole('button', { name: 'Enregistrer' }).click();
|
||||||
|
// 2 × 100 + 1,5 h × 120 MAD/h (taux seedé d'Ahmed) = 380 MAD
|
||||||
|
await expect(page.getByText('1 h 30 × 120 MAD/h')).toBeVisible();
|
||||||
|
await expect(page.locator('.total .num')).toHaveText('380 MAD');
|
||||||
|
|
||||||
|
// 4 · Ahmed joint une photo — puis la retélécharge (aller-retour MinIO réel)
|
||||||
|
await page.getByLabel('Choisir un fichier').setInputFiles({
|
||||||
|
name: `photo-e2e-${suffix}.png`,
|
||||||
|
mimeType: 'image/png',
|
||||||
|
buffer: PNG_1PX,
|
||||||
|
});
|
||||||
|
await expect(page.getByText(`photo-e2e-${suffix}.png`)).toBeVisible();
|
||||||
|
const attenteTelechargement = page.waitForEvent('download');
|
||||||
|
await page.getByRole('button', { name: 'Télécharger' }).click();
|
||||||
|
expect((await attenteTelechargement).suggestedFilename()).toBe(`photo-e2e-${suffix}.png`);
|
||||||
|
|
||||||
|
// 5 · Rachid (vue seule) lit les statistiques
|
||||||
|
await changerDeCompte(page, /Rachid Bennis/);
|
||||||
|
await page.getByRole('link', { name: 'Statistiques', exact: true }).click();
|
||||||
|
await expect(page.getByText('OT clôturés · 12 mois')).toBeVisible();
|
||||||
|
await expect(page.getByText('Pannes par organe · 12 mois')).toBeVisible();
|
||||||
|
await expect(page.getByText('Top équipements en coût · 12 mois')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recette R3 corrigée : recherche ⌘K, tri/filtre stock, période stats, rattachements, dépôt', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await connexionDemo(page, /Yasmine Alaoui/);
|
||||||
|
|
||||||
|
// Recherche globale : ⌘K → site (insensible à la casse), clic → fiche site
|
||||||
|
await page.keyboard.press('ControlOrMeta+k');
|
||||||
|
await page.keyboard.type('atlas');
|
||||||
|
await page.locator('.resultat', { hasText: 'Tour Atlas' }).first().click();
|
||||||
|
await expect(page).toHaveURL(/\/sites\//);
|
||||||
|
// le site porte son syndic (rattachement seedé, éditable par l'admin)
|
||||||
|
await expect(page.getByLabel('Client / syndic du site')).toHaveValue(/.+/);
|
||||||
|
|
||||||
|
// Recherche OT au clavier seul (Entrée sur le premier résultat)
|
||||||
|
await page.keyboard.press('ControlOrMeta+k');
|
||||||
|
await page.keyboard.type('bruit');
|
||||||
|
await page.locator('.resultat', { hasText: 'Bruit anormal' }).first().waitFor();
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(page).toHaveURL(/\/ot\//);
|
||||||
|
|
||||||
|
// Stock : les pièces sous le seuil remontent en tête, filtre fournisseur,
|
||||||
|
// « Entrée de stock » accessible depuis la liste (choix de la pièce)
|
||||||
|
await page.getByRole('link', { name: 'Stock & achats' }).click();
|
||||||
|
await expect(page.locator('tbody tr').first().locator('.stock-bas')).toHaveText(/Sous le seuil/);
|
||||||
|
await page.getByLabel('Filtrer par fournisseur').selectOption({ label: 'Lubmaroc' });
|
||||||
|
await expect(page.locator('tbody tr', { hasText: 'P-0042' })).toHaveCount(1);
|
||||||
|
await page.getByLabel('Filtrer par fournisseur').selectOption({ index: 0 });
|
||||||
|
await page.getByRole('button', { name: 'Entrée de stock' }).click();
|
||||||
|
await expect(page.getByLabel('Pièce *')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Annuler' }).click();
|
||||||
|
|
||||||
|
// Fiche pièce : le fournisseur est un lien vers les tiers
|
||||||
|
await page.getByRole('cell', { name: 'P-0113' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Ascentech Maroc' }).click();
|
||||||
|
await expect(page).toHaveURL(/\/tiers/);
|
||||||
|
// Tiers : les syndics affichent leurs sites
|
||||||
|
await expect(page.getByRole('cell', { name: 'Tour Atlas' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('cell', { name: 'Résidence Al Manar' })).toBeVisible();
|
||||||
|
|
||||||
|
// Statistiques : la période est un vrai sélecteur
|
||||||
|
await page.getByRole('link', { name: 'Statistiques', exact: true }).click();
|
||||||
|
await page.getByLabel('Période').selectOption('3');
|
||||||
|
await expect(page.getByText('OT clôturés · 3 mois')).toBeVisible();
|
||||||
|
|
||||||
|
// Bibliothèque : filtre « Rattaché à » + zone de glisser-déposer
|
||||||
|
await page.getByRole('link', { name: 'Fichiers' }).click();
|
||||||
|
await expect(page.getByLabel('Filtrer par appareil')).toBeVisible();
|
||||||
|
await expect(page.locator('.zone-depot')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -2,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
268
apps/web/src/api/gestion.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
ConsumePart,
|
||||||
|
LaborTimeCreate,
|
||||||
|
PartCreate,
|
||||||
|
PartnerCreate,
|
||||||
|
PartnerUpdate,
|
||||||
|
PartUpdate,
|
||||||
|
PurchaseOrderCreate,
|
||||||
|
PurchaseOrderTransition,
|
||||||
|
StockMovementCreate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { api, getToken } from './client';
|
||||||
|
|
||||||
|
/** Hooks R3 — client typé ; l'upload passe par FormData (multipart). */
|
||||||
|
|
||||||
|
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.error || res.data === undefined) {
|
||||||
|
const message =
|
||||||
|
(res.error as { message?: string } | undefined)?.message ??
|
||||||
|
`Le serveur a répondu ${res.response.status}`;
|
||||||
|
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidate(...keys: string[]) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return () =>
|
||||||
|
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Tiers —————
|
||||||
|
|
||||||
|
export function usePartners(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['partners'],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/partners'))).partners,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePartner() {
|
||||||
|
const invalidate = useInvalidate('partners');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: PartnerCreate) => unwrap(await api.POST('/partners', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePartner() {
|
||||||
|
const invalidate = useInvalidate('partners');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: PartnerUpdate & { id: string }) =>
|
||||||
|
unwrap(await api.PATCH('/partners/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Pièces & stock —————
|
||||||
|
|
||||||
|
export function useParts(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['parts'],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/parts'))).parts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePart(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['parts', id],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET('/parts/{id}', { params: { path: { id: id! } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePart() {
|
||||||
|
const invalidate = useInvalidate('parts');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: PartCreate) => unwrap(await api.POST('/parts', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdatePart(id: string) {
|
||||||
|
const invalidate = useInvalidate('parts');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: PartUpdate) =>
|
||||||
|
unwrap(await api.PATCH('/parts/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddStockMovement(id: string) {
|
||||||
|
const invalidate = useInvalidate('parts');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: StockMovementCreate) =>
|
||||||
|
unwrap(await api.POST('/parts/{id}/movements', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Bons de commande —————
|
||||||
|
|
||||||
|
export function usePurchaseOrders(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['purchase-orders'],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () =>
|
||||||
|
(await unwrap(await api.GET('/purchase-orders'))).purchaseOrders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePurchaseOrder() {
|
||||||
|
const invalidate = useInvalidate('purchase-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: PurchaseOrderCreate) =>
|
||||||
|
unwrap(await api.POST('/purchase-orders', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTransitionPurchaseOrder() {
|
||||||
|
const invalidate = useInvalidate('purchase-orders', 'parts');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: PurchaseOrderTransition & { id: string }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/purchase-orders/{id}/transition', { params: { path: { id } }, body }),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Coûts sur OT —————
|
||||||
|
|
||||||
|
export function useConsumePart(workOrderId: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders', 'parts');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: ConsumePart) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/work-orders/{id}/consume-part', {
|
||||||
|
params: { path: { id: workOrderId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddLaborTime(workOrderId: string) {
|
||||||
|
const invalidate = useInvalidate('work-orders');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: LaborTimeCreate) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/work-orders/{id}/labor', {
|
||||||
|
params: { path: { id: workOrderId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Bibliothèque —————
|
||||||
|
|
||||||
|
export function useDocuments(filtres: { assetId?: string; workOrderId?: string; kind?: string } = {}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['documents', filtres],
|
||||||
|
queryFn: async () =>
|
||||||
|
(
|
||||||
|
await unwrap(
|
||||||
|
await api.GET('/documents', { params: { query: filtres } }),
|
||||||
|
)
|
||||||
|
).documents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadDocument() {
|
||||||
|
const invalidate = useInvalidate('documents');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: {
|
||||||
|
file: File;
|
||||||
|
kind: string;
|
||||||
|
assetId?: string;
|
||||||
|
workOrderId?: string;
|
||||||
|
}) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', input.file);
|
||||||
|
form.append('kind', input.kind);
|
||||||
|
if (input.assetId) form.append('assetId', input.assetId);
|
||||||
|
if (input.workOrderId) form.append('workOrderId', input.workOrderId);
|
||||||
|
const res = await fetch('/api/documents', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const corps = (await res.json().catch(() => null)) as { message?: string } | null;
|
||||||
|
throw new Error(corps?.message ?? `Téléversement refusé (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteDocument() {
|
||||||
|
const invalidate = useInvalidate('documents');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
const res = await api.DELETE('/documents/{id}', { params: { path: { id } } });
|
||||||
|
if (res.response.status >= 400) throw new Error('Suppression impossible');
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Récupération authentifiée du fichier (un lien nu ne porte pas le jeton). */
|
||||||
|
export async function blobDocument(id: string): Promise<Blob> {
|
||||||
|
const res = await fetch(`/api/documents/${id}/download`, {
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Téléchargement impossible');
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function telechargerDocument(id: string, fileName: string): Promise<void> {
|
||||||
|
const blob = await blobDocument(id);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ouverture dans un nouvel onglet (PDF et images : le navigateur les affiche). */
|
||||||
|
export async function ouvrirDocument(id: string): Promise<void> {
|
||||||
|
// Onglet ouvert dans le geste utilisateur, sinon les bloqueurs de pop-up
|
||||||
|
// refusent un window.open survenant après le fetch.
|
||||||
|
const onglet = window.open('', '_blank');
|
||||||
|
try {
|
||||||
|
const blob = await blobDocument(id);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
if (onglet) onglet.location.href = url;
|
||||||
|
else window.open(url, '_blank');
|
||||||
|
} catch (e) {
|
||||||
|
onglet?.close();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— Analytics —————
|
||||||
|
|
||||||
|
export function useAnalyticsSummary(months: 3 | 6 | 12 = 12, enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['analytics', months],
|
||||||
|
enabled,
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(
|
||||||
|
await api.GET('/analytics/summary', {
|
||||||
|
params: { query: { months: String(months) } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
22
apps/web/src/api/recherche.ts
Normal file
22
apps/web/src/api/recherche.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { SEARCH_MIN_CHARS } from '@siop/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.data !== undefined) return res.data;
|
||||||
|
throw new Error(`Recherche indisponible (${res.response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recherche globale de la topbar (⌘K). L'API filtre chaque famille par la
|
||||||
|
* matrice — le client n'a aucune logique de permission à porter. */
|
||||||
|
export function useGlobalSearch(q: string) {
|
||||||
|
const terme = q.trim();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['search', terme],
|
||||||
|
enabled: terme.length >= SEARCH_MIN_CHARS,
|
||||||
|
staleTime: 30_000,
|
||||||
|
placeholderData: (precedent) => precedent, // pas de clignotement en tapant
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET('/search', { params: { query: { q: terme } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CategoryUpdate,
|
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({
|
||||||
|
|||||||
1334
apps/web/src/api/schema.d.ts
vendored
1334
apps/web/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,9 @@ export function useMe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** ADR-002 : la liste n'existe qu'en DEMO_MODE — un 404 signifie « pas de
|
/** 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;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
179
apps/web/src/components/carte-documents.tsx
Normal file
179
apps/web/src/components/carte-documents.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
DOCUMENT_KIND_LABELS,
|
||||||
|
DOCUMENT_KINDS,
|
||||||
|
DOCUMENT_MAX_BYTES,
|
||||||
|
type DocumentDto,
|
||||||
|
type DocumentKind,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import {
|
||||||
|
blobDocument,
|
||||||
|
ouvrirDocument,
|
||||||
|
telechargerDocument,
|
||||||
|
useDeleteDocument,
|
||||||
|
useDocuments,
|
||||||
|
useUploadDocument,
|
||||||
|
} from '@/api/gestion';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const CLASSE_TYPE: Partial<Record<DocumentKind, string>> = {
|
||||||
|
CERTIFICATE: 'type-doc certificat',
|
||||||
|
PHOTO: 'type-doc photo',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function tailleLisible(octets: number): string {
|
||||||
|
if (octets === 0) return '0 Ko';
|
||||||
|
if (octets >= 1024 * 1024) return `${(octets / (1024 * 1024)).toFixed(1).replace('.', ',')} Mo`;
|
||||||
|
return `${Math.max(1, Math.round(octets / 1024))} Ko`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aperçu réel des images : le fichier exige le jeton, donc pas de <img src>
|
||||||
|
* direct — on récupère le blob et on affiche une URL objet, révoquée au
|
||||||
|
* démontage. Les PDF gardent leur pictogramme (le navigateur les ouvre). */
|
||||||
|
function ApercuVignette({ doc }: { doc: DocumentDto }) {
|
||||||
|
const estImage = doc.contentType.startsWith('image/');
|
||||||
|
const [url, setUrl] = useState<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!estImage) return;
|
||||||
|
let actif = true;
|
||||||
|
let objet: string | undefined;
|
||||||
|
blobDocument(doc.id)
|
||||||
|
.then((blob) => {
|
||||||
|
if (!actif) return;
|
||||||
|
objet = URL.createObjectURL(blob);
|
||||||
|
setUrl(objet);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
actif = false;
|
||||||
|
if (objet) URL.revokeObjectURL(objet);
|
||||||
|
};
|
||||||
|
}, [doc.id, estImage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vignette"
|
||||||
|
title={`Ouvrir ${doc.fileName}`}
|
||||||
|
onClick={() => void ouvrirDocument(doc.id)}
|
||||||
|
>
|
||||||
|
{url ? <img src={url} alt={doc.fileName} /> : estImage ? '🖼' : '📄'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSuppression?: (id: string) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="doc">
|
||||||
|
<ApercuVignette doc={doc} />
|
||||||
|
<span className={CLASSE_TYPE[doc.kind] ?? 'type-doc'}>{DOCUMENT_KIND_LABELS[doc.kind]}</span>
|
||||||
|
<b>{doc.fileName}</b>
|
||||||
|
<span className="meta">
|
||||||
|
{doc.assetReference ? `Asc. ${doc.assetReference} · ` : ''}
|
||||||
|
{doc.workOrderReference ? `${doc.workOrderReference} · ` : ''}
|
||||||
|
{tailleLisible(doc.size)} ·{' '}
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(doc.createdAt))}
|
||||||
|
{doc.uploadedByName ? ` · ${doc.uploadedByName}` : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void ouvrirDocument(doc.id)}
|
||||||
|
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Ouvrir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void telechargerDocument(doc.id, doc.fileName)}
|
||||||
|
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
{surSuppression ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Supprimer « ${doc.fileName} » ?`)) surSuppression(doc.id);
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--danger)', fontWeight: 600, fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carte « Documents » des fiches OT / ascenseur — la même bibliothèque,
|
||||||
|
* filtrée sur la cible ; l'upload rattache automatiquement. */
|
||||||
|
export function CarteDocuments({
|
||||||
|
cible,
|
||||||
|
peutEditer,
|
||||||
|
}: {
|
||||||
|
cible: { assetId: string } | { workOrderId: string };
|
||||||
|
peutEditer: boolean;
|
||||||
|
}) {
|
||||||
|
const { data: documents } = useDocuments(cible);
|
||||||
|
const upload = useUploadDocument();
|
||||||
|
const suppression = useDeleteDocument();
|
||||||
|
const fichierRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [kind, setKind] = useState<DocumentKind>('workOrderId' in cible ? 'PHOTO' : 'NOTICE');
|
||||||
|
|
||||||
|
const surFichier = (file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > DOCUMENT_MAX_BYTES) {
|
||||||
|
window.alert('Fichier trop lourd (20 Mo max).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
upload.mutate({ file, kind, ...cible });
|
||||||
|
if (fichierRef.current) fichierRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Documents</h2>
|
||||||
|
{documents?.length ? (
|
||||||
|
<div className="docs" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
|
||||||
|
{documents.map((d) => (
|
||||||
|
<VignetteDoc
|
||||||
|
key={d.id}
|
||||||
|
doc={d}
|
||||||
|
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-encre-3">Aucun document rattaché.</p>
|
||||||
|
)}
|
||||||
|
{peutEditer ? (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="Type de document"
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => setKind(e.target.value as DocumentKind)}
|
||||||
|
className="rounded-lg border border-bordure-forte bg-surface p-2 text-encre"
|
||||||
|
>
|
||||||
|
{DOCUMENT_KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
ref={fichierRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/jpeg,image/png"
|
||||||
|
aria-label="Choisir un fichier"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => surFichier(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<Button disabled={upload.isPending} onClick={() => fichierRef.current?.click()}>
|
||||||
|
{upload.isPending ? 'Téléversement…' : 'Téléverser (PDF, JPG, PNG · 20 Mo)'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{upload.isError ? <p className="erreur-form">{upload.error.message}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
179
apps/web/src/components/recherche-globale.tsx
Normal file
179
apps/web/src/components/recherche-globale.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { SEARCH_MIN_CHARS } from '@siop/shared';
|
||||||
|
import { useGlobalSearch } from '@/api/recherche';
|
||||||
|
import { StatutOT } from '@/components/chips-ot';
|
||||||
|
import { IcoRecherche } from '@/components/nav-icons';
|
||||||
|
|
||||||
|
interface Resultat {
|
||||||
|
cle: string;
|
||||||
|
route: string;
|
||||||
|
contenu: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** La recherche de la topbar, enfin active (recette R3) : ⌘K pour saisir,
|
||||||
|
* résultats groupés (OT / ascenseurs / sites), déjà filtrés par la matrice
|
||||||
|
* côté API — flèches + Entrée pour naviguer. */
|
||||||
|
export function RechercheGlobale() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [saisie, setSaisie] = useState('');
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [ouverte, setOuverte] = useState(false);
|
||||||
|
const [actif, setActif] = useState(0);
|
||||||
|
const champ = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Débounce : on n'interroge l'API que 200 ms après la dernière frappe.
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setQ(saisie), 200);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [saisie]);
|
||||||
|
|
||||||
|
// ⌘K / Ctrl+K : focus depuis n'importe où.
|
||||||
|
useEffect(() => {
|
||||||
|
const surTouche = (e: KeyboardEvent) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
champ.current?.focus();
|
||||||
|
champ.current?.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', surTouche);
|
||||||
|
return () => window.removeEventListener('keydown', surTouche);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data } = useGlobalSearch(q);
|
||||||
|
|
||||||
|
const resultats = useMemo<Resultat[]>(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return [
|
||||||
|
...data.workOrders.map((w) => ({
|
||||||
|
cle: `ot-${w.id}`,
|
||||||
|
route: `/ot/${w.id}`,
|
||||||
|
contenu: (
|
||||||
|
<>
|
||||||
|
<span className="ref num">{w.reference}</span>
|
||||||
|
<span className="titre">{w.title}</span>
|
||||||
|
<span className="fin"><StatutOT statut={w.status} /></span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
...data.assets.map((a) => ({
|
||||||
|
cle: `asc-${a.id}`,
|
||||||
|
route: `/ascenseurs/${a.id}`,
|
||||||
|
contenu: (
|
||||||
|
<>
|
||||||
|
<span className="ref num">{a.reference}</span>
|
||||||
|
<span className="titre">
|
||||||
|
{a.brand}
|
||||||
|
{a.model ? ` ${a.model}` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="fin">{a.siteName}</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
...data.sites.map((s) => ({
|
||||||
|
cle: `site-${s.id}`,
|
||||||
|
route: `/sites/${s.id}`,
|
||||||
|
contenu: (
|
||||||
|
<>
|
||||||
|
<span className="titre"><b>{s.name}</b></span>
|
||||||
|
<span className="fin">{s.city ?? ''}</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const groupes = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const g: { libelle: string; debut: number; nombre: number }[] = [];
|
||||||
|
if (data.workOrders.length) g.push({ libelle: 'Ordres de travail', debut: 0, nombre: data.workOrders.length });
|
||||||
|
if (data.assets.length) g.push({ libelle: 'Ascenseurs', debut: data.workOrders.length, nombre: data.assets.length });
|
||||||
|
if (data.sites.length) {
|
||||||
|
g.push({
|
||||||
|
libelle: 'Sites',
|
||||||
|
debut: data.workOrders.length + data.assets.length,
|
||||||
|
nombre: data.sites.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const visible = ouverte && q.trim().length >= SEARCH_MIN_CHARS;
|
||||||
|
|
||||||
|
const aller = (r: Resultat | undefined) => {
|
||||||
|
if (!r) return;
|
||||||
|
setOuverte(false);
|
||||||
|
setSaisie('');
|
||||||
|
navigate(r.route);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="recherche" data-active>
|
||||||
|
<IcoRecherche />
|
||||||
|
<input
|
||||||
|
ref={champ}
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher un OT, un ascenseur, un site…"
|
||||||
|
aria-label="Recherche globale"
|
||||||
|
value={saisie}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSaisie(e.target.value);
|
||||||
|
setOuverte(true);
|
||||||
|
setActif(0);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOuverte(true)}
|
||||||
|
onBlur={() => setOuverte(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setOuverte(false);
|
||||||
|
champ.current?.blur();
|
||||||
|
}
|
||||||
|
if (!visible || !resultats.length) return;
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActif((a) => Math.min(a + 1, resultats.length - 1));
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActif((a) => Math.max(a - 1, 0));
|
||||||
|
}
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
aller(resultats[actif]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
{visible ? (
|
||||||
|
<div className="resultats" role="listbox" aria-label="Résultats">
|
||||||
|
{groupes.map((g) => (
|
||||||
|
<div key={g.libelle}>
|
||||||
|
<div className="groupe-resultats">{g.libelle}</div>
|
||||||
|
{resultats.slice(g.debut, g.debut + g.nombre).map((r, i) => (
|
||||||
|
<button
|
||||||
|
key={r.cle}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={g.debut + i === actif}
|
||||||
|
className="resultat"
|
||||||
|
data-actif={g.debut + i === actif || undefined}
|
||||||
|
onMouseEnter={() => setActif(g.debut + i)}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault(); // avant le blur du champ
|
||||||
|
aller(r);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.contenu}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!resultats.length ? (
|
||||||
|
<div className="groupe-resultats">Aucun résultat pour « {q.trim()} »</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,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… ⌘K
|
|
||||||
</div>
|
|
||||||
{urgences.length > 0 ? (
|
{urgences.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
157
apps/web/src/pages/achats.tsx
Normal file
157
apps/web/src/pages/achats.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
PURCHASE_ORDER_STATUS_LABELS,
|
||||||
|
type PurchaseOrderDto,
|
||||||
|
type PurchaseOrderStatus,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { usePurchaseOrders, useTransitionPurchaseOrder } from '@/api/gestion';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('fr-FR');
|
||||||
|
|
||||||
|
const CLASSE_STATUT: Record<PurchaseOrderStatus, string> = {
|
||||||
|
DRAFT: 'st brouillon',
|
||||||
|
SENT: 'st envoye',
|
||||||
|
RECEIVED: 'st recu',
|
||||||
|
CANCELLED: 'st annule',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatutBC({ statut }: { statut: PurchaseOrderStatus }) {
|
||||||
|
return <span className={CLASSE_STATUT[statut]}>{PURCHASE_ORDER_STATUS_LABELS[statut]}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Écran 3 des maquettes R3 : liste + panneau — la réception crée les
|
||||||
|
* mouvements d'entrée et fige le PU côté API, en une transaction. */
|
||||||
|
export default function PageAchats() {
|
||||||
|
const { data: bons } = usePurchaseOrders();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [selection, setSelection] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const liste = bons ?? [];
|
||||||
|
// sans sélection explicite, le panneau montre le premier BC de la liste
|
||||||
|
const bc = liste.find((b) => b.id === selection) ?? liste[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Bons de commande</h1>
|
||||||
|
<div className="actions">
|
||||||
|
{can('PURCHASE_ORDERS', 'create') ? (
|
||||||
|
<Link to="/achats/nouveau">
|
||||||
|
<Button variant="prim">+ Nouveau BC</Button>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2" style={{ gridTemplateColumns: '1.1fr 1fr' }}>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>N°</th><th>Fournisseur</th><th>Lignes</th><th>Total</th><th>Statut</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{liste.map((b) => (
|
||||||
|
<tr
|
||||||
|
key={b.id}
|
||||||
|
onClick={() => setSelection(b.id)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: bc?.id === b.id ? 'var(--primaire-doux)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="ref num">{b.reference}</td>
|
||||||
|
<td>{b.supplierName}</td>
|
||||||
|
<td className="num">{b.lines.length}</td>
|
||||||
|
<td className="num">{fmt.format(b.total)} MAD</td>
|
||||||
|
<td><StatutBC statut={b.status} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{bc ? <PanneauBC bc={bc} /> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanneauBC({ bc }: { bc: PurchaseOrderDto }) {
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const transition = useTransitionPurchaseOrder();
|
||||||
|
const peutEditer = can('PURCHASE_ORDERS', 'edit');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panneau">
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 13, fontWeight: 700, letterSpacing: '0.04em',
|
||||||
|
textTransform: 'uppercase', color: 'var(--encre-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bc.reference} — {bc.supplierName} <StatutBC statut={bc.status} />
|
||||||
|
</h2>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Pièce</th><th>Qté</th><th>PU</th><th>Total</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bc.lines.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td>{l.designation}</td>
|
||||||
|
<td className="num">{l.quantity}</td>
|
||||||
|
<td className="num">{fmt.format(l.unitPrice)}</td>
|
||||||
|
<td className="num">{fmt.format(l.quantity * l.unitPrice)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="total">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="num">{fmt.format(bc.total)} MAD</span>
|
||||||
|
</div>
|
||||||
|
{peutEditer && (bc.status === 'DRAFT' || bc.status === 'SENT') ? (
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
variant="dangerLeger"
|
||||||
|
disabled={transition.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Annuler le ${bc.reference} ?`)) {
|
||||||
|
transition.mutate({ id: bc.id, to: 'CANCELLED' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Annuler le BC
|
||||||
|
</Button>
|
||||||
|
{bc.status === 'DRAFT' ? (
|
||||||
|
<Button
|
||||||
|
variant="prim"
|
||||||
|
disabled={transition.isPending}
|
||||||
|
onClick={() => transition.mutate({ id: bc.id, to: 'SENT' })}
|
||||||
|
>
|
||||||
|
Envoyer au fournisseur
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="succes"
|
||||||
|
disabled={transition.isPending}
|
||||||
|
onClick={() => transition.mutate({ id: bc.id, to: 'RECEIVED' })}
|
||||||
|
>
|
||||||
|
Réceptionner (→ entrées de stock)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{transition.isError ? <p className="erreur-form">{transition.error.message}</p> : null}
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--encre-3)' }}>
|
||||||
|
La réception crée un mouvement d'entrée par ligne et fige le PU — le stock
|
||||||
|
remonte, l'alerte s'éteint.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
apps/web/src/pages/bibliotheque.tsx
Normal file
214
apps/web/src/pages/bibliotheque.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { useEffect, useRef, useState, type DragEvent } from 'react';
|
||||||
|
import {
|
||||||
|
DOCUMENT_KIND_LABELS,
|
||||||
|
DOCUMENT_KINDS,
|
||||||
|
DOCUMENT_MAX_BYTES,
|
||||||
|
type DocumentKind,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion';
|
||||||
|
import { useAssetOptions } from '@/api/referentiel';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { tailleLisible, VignetteDoc } from '@/components/carte-documents';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Modale } from '@/components/ui/modale';
|
||||||
|
|
||||||
|
/** Écran 7 des maquettes R3 : notices, certificats, photos — toujours
|
||||||
|
* RATTACHÉS (appareil ou OT). Ces documents nourriront le RAG en R5. */
|
||||||
|
export default function PageBibliotheque() {
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [kind, setKind] = useState<DocumentKind | ''>('');
|
||||||
|
const [assetId, setAssetId] = useState('');
|
||||||
|
const { data: options } = useAssetOptions();
|
||||||
|
const { data: documents } = useDocuments({
|
||||||
|
...(kind ? { kind } : {}),
|
||||||
|
...(assetId ? { assetId } : {}),
|
||||||
|
});
|
||||||
|
const suppression = useDeleteDocument();
|
||||||
|
const [modale, setModale] = useState(false);
|
||||||
|
const [fichierDepose, setFichierDepose] = useState<File | null>(null);
|
||||||
|
const [survol, setSurvol] = useState(false);
|
||||||
|
|
||||||
|
const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0);
|
||||||
|
const peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit');
|
||||||
|
|
||||||
|
const surDepot = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSurvol(false);
|
||||||
|
const fichier = e.dataTransfer.files?.[0];
|
||||||
|
if (!fichier) return;
|
||||||
|
setFichierDepose(fichier);
|
||||||
|
setModale(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Bibliothèque</h1>
|
||||||
|
<span className="filajout">
|
||||||
|
{documents?.length ?? 0} documents · {tailleLisible(totalOctets)}
|
||||||
|
</span>
|
||||||
|
<div className="actions">
|
||||||
|
{peutEditer ? (
|
||||||
|
<Button variant="prim" onClick={() => setModale(true)}>Téléverser</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filtres">
|
||||||
|
<select
|
||||||
|
aria-label="Filtrer par type"
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => setKind(e.target.value as DocumentKind | '')}
|
||||||
|
>
|
||||||
|
<option value="">Type : tous</option>
|
||||||
|
{DOCUMENT_KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="Filtrer par appareil"
|
||||||
|
value={assetId}
|
||||||
|
onChange={(e) => setAssetId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Rattaché à : tout le parc</option>
|
||||||
|
{(options ?? []).map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.reference} — {a.siteName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{peutEditer ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="zone-depot"
|
||||||
|
data-survol={survol || undefined}
|
||||||
|
onClick={() => setModale(true)}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSurvol(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setSurvol(false)}
|
||||||
|
onDrop={surDepot}
|
||||||
|
>
|
||||||
|
📂 Glissez un fichier ici — PDF, JPG, PNG · 20 Mo max · rattachez-le à un appareil ou un OT
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{documents?.length ? (
|
||||||
|
<div className="docs">
|
||||||
|
{documents.map((d) => (
|
||||||
|
<VignetteDoc
|
||||||
|
key={d.id}
|
||||||
|
doc={d}
|
||||||
|
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>
|
||||||
|
)}
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||||
|
Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) —
|
||||||
|
le rattachement propre commence ici.
|
||||||
|
</div>
|
||||||
|
{peutEditer ? (
|
||||||
|
<ModaleTeleversement
|
||||||
|
ouverte={modale}
|
||||||
|
fichierInitial={fichierDepose}
|
||||||
|
surFermeture={(o) => {
|
||||||
|
setModale(o);
|
||||||
|
if (!o) setFichierDepose(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModaleTeleversement({
|
||||||
|
ouverte,
|
||||||
|
fichierInitial,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
ouverte: boolean;
|
||||||
|
fichierInitial?: File | null;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { data: options } = useAssetOptions();
|
||||||
|
const upload = useUploadDocument();
|
||||||
|
const fichierRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [kind, setKind] = useState<DocumentKind>('NOTICE');
|
||||||
|
const [assetId, setAssetId] = useState('');
|
||||||
|
const [fichier, setFichier] = useState<File | null>(null);
|
||||||
|
|
||||||
|
// Fichier arrivé par glisser-déposer : la modale s'ouvre déjà remplie.
|
||||||
|
useEffect(() => {
|
||||||
|
if (fichierInitial) setFichier(fichierInitial);
|
||||||
|
}, [fichierInitial]);
|
||||||
|
|
||||||
|
const envoyer = () => {
|
||||||
|
if (!fichier || !assetId) return;
|
||||||
|
if (fichier.size > DOCUMENT_MAX_BYTES) {
|
||||||
|
window.alert('Fichier trop lourd (20 Mo max).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
upload.mutate(
|
||||||
|
{ file: fichier, kind, assetId },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setFichier(null);
|
||||||
|
surFermeture(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modale titre="Téléverser un document" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="tv-appareil">Rattacher à l'appareil *</label>
|
||||||
|
<select id="tv-appareil" value={assetId} onChange={(e) => setAssetId(e.target.value)}>
|
||||||
|
<option value="">Sélectionner…</option>
|
||||||
|
{(options ?? []).map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.reference} — {a.siteName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="tv-type">Type *</label>
|
||||||
|
<select id="tv-type" value={kind} onChange={(e) => setKind(e.target.value as DocumentKind)}>
|
||||||
|
{DOCUMENT_KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fichierRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/jpeg,image/png"
|
||||||
|
aria-label="Choisir un fichier"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => setFichier(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="zone-photo" onClick={() => fichierRef.current?.click()}>
|
||||||
|
{fichier
|
||||||
|
? `📄 ${fichier.name} (${tailleLisible(fichier.size)})`
|
||||||
|
: '📁 Choisir un fichier — PDF, JPG, PNG · 20 Mo max'}
|
||||||
|
</button>
|
||||||
|
<p className="note-douce">
|
||||||
|
Les photos prises sur une intervention se téléversent plutôt depuis la fiche OT —
|
||||||
|
elles restent rattachées à l'intervention.
|
||||||
|
</p>
|
||||||
|
{upload.isError ? <p className="erreur-form">{upload.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||||
|
<Button variant="prim" disabled={!fichier || !assetId || upload.isPending} onClick={envoyer}>
|
||||||
|
Téléverser
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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]"
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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, l’alerte s’allumera.'
|
||||||
|
: '.'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{consommation.isError ? <p className="erreur-form">{consommation.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={surFermeture}>Annuler</Button>
|
||||||
|
<Button
|
||||||
|
variant="prim"
|
||||||
|
disabled={!piece || quantite < 1 || (piece && quantite > piece.stock) || consommation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
consommation.mutate({ partId, quantity: quantite }, { onSuccess: surFermeture })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Consommer (− stock, prix figé)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModaleTemps({ otId, surFermeture }: { otId: string; surFermeture: () => void }) {
|
||||||
|
const saisie = useAddLaborTime(otId);
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
const heures = Number(donnees.get('heures') || 0);
|
||||||
|
const minutes = Number(donnees.get('minutes') || 0);
|
||||||
|
const totalMinutes = heures * 60 + minutes;
|
||||||
|
if (totalMinutes < 1) return;
|
||||||
|
saisie.mutate(
|
||||||
|
{
|
||||||
|
minutes: totalMinutes,
|
||||||
|
note: String(donnees.get('note') ?? '').trim() || undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: surFermeture },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modale titre="Saisir du temps" ouverte surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="st-h">Heures</label>
|
||||||
|
<input id="st-h" name="heures" type="number" min={0} max={24} defaultValue={1} className="num" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="st-m">Minutes</label>
|
||||||
|
<input id="st-m" name="minutes" type="number" min={0} max={59} defaultValue={0} className="num" />
|
||||||
|
</div>
|
||||||
|
<div className="champ pleine-l">
|
||||||
|
<label htmlFor="st-note">Note (optionnelle)</label>
|
||||||
|
<input id="st-note" name="note" placeholder="Ex. : réglage des coulisseaux" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="note-douce">
|
||||||
|
Le temps est valorisé au <b>taux horaire figé</b> de la personne connectée
|
||||||
|
(géré dans Personnes) — il ne bougera plus, même si le taux change.
|
||||||
|
</p>
|
||||||
|
{saisie.isError ? <p className="erreur-form">{saisie.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={surFermeture}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={saisie.isPending}>Enregistrer</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail['report']> & string; dto: string }[] = [
|
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' },
|
||||||
|
|||||||
188
apps/web/src/pages/fiche-piece.tsx
Normal file
188
apps/web/src/pages/fiche-piece.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { STOCK_MOVEMENT_KIND_LABELS } from '@siop/shared';
|
||||||
|
import { useAddStockMovement, usePart } from '@/api/gestion';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Modale } from '@/components/ui/modale';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('fr-FR');
|
||||||
|
|
||||||
|
/** Écran 2 des maquettes R3 : les mouvements SONT le stock. */
|
||||||
|
export default function PageFichePiece() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data: piece } = usePart(id);
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [modale, setModale] = useState<'ENTRY' | 'ADJUSTMENT' | null>(null);
|
||||||
|
|
||||||
|
if (!piece) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/stock">Stock</Link> / {piece.reference}
|
||||||
|
</div>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>{piece.designation}</h1>
|
||||||
|
{piece.belowThreshold ? <span className="stock-bas">Sous le seuil</span> : null}
|
||||||
|
<div className="actions">
|
||||||
|
{can('PARTS', 'edit') ? (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setModale('ENTRY')}>Entrée de stock</Button>
|
||||||
|
<Button onClick={() => setModale('ADJUSTMENT')}>Ajustement d'inventaire</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{piece.belowThreshold && can('PURCHASE_ORDERS', 'create') ? (
|
||||||
|
<Link to={`/achats/nouveau?part=${piece.id}`}>
|
||||||
|
<Button variant="prim">Préparer le BC</Button>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Identité</h2>
|
||||||
|
<dl className="infos">
|
||||||
|
<dt>Référence</dt>
|
||||||
|
<dd className="num">{piece.reference}</dd>
|
||||||
|
<dt>Stock actuel</dt>
|
||||||
|
<dd className="num"><b>{piece.stock}</b> (seuil : {piece.threshold})</dd>
|
||||||
|
<dt>Dernier PU</dt>
|
||||||
|
<dd className="num">
|
||||||
|
{piece.lastUnitPrice != null ? `${fmt.format(piece.lastUnitPrice)} MAD` : 'aucun prix connu'}
|
||||||
|
</dd>
|
||||||
|
<dt>Fournisseur</dt>
|
||||||
|
<dd>
|
||||||
|
{piece.supplierName ? (
|
||||||
|
<Link to="/tiers" style={{ color: 'var(--primaire)', fontWeight: 600 }}>
|
||||||
|
{piece.supplierName}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt>Compatible</dt>
|
||||||
|
<dd>{piece.compatible ?? '—'}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="carte">
|
||||||
|
<h2>
|
||||||
|
Mouvements{' '}
|
||||||
|
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||||
|
— le stock EST cette liste
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Mouvement</th><th>Qté</th><th>Origine</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{piece.movements.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td className="num">
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(m.createdAt))}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={m.quantity > 0 ? 'mvt entree' : 'mvt sortie'}>
|
||||||
|
{m.quantity > 0 ? '+ ' : '− '}
|
||||||
|
{STOCK_MOVEMENT_KIND_LABELS[m.kind]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">{Math.abs(m.quantity)}</td>
|
||||||
|
<td>
|
||||||
|
{m.workOrderReference ? (
|
||||||
|
<span className="ref num">{m.workOrderReference}</span>
|
||||||
|
) : m.purchaseOrderReference ? (
|
||||||
|
<>
|
||||||
|
<span className="ref num">{m.purchaseOrderReference}</span>
|
||||||
|
{m.unitPrice != null ? ` · ${fmt.format(m.unitPrice)} MAD/u` : ''}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
m.reason ?? '—'
|
||||||
|
)}
|
||||||
|
{m.byName ? ` · ${m.byName}` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ModaleMouvement
|
||||||
|
pieceId={piece.id}
|
||||||
|
kind={modale}
|
||||||
|
surFermeture={() => setModale(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModaleMouvement({
|
||||||
|
pieceId,
|
||||||
|
kind,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
pieceId: string;
|
||||||
|
kind: 'ENTRY' | 'ADJUSTMENT' | null;
|
||||||
|
surFermeture: () => void;
|
||||||
|
}) {
|
||||||
|
const mouvement = useAddStockMovement(pieceId);
|
||||||
|
if (!kind) return null;
|
||||||
|
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
mouvement.mutate(
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
quantity: Number(donnees.get('quantity')),
|
||||||
|
reason: String(donnees.get('reason') ?? '').trim() || undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: surFermeture },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modale
|
||||||
|
titre={kind === 'ENTRY' ? 'Entrée de stock' : "Ajustement d'inventaire"}
|
||||||
|
ouverte
|
||||||
|
surFermeture={surFermeture}
|
||||||
|
>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="mv-qte">
|
||||||
|
Quantité {kind === 'ADJUSTMENT' ? '(± signée)' : ''} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="mv-qte"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
min={kind === 'ENTRY' ? 1 : undefined}
|
||||||
|
placeholder={kind === 'ENTRY' ? '5' : '-1'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="mv-motif">
|
||||||
|
Motif {kind === 'ADJUSTMENT' ? '(requis)' : '(optionnel)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="mv-motif"
|
||||||
|
name="reason"
|
||||||
|
required={kind === 'ADJUSTMENT'}
|
||||||
|
placeholder={kind === 'ADJUSTMENT' ? 'Ex. : pièce endommagée à l’inventaire' : 'Ex. : reprise de stock atelier'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{mouvement.isError ? <p className="erreur-form">{mouvement.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={surFermeture}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={mouvement.isPending}>
|
||||||
|
Tracer le mouvement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { 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,
|
||||||
|
|||||||
166
apps/web/src/pages/nouvel-achat.tsx
Normal file
166
apps/web/src/pages/nouvel-achat.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useCreatePurchaseOrder, usePartners, useParts } from '@/api/gestion';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('fr-FR');
|
||||||
|
|
||||||
|
type Ligne = { partId: string; quantity: number; unitPrice: number };
|
||||||
|
|
||||||
|
/** Création de BC — préremplie depuis « Préparer le BC » (?part=…) :
|
||||||
|
* fournisseur de la pièce, quantité proposée pour repasser le seuil,
|
||||||
|
* dernier PU connu. Tout reste modifiable avant l'envoi. */
|
||||||
|
export default function PageNouvelAchat() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const partPrerempli = params.get('part');
|
||||||
|
const { data: parts, isFetching } = useParts();
|
||||||
|
const { data: partners } = usePartners();
|
||||||
|
const creation = useCreatePurchaseOrder();
|
||||||
|
|
||||||
|
const [supplierId, setSupplierId] = useState('');
|
||||||
|
const [lignes, setLignes] = useState<Ligne[]>([]);
|
||||||
|
const [initialise, setInitialise] = useState(false);
|
||||||
|
|
||||||
|
const fournisseurs = (partners ?? []).filter((p) => p.kind === 'SUPPLIER' && p.isActive);
|
||||||
|
const pieces = (parts ?? []).filter((p) => p.isActive);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// attendre la fin du refetch : le cache peut porter un stock périmé
|
||||||
|
if (initialise || !parts || isFetching) return;
|
||||||
|
setInitialise(true);
|
||||||
|
const piece = parts.find((p) => p.id === partPrerempli);
|
||||||
|
if (!piece) return;
|
||||||
|
setSupplierId(piece.supplierId ?? '');
|
||||||
|
setLignes([
|
||||||
|
{
|
||||||
|
partId: piece.id,
|
||||||
|
// proposition : revenir 5 unités au-dessus du seuil
|
||||||
|
quantity: Math.max(piece.threshold - piece.stock + 5, 1),
|
||||||
|
unitPrice: piece.lastUnitPrice ?? 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}, [initialise, parts, isFetching, partPrerempli]);
|
||||||
|
|
||||||
|
const majLigne = (index: number, patch: Partial<Ligne>) =>
|
||||||
|
setLignes((l) => l.map((ligne, i) => (i === index ? { ...ligne, ...patch } : ligne)));
|
||||||
|
|
||||||
|
const total = lignes.reduce((somme, l) => somme + l.quantity * l.unitPrice, 0);
|
||||||
|
const valide =
|
||||||
|
supplierId && lignes.length > 0 && lignes.every((l) => l.partId && l.quantity > 0 && l.unitPrice > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/achats">Bons de commande</Link> / nouveau
|
||||||
|
</div>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Nouveau bon de commande</h1>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ maxWidth: 760 }}>
|
||||||
|
<div className="champ" style={{ marginBottom: 14 }}>
|
||||||
|
<label htmlFor="bc-fournisseur">Fournisseur *</label>
|
||||||
|
<select
|
||||||
|
id="bc-fournisseur"
|
||||||
|
value={supplierId}
|
||||||
|
onChange={(e) => setSupplierId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner…</option>
|
||||||
|
{fournisseurs.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>{f.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Pièce</th><th style={{ width: 90 }}>Qté</th><th style={{ width: 120 }}>PU (MAD)</th><th style={{ width: 100 }}>Total</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lignes.map((ligne, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
aria-label={`Pièce de la ligne ${i + 1}`}
|
||||||
|
value={ligne.partId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const piece = pieces.find((p) => p.id === e.target.value);
|
||||||
|
majLigne(i, {
|
||||||
|
partId: e.target.value,
|
||||||
|
unitPrice: piece?.lastUnitPrice ?? ligne.unitPrice,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner…</option>
|
||||||
|
{pieces.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.reference} — {p.designation} (stock : {p.stock})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
aria-label={`Quantité de la ligne ${i + 1}`}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="num"
|
||||||
|
value={ligne.quantity || ''}
|
||||||
|
onChange={(e) => majLigne(i, { quantity: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
aria-label={`Prix unitaire de la ligne ${i + 1}`}
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
step="0.01"
|
||||||
|
className="num"
|
||||||
|
value={ligne.unitPrice || ''}
|
||||||
|
onChange={(e) => majLigne(i, { unitPrice: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="num">{fmt.format(ligne.quantity * ligne.unitPrice)}</td>
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-[12px]"
|
||||||
|
onClick={() => setLignes((l) => l.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
Retirer
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<Button onClick={() => setLignes((l) => [...l, { partId: '', quantity: 1, unitPrice: 0 }])}>
|
||||||
|
+ Ajouter une ligne
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="total">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="num">{fmt.format(total)} MAD</span>
|
||||||
|
</div>
|
||||||
|
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Link to="/achats"><Button>Annuler</Button></Link>
|
||||||
|
<Button
|
||||||
|
variant="prim"
|
||||||
|
disabled={!valide || creation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
creation.mutate(
|
||||||
|
{ supplierId, lines: lignes },
|
||||||
|
{ onSuccess: () => navigate('/achats') },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Créer le BC (brouillon)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
158
apps/web/src/pages/statistiques.tsx
Normal file
158
apps/web/src/pages/statistiques.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ANALYTICS_PERIODS, type AnalyticsPeriod } from '@siop/shared';
|
||||||
|
import { useAnalyticsSummary } from '@/api/gestion';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('fr-FR');
|
||||||
|
const fmtCompact = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 1 });
|
||||||
|
|
||||||
|
function enMilliers(v: number): string {
|
||||||
|
return v >= 1000 ? `${fmtCompact.format(v / 1000)} k` : fmt.format(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** « 2026-03 » → « mars » (maquette : étiquettes de mois en toutes lettres). */
|
||||||
|
function moisLisible(period: string): string {
|
||||||
|
const [annee, mois] = period.split('-').map(Number);
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', { month: 'long' }).format(new Date(annee, mois - 1, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Écran 5 des maquettes R3 : M. Bennis lit en 30 s — barres horizontales
|
||||||
|
* à étiquettes directes (palette CVD validée), chiffres tabulaires. */
|
||||||
|
export default function PageStatistiques() {
|
||||||
|
const [periode, setPeriode] = useState<AnalyticsPeriod>(12);
|
||||||
|
const { data } = useAnalyticsSummary(periode);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const delta =
|
||||||
|
data.previousMonthCost > 0
|
||||||
|
? Math.round(((data.monthCost - data.previousMonthCost) / data.previousMonthCost) * 100)
|
||||||
|
: null;
|
||||||
|
const maxPannes = Math.max(...data.failuresByComponent.map((f) => f.count), 1);
|
||||||
|
const maxCout = Math.max(...data.costsByMonth.map((c) => c.total), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Statistiques</h1>
|
||||||
|
<div className="actions">
|
||||||
|
<select
|
||||||
|
aria-label="Période"
|
||||||
|
value={periode}
|
||||||
|
onChange={(e) => setPeriode(Number(e.target.value) as AnalyticsPeriod)}
|
||||||
|
>
|
||||||
|
{ANALYTICS_PERIODS.map((m) => (
|
||||||
|
<option key={m} value={m}>Période : {m} derniers mois</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">
|
||||||
|
Coût · {new Intl.DateTimeFormat('fr-FR', { month: 'long' }).format(new Date())}
|
||||||
|
</div>
|
||||||
|
<div className="val num">
|
||||||
|
{fmt.format(data.monthCost)}{' '}
|
||||||
|
<span style={{ fontSize: 14, color: 'var(--encre-2)' }}>MAD</span>
|
||||||
|
</div>
|
||||||
|
{delta != null ? (
|
||||||
|
<div className={`delta ${delta <= 0 ? 'bon' : 'mauvais'}`}>
|
||||||
|
{delta > 0 ? '+' : '−'}{Math.abs(delta)} % vs mois précédent
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="delta neutre">pas de mois précédent</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">OT clôturés · {data.months} mois</div>
|
||||||
|
<div className="val num">{fmt.format(data.closed.total)}</div>
|
||||||
|
<div className="delta neutre">dont {fmt.format(data.closed.preventive)} préventifs</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Taux de préventif</div>
|
||||||
|
<div className="val num">
|
||||||
|
{data.preventiveRate != null ? `${Math.round(data.preventiveRate * 100)} %` : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="delta bon">grilles faites / dues</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Durée moyenne de résolution</div>
|
||||||
|
<div className="val num">
|
||||||
|
{data.avgResolutionDays != null ? `${fmtCompact.format(data.avgResolutionDays)} j` : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="delta neutre">dépannages, {data.months} mois</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="carte graf">
|
||||||
|
<h2>
|
||||||
|
Pannes par organe · {data.months} mois{' '}
|
||||||
|
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||||
|
(bilans codés)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="barres-h">
|
||||||
|
{data.failuresByComponent.map((f) => (
|
||||||
|
<div className="ligne-b" key={f.label}>
|
||||||
|
<span>{f.label}</span>
|
||||||
|
<div className="fond-b">
|
||||||
|
<div
|
||||||
|
className="rempli"
|
||||||
|
style={{ width: `${(f.count / maxPannes) * 100}%`, background: 'var(--graf-1)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="valeur num">{f.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!data.failuresByComponent.length ? (
|
||||||
|
<p className="text-encre-3">Aucun bilan codé sur la période.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte graf">
|
||||||
|
<h2>
|
||||||
|
Coûts par mois{' '}
|
||||||
|
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||||
|
(pièces + main-d'œuvre, MAD)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="barres-h">
|
||||||
|
{data.costsByMonth.map((c) => (
|
||||||
|
<div className="ligne-b" key={c.month}>
|
||||||
|
<span style={{ textTransform: 'capitalize' }}>{moisLisible(c.month)}</span>
|
||||||
|
<div className="fond-b">
|
||||||
|
<div
|
||||||
|
className="rempli"
|
||||||
|
style={{ width: `${(c.total / maxCout) * 100}%`, background: 'var(--graf-2)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="valeur num">{enMilliers(c.total)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Top équipements en coût · {data.months} mois</h2>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Appareil</th><th>Site</th><th>Dépannages</th><th>Pièces</th><th>Main-d'œuvre</th><th>Total</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.topAssets.map((a) => (
|
||||||
|
<tr key={a.reference}>
|
||||||
|
<td className="ref num">{a.reference}</td>
|
||||||
|
<td>{a.siteName}</td>
|
||||||
|
<td className="num">{a.correctives}</td>
|
||||||
|
<td className="num">{fmt.format(a.partsCost)}</td>
|
||||||
|
<td className="num">{fmt.format(a.laborCost)}</td>
|
||||||
|
<td className="num"><b>{fmt.format(a.total)} MAD</b></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
apps/web/src/pages/stock.tsx
Normal file
266
apps/web/src/pages/stock.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAddStockMovement, useCreatePart, usePartners, useParts } from '@/api/gestion';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Modale } from '@/components/ui/modale';
|
||||||
|
|
||||||
|
const fmt = new Intl.NumberFormat('fr-FR');
|
||||||
|
|
||||||
|
/** Écran 1 des maquettes R3 : le stock DÉRIVÉ des mouvements — jamais de
|
||||||
|
* quantité saisie. Sous le seuil : strie ambre + « Préparer le BC »,
|
||||||
|
* et les pièces en alerte remontent en tête de liste (maquette). */
|
||||||
|
export default function PageStock() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: parts } = useParts();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [modale, setModale] = useState<'piece' | 'entree' | null>(null);
|
||||||
|
const [alerte, setAlerte] = useState(false);
|
||||||
|
const [fournisseur, setFournisseur] = useState('');
|
||||||
|
|
||||||
|
const toutes = parts ?? [];
|
||||||
|
const sousSeuil = toutes.filter((p) => p.belowThreshold && p.isActive);
|
||||||
|
const fournisseurs = [...new Set(toutes.map((p) => p.supplierName).filter(Boolean))] as string[];
|
||||||
|
const visibles = (alerte ? sousSeuil : toutes)
|
||||||
|
.filter((p) => !fournisseur || p.supplierName === fournisseur)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
Number(b.belowThreshold && b.isActive) - Number(a.belowThreshold && a.isActive) ||
|
||||||
|
a.reference.localeCompare(b.reference),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Stock de pièces</h1>
|
||||||
|
<span className="filajout">
|
||||||
|
{toutes.length} références
|
||||||
|
{sousSeuil.length ? (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<span className="stock-bas">{sousSeuil.length} sous le seuil</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<div className="actions">
|
||||||
|
{can('PURCHASE_ORDERS', 'view') ? (
|
||||||
|
<Link to="/achats"><Button>Bons de commande</Button></Link>
|
||||||
|
) : null}
|
||||||
|
{can('PARTS', 'edit') ? (
|
||||||
|
<Button onClick={() => setModale('entree')}>Entrée de stock</Button>
|
||||||
|
) : null}
|
||||||
|
{can('PARTS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => setModale('piece')}>
|
||||||
|
+ Nouvelle pièce
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filtres">
|
||||||
|
<select
|
||||||
|
aria-label="Filtrer par alerte"
|
||||||
|
value={alerte ? 'seuil' : ''}
|
||||||
|
onChange={(e) => setAlerte(e.target.value === 'seuil')}
|
||||||
|
>
|
||||||
|
<option value="">Alerte : toutes</option>
|
||||||
|
<option value="seuil">Sous le seuil</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="Filtrer par fournisseur"
|
||||||
|
value={fournisseur}
|
||||||
|
onChange={(e) => setFournisseur(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Fournisseur : tous</option>
|
||||||
|
{fournisseurs.map((f) => (
|
||||||
|
<option key={f} value={f}>{f}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Réf.</th>
|
||||||
|
<th>Désignation</th>
|
||||||
|
<th>Stock</th>
|
||||||
|
<th>Seuil</th>
|
||||||
|
<th>Dernier PU</th>
|
||||||
|
<th>Fournisseur</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibles.map((p) => (
|
||||||
|
<tr
|
||||||
|
key={p.id}
|
||||||
|
className={p.belowThreshold ? 'ligne-seuil' : undefined}
|
||||||
|
onClick={() => navigate(`/stock/${p.id}`)}
|
||||||
|
style={{ cursor: 'pointer', color: !p.isActive ? 'var(--encre-3)' : undefined }}
|
||||||
|
>
|
||||||
|
<td className="ref num">{p.reference}</td>
|
||||||
|
<td>{p.designation}{!p.isActive ? ' (désactivée)' : ''}</td>
|
||||||
|
<td className="num">{p.belowThreshold ? <b>{p.stock}</b> : p.stock}</td>
|
||||||
|
<td className="num">{p.threshold}</td>
|
||||||
|
<td className="num">{p.lastUnitPrice != null ? `${fmt.format(p.lastUnitPrice)} MAD` : '—'}</td>
|
||||||
|
<td>{p.supplierName ?? '—'}</td>
|
||||||
|
<td onClick={(e) => e.stopPropagation()}>
|
||||||
|
{p.belowThreshold && can('PURCHASE_ORDERS', 'create') ? (
|
||||||
|
<>
|
||||||
|
<span className="stock-bas">Sous le seuil</span>{' '}
|
||||||
|
<Link
|
||||||
|
to={`/achats/nouveau?part=${p.id}`}
|
||||||
|
style={{ color: 'var(--primaire)', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Préparer le BC
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||||
|
Le stock affiché = somme des mouvements (réceptions de BC, entrées manuelles,
|
||||||
|
consommations d'OT, ajustements motivés). <b>Aucune saisie directe de quantité.</b>
|
||||||
|
</div>
|
||||||
|
{can('PARTS', 'create') ? (
|
||||||
|
<NouvellePiece ouverte={modale === 'piece'} surFermeture={() => setModale(null)} />
|
||||||
|
) : null}
|
||||||
|
{can('PARTS', 'edit') && modale === 'entree' ? (
|
||||||
|
<EntreeDeStock surFermeture={() => setModale(null)} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entrée de stock depuis la liste (maquette) : on choisit la pièce ici —
|
||||||
|
* même mouvement tracé que depuis la fiche. */
|
||||||
|
function EntreeDeStock({ surFermeture }: { surFermeture: () => void }) {
|
||||||
|
const { data: parts } = useParts();
|
||||||
|
const [pieceId, setPieceId] = useState('');
|
||||||
|
const mouvement = useAddStockMovement(pieceId);
|
||||||
|
const actives = (parts ?? []).filter((p) => p.isActive);
|
||||||
|
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!pieceId) return;
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
mouvement.mutate(
|
||||||
|
{
|
||||||
|
kind: 'ENTRY',
|
||||||
|
quantity: Number(donnees.get('quantity')),
|
||||||
|
reason: String(donnees.get('reason') ?? '').trim() || undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: surFermeture },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modale titre="Entrée de stock" ouverte surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="es-piece">Pièce *</label>
|
||||||
|
<select id="es-piece" required value={pieceId} onChange={(e) => setPieceId(e.target.value)}>
|
||||||
|
<option value="">Sélectionner…</option>
|
||||||
|
{actives.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.reference} — {p.designation} (stock : {p.stock})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="es-qte">Quantité *</label>
|
||||||
|
<input id="es-qte" name="quantity" type="number" required min={1} placeholder="5" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="es-motif">Motif (optionnel)</label>
|
||||||
|
<input id="es-motif" name="reason" placeholder="Ex. : reprise de stock atelier" />
|
||||||
|
</div>
|
||||||
|
{mouvement.isError ? <p className="erreur-form">{mouvement.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={surFermeture}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={!pieceId || mouvement.isPending}>
|
||||||
|
Tracer le mouvement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NouvellePiece({
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: partners } = usePartners();
|
||||||
|
const creation = useCreatePart();
|
||||||
|
const fournisseurs = (partners ?? []).filter((p) => p.kind === 'SUPPLIER' && p.isActive);
|
||||||
|
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
const champ = (n: string) => String(donnees.get(n) ?? '').trim();
|
||||||
|
creation.mutate(
|
||||||
|
{
|
||||||
|
designation: champ('designation'),
|
||||||
|
threshold: champ('threshold') ? Number(champ('threshold')) : undefined,
|
||||||
|
supplierId: champ('supplierId') || undefined,
|
||||||
|
compatible: champ('compatible') || undefined,
|
||||||
|
initialPrice: champ('initialPrice') ? Number(champ('initialPrice')) : undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: (piece) => navigate(`/stock/${piece.id}`) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modale titre="Nouvelle pièce" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="np-designation">Désignation *</label>
|
||||||
|
<input id="np-designation" name="designation" required placeholder="Ex. : Contact de porte NC-31" />
|
||||||
|
</div>
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="np-seuil">Seuil d'alerte</label>
|
||||||
|
<input id="np-seuil" name="threshold" type="number" min="0" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="np-prix">Prix initial (MAD)</label>
|
||||||
|
<input id="np-prix" name="initialPrice" type="number" min="0" step="0.01" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="np-fournisseur">Fournisseur</label>
|
||||||
|
<select id="np-fournisseur" name="supplierId" defaultValue="">
|
||||||
|
<option value="">—</option>
|
||||||
|
{fournisseurs.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>{f.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="np-compatible">Compatible avec</label>
|
||||||
|
<input id="np-compatible" name="compatible" placeholder="Ex. : Otis Gen2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="note-douce">
|
||||||
|
La référence P-#### est générée. Le stock initial s'ajoute ensuite par
|
||||||
|
une <b>entrée</b> — jamais par un champ de quantité.
|
||||||
|
</p>
|
||||||
|
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>Créer la pièce</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
133
apps/web/src/pages/tiers.tsx
Normal file
133
apps/web/src/pages/tiers.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { PARTNER_KIND_LABELS, PARTNER_KINDS } from '@siop/shared';
|
||||||
|
import { useCreatePartner, usePartners } from '@/api/gestion';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Modale } from '@/components/ui/modale';
|
||||||
|
|
||||||
|
/** Écran 6 des maquettes R3 : annuaire minimal — pas un CRM. */
|
||||||
|
export default function PageTiers() {
|
||||||
|
const { data: partners } = usePartners();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [modale, setModale] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Tiers</h1>
|
||||||
|
<div className="actions">
|
||||||
|
{can('PURCHASE_ORDERS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => setModale(true)}>
|
||||||
|
+ Nouveau tiers
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Nom</th><th>Type</th><th>Contact</th><th>Rattachements</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(partners ?? []).map((p) => (
|
||||||
|
<tr key={p.id} style={!p.isActive ? { color: 'var(--encre-3)' } : undefined}>
|
||||||
|
<td>
|
||||||
|
<b>{p.name}</b>
|
||||||
|
{p.city ? <span className="sous">{p.city}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td>{PARTNER_KIND_LABELS[p.kind]}</td>
|
||||||
|
<td>
|
||||||
|
{p.contactName}
|
||||||
|
{p.contactName && p.phone ? ' · ' : null}
|
||||||
|
{p.phone ? <span className="num">{p.phone}</span> : null}
|
||||||
|
{!p.contactName && !p.phone && !p.email ? '—' : null}
|
||||||
|
{p.email ? <span className="sous">{p.email}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{p.kind === 'SUPPLIER'
|
||||||
|
? p.openOrders
|
||||||
|
? `${p.openOrders} BC en cours`
|
||||||
|
: '—'
|
||||||
|
: p.siteNames.length
|
||||||
|
? p.siteNames.join(' · ')
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NouveauTiers ouverte={modale} surFermeture={setModale} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NouveauTiers({
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const creation = useCreatePartner();
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
const champ = (n: string) => String(donnees.get(n) ?? '').trim();
|
||||||
|
creation.mutate(
|
||||||
|
{
|
||||||
|
name: champ('name'),
|
||||||
|
kind: champ('kind') as 'SUPPLIER' | 'CLIENT',
|
||||||
|
contactName: champ('contactName') || undefined,
|
||||||
|
phone: champ('phone') || undefined,
|
||||||
|
email: champ('email') || undefined,
|
||||||
|
city: champ('city') || undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => surFermeture(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modale titre="Nouveau tiers" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="nt-nom">Nom *</label>
|
||||||
|
<input id="nt-nom" name="name" required placeholder="Ex. : Ascentech Maroc" />
|
||||||
|
</div>
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="nt-type">Type *</label>
|
||||||
|
<select id="nt-type" name="kind" required defaultValue="SUPPLIER">
|
||||||
|
{PARTNER_KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>{PARTNER_KIND_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="nt-ville">Ville</label>
|
||||||
|
<input id="nt-ville" name="city" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="nt-contact">Contact</label>
|
||||||
|
<input id="nt-contact" name="contactName" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="nt-tel">Téléphone</label>
|
||||||
|
<input id="nt-tel" name="phone" />
|
||||||
|
</div>
|
||||||
|
<div className="champ pleine-l">
|
||||||
|
<label htmlFor="nt-email">Email</label>
|
||||||
|
<input id="nt-email" name="email" type="email" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>Créer</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -187,7 +187,38 @@ table {
|
|||||||
.recherche svg {
|
.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;
|
||||||
|
|||||||
840
docs/02-design/maquettes/maquette-r3.html
Normal file
840
docs/02-design/maquettes/maquette-r3.html
Normal file
@@ -0,0 +1,840 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>SIOP V2 — Maquettes HD · R3 Gestion</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
SIOP V2 — Maquettes HD de la release R3 « Gestion » (design-first).
|
||||||
|
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE APPLICATIF R3 (principe n°1).
|
||||||
|
Écrans déjà validés non redessinés : la tuile « Coût » du tableau de bord et
|
||||||
|
la carte « Pièces & main-d'œuvre » de la fiche OT (maquette-web, R0).
|
||||||
|
CSS répliquée des maquettes validées ; ajouts R3 dans le dernier bloc <style>.
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
/* ═══ Tokens (répliques de ../tokens.css) ═══ */
|
||||||
|
:root{
|
||||||
|
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
|
||||||
|
--safran:#dd8a0b;--safran-doux:#fdf3e3;
|
||||||
|
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
|
||||||
|
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
|
||||||
|
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
|
||||||
|
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
|
||||||
|
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
|
||||||
|
--st-annule:#68788f;--st-annule-fond:#edf0f4;
|
||||||
|
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
|
||||||
|
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
|
||||||
|
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
|
||||||
|
--graf-1:#3b69d6;--graf-2:#dd8a0b;--graf-3:#1f9e8e;--graf-4:#8a63d2;--graf-grille:#e6ebf2;
|
||||||
|
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
|
||||||
|
--police-ui:"Manrope","Avenir Next","Segoe UI Variable",system-ui,sans-serif;
|
||||||
|
--rayon:10px;--rayon-controle:8px;--ombre-menu:0 8px 24px rgb(16 30 54/.14);
|
||||||
|
--transition:140ms ease-out;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme:dark){:root{
|
||||||
|
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
|
||||||
|
--safran:#e89a1f;--safran-doux:#33270f;
|
||||||
|
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
|
||||||
|
--bordure:#2b3850;--bordure-forte:#3d4d6b;
|
||||||
|
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
|
||||||
|
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
|
||||||
|
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
|
||||||
|
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
|
||||||
|
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
|
||||||
|
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
|
||||||
|
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
|
||||||
|
--graf-1:#5580e4;--graf-2:#c7820a;--graf-3:#26a896;--graf-4:#9673de;--graf-grille:#24304a;
|
||||||
|
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
|
||||||
|
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
|
||||||
|
}}
|
||||||
|
:root[data-theme="light"]{
|
||||||
|
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
|
||||||
|
--safran:#dd8a0b;--safran-doux:#fdf3e3;
|
||||||
|
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
|
||||||
|
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
|
||||||
|
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
|
||||||
|
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
|
||||||
|
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
|
||||||
|
--st-annule:#68788f;--st-annule-fond:#edf0f4;
|
||||||
|
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
|
||||||
|
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
|
||||||
|
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
|
||||||
|
--graf-1:#3b69d6;--graf-2:#dd8a0b;--graf-3:#1f9e8e;--graf-4:#8a63d2;--graf-grille:#e6ebf2;
|
||||||
|
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
|
||||||
|
--ombre-menu:0 8px 24px rgb(16 30 54/.14);
|
||||||
|
}
|
||||||
|
:root[data-theme="dark"]{
|
||||||
|
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
|
||||||
|
--safran:#e89a1f;--safran-doux:#33270f;
|
||||||
|
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
|
||||||
|
--bordure:#2b3850;--bordure-forte:#3d4d6b;
|
||||||
|
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
|
||||||
|
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
|
||||||
|
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
|
||||||
|
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
|
||||||
|
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
|
||||||
|
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
|
||||||
|
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
|
||||||
|
--graf-1:#5580e4;--graf-2:#c7820a;--graf-3:#26a896;--graf-4:#9673de;--graf-grille:#24304a;
|
||||||
|
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
|
||||||
|
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Base ═══ */
|
||||||
|
*{box-sizing:border-box;margin:0}
|
||||||
|
html,body{background:var(--fond)}
|
||||||
|
body{font-family:var(--police-ui);color:var(--encre);font-size:14px;line-height:1.5}
|
||||||
|
button{font:inherit;cursor:pointer;border:0;background:none;color:inherit}
|
||||||
|
button:focus-visible,a:focus-visible{outline:2px solid var(--primaire);outline-offset:2px;border-radius:4px}
|
||||||
|
table{border-collapse:collapse;font-variant-numeric:tabular-nums}
|
||||||
|
.num{font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
/* ═══ Barre de revue (outil maquette, pas l'app) ═══ */
|
||||||
|
.revue{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:12px;flex-wrap:wrap;
|
||||||
|
padding:10px 16px;background:var(--nav-fond);color:var(--nav-encre);font-size:13px}
|
||||||
|
.revue b{color:var(--nav-encre-vif);letter-spacing:.02em}
|
||||||
|
.revue .onglets{display:flex;gap:4px;flex-wrap:wrap}
|
||||||
|
.revue .onglets button{padding:6px 10px;border-radius:6px;color:var(--nav-encre)}
|
||||||
|
.revue .onglets button:hover{background:rgb(255 255 255/.08);color:var(--nav-encre-vif)}
|
||||||
|
.revue .onglets button[aria-selected="true"]{background:rgb(255 255 255/.12);color:var(--nav-encre-vif);
|
||||||
|
box-shadow:inset 3px 0 0 var(--safran)}
|
||||||
|
.revue .droite{margin-left:auto;display:flex;align-items:center;gap:8px}
|
||||||
|
.revue .theme{padding:6px 10px;border:1px solid var(--nav-bordure);border-radius:6px}
|
||||||
|
.note-ecran{max-width:1280px;margin:14px auto 0;padding:0 20px;color:var(--encre-2);font-size:13px}
|
||||||
|
.note-ecran b{color:var(--encre)}
|
||||||
|
|
||||||
|
/* ═══ Coquille applicative ═══ */
|
||||||
|
.ecran{display:none;max-width:1280px;margin:14px auto 40px;padding:0 20px}
|
||||||
|
.ecran.actif{display:block}
|
||||||
|
.app{display:grid;grid-template-columns:232px 1fr;min-height:720px;border:1px solid var(--bordure);
|
||||||
|
border-radius:14px;overflow:hidden;background:var(--fond)}
|
||||||
|
.app.pleine{grid-template-columns:1fr}
|
||||||
|
|
||||||
|
/* Sidebar (toujours sombre — identité) */
|
||||||
|
.nav{background:var(--nav-fond);color:var(--nav-encre);display:flex;flex-direction:column;padding:16px 0}
|
||||||
|
.logo{display:flex;align-items:center;gap:10px;padding:2px 18px 16px;color:var(--nav-encre-vif)}
|
||||||
|
.logo .marque{display:flex;flex-direction:column;line-height:.55;font-size:11px;color:var(--safran)}
|
||||||
|
.logo b{font-size:19px;letter-spacing:.04em}
|
||||||
|
.logo small{color:var(--nav-encre);font-weight:400;font-size:11px;display:block;margin-top:2px}
|
||||||
|
.nav .groupe{padding:12px 18px 4px;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;color:#6b7f9e}
|
||||||
|
.nav a{display:flex;align-items:center;gap:10px;padding:8px 18px;color:var(--nav-encre);
|
||||||
|
text-decoration:none;font-size:13.5px;border-left:3px solid transparent;transition:background var(--transition)}
|
||||||
|
.nav a:hover{background:rgb(255 255 255/.06);color:var(--nav-encre-vif)}
|
||||||
|
.nav a.actif{background:rgb(255 255 255/.09);color:var(--nav-encre-vif);border-left-color:var(--safran)}
|
||||||
|
.nav a .badge-nav{margin-left:auto;background:var(--prio-bloque);color:#fff;font-size:11px;
|
||||||
|
padding:1px 7px;border-radius:999px;font-weight:700}
|
||||||
|
.nav svg{width:17px;height:17px;stroke-width:1.75;flex:none}
|
||||||
|
|
||||||
|
/* Topbar */
|
||||||
|
.contenu{display:flex;flex-direction:column;min-width:0}
|
||||||
|
.topbar{display:flex;align-items:center;gap:12px;padding:12px 24px;background:var(--surface);
|
||||||
|
border-bottom:1px solid var(--bordure)}
|
||||||
|
.recherche{flex:1;max-width:420px;display:flex;align-items:center;gap:8px;background:var(--surface-2);
|
||||||
|
border:1px solid var(--bordure);border-radius:8px;padding:7px 12px;color:var(--encre-3);font-size:13px}
|
||||||
|
.recherche svg{width:15px;height:15px}
|
||||||
|
.chip-urgence{display:flex;align-items:center;gap:7px;background:var(--prio-bloque-fond);
|
||||||
|
color:var(--prio-bloque);border:1px solid color-mix(in srgb,var(--prio-bloque) 30%,transparent);
|
||||||
|
padding:6px 12px;border-radius:999px;font-weight:700;font-size:12.5px}
|
||||||
|
.point-pulse{width:8px;height:8px;border-radius:50%;background:var(--prio-bloque);animation:pulse 1.4s ease-out infinite}
|
||||||
|
@keyframes pulse{0%{box-shadow:0 0 0 0 color-mix(in srgb,var(--prio-bloque) 45%,transparent)}
|
||||||
|
70%{box-shadow:0 0 0 8px transparent}100%{box-shadow:0 0 0 0 transparent}}
|
||||||
|
@media (prefers-reduced-motion:reduce){.point-pulse{animation:none}}
|
||||||
|
.compte{margin-left:auto;display:flex;align-items:center;gap:10px}
|
||||||
|
.chip-demo{font-size:11px;font-weight:700;letter-spacing:.05em;color:var(--safran);
|
||||||
|
background:var(--safran-doux);padding:3px 9px;border-radius:999px;border:1px dashed var(--safran)}
|
||||||
|
.avatar{width:30px;height:30px;border-radius:50%;background:var(--primaire);color:#fff;display:flex;
|
||||||
|
align-items:center;justify-content:center;font-size:12px;font-weight:700;flex:none}
|
||||||
|
.compte .qui{line-height:1.15;font-size:12.5px}
|
||||||
|
.compte .qui small{color:var(--encre-2)}
|
||||||
|
|
||||||
|
/* Corps de page */
|
||||||
|
.page{padding:20px 24px;display:flex;flex-direction:column;gap:16px}
|
||||||
|
.entete-page{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||||
|
.entete-page h1{font-size:21px;font-weight:700;letter-spacing:-.01em;text-wrap:balance}
|
||||||
|
.entete-page .actions{margin-left:auto;display:flex;gap:8px}
|
||||||
|
.filajout{color:var(--encre-3);font-size:12.5px}
|
||||||
|
.filajout a{color:var(--encre-2);text-decoration:none}
|
||||||
|
|
||||||
|
/* Boutons */
|
||||||
|
.btn{display:inline-flex;align-items:center;gap:7px;padding:8px 14px;border-radius:8px;font-size:13.5px;
|
||||||
|
font-weight:600;border:1px solid var(--bordure-forte);background:var(--surface);color:var(--encre);
|
||||||
|
transition:filter var(--transition)}
|
||||||
|
.btn:hover{filter:brightness(.97)}
|
||||||
|
.btn.prim{background:var(--primaire);border-color:var(--primaire);color:#fff}
|
||||||
|
.btn.succes{background:var(--succes);border-color:var(--succes);color:#fff}
|
||||||
|
.btn.danger-l{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 40%,transparent);background:transparent}
|
||||||
|
.btn.ghost{border-color:transparent;background:transparent;color:var(--encre-2)}
|
||||||
|
.btn:disabled{opacity:.45;cursor:not-allowed}
|
||||||
|
.btn svg{width:15px;height:15px;stroke-width:2}
|
||||||
|
|
||||||
|
/* Cartes */
|
||||||
|
.carte{background:var(--surface);border:1px solid var(--bordure);border-radius:var(--rayon);padding:16px 18px}
|
||||||
|
.carte h2{font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--encre-2);margin-bottom:12px}
|
||||||
|
.grille-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
|
.grille-3{display:grid;grid-template-columns:repeat(3,1fr);gap:16px}
|
||||||
|
@media(max-width:900px){.grille-2,.grille-3{grid-template-columns:1fr}}
|
||||||
|
|
||||||
|
/* Tuiles KPI */
|
||||||
|
.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}
|
||||||
|
@media(max-width:1100px){.kpis{grid-template-columns:repeat(2,1fr)}}
|
||||||
|
.kpi{background:var(--surface);border:1px solid var(--bordure);border-radius:var(--rayon);padding:14px 16px}
|
||||||
|
.kpi .lib{font-size:11.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--encre-2);font-weight:600}
|
||||||
|
.kpi .val{font-size:27px;font-weight:700;letter-spacing:-.02em;margin-top:4px}
|
||||||
|
.kpi .delta{font-size:12px;font-weight:600;margin-top:2px}
|
||||||
|
.delta.bon{color:var(--succes)}.delta.mauvais{color:var(--danger)}.delta.neutre{color:var(--encre-3)}
|
||||||
|
.kpi.alarme{border-color:color-mix(in srgb,var(--danger) 45%,transparent)}
|
||||||
|
.kpi.alarme .val{color:var(--danger)}
|
||||||
|
.anneau{display:flex;align-items:center;gap:12px}
|
||||||
|
.anneau svg{flex:none}
|
||||||
|
|
||||||
|
/* Pastilles statut / priorité */
|
||||||
|
.st{display:inline-flex;align-items:center;gap:6px;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:700}
|
||||||
|
.st::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor}
|
||||||
|
.st.ouvert{color:var(--st-ouvert);background:var(--st-ouvert-fond)}
|
||||||
|
.st.encours{color:var(--st-encours);background:var(--st-encours-fond)}
|
||||||
|
.st.attente{color:var(--st-attente);background:var(--st-attente-fond)}
|
||||||
|
.st.termine{color:var(--st-termine);background:var(--st-termine-fond)}
|
||||||
|
.st.annule{color:var(--st-annule);background:var(--st-annule-fond)}
|
||||||
|
.prio{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600}
|
||||||
|
.prio i{width:8px;height:8px;border-radius:50%;flex:none}
|
||||||
|
.prio.bloque{color:var(--prio-bloque)}.prio.bloque i{background:var(--prio-bloque);animation:pulse 1.4s ease-out infinite}
|
||||||
|
.prio.haute{color:var(--prio-haute)}.prio.haute i{background:var(--prio-haute)}
|
||||||
|
.prio.moyenne{color:var(--prio-moyenne)}.prio.moyenne i{background:var(--prio-moyenne)}
|
||||||
|
.prio.basse{color:var(--prio-basse)}.prio.basse i{background:var(--prio-basse)}
|
||||||
|
|
||||||
|
/* Bandeau urgence */
|
||||||
|
.bandeau-urgence{display:flex;align-items:center;gap:14px;background:var(--prio-bloque-fond);
|
||||||
|
border:1px solid color-mix(in srgb,var(--prio-bloque) 35%,transparent);border-left:4px solid var(--prio-bloque);
|
||||||
|
border-radius:var(--rayon);padding:12px 16px}
|
||||||
|
.bandeau-urgence b{color:var(--prio-bloque)}
|
||||||
|
.bandeau-urgence .depuis{color:var(--encre-2);font-size:12.5px}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.table{width:100%;overflow-x:auto}
|
||||||
|
.table table{width:100%;font-size:13.5px}
|
||||||
|
.table th{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);text-align:left;
|
||||||
|
padding:9px 12px;border-bottom:1px solid var(--bordure);background:var(--surface-2);white-space:nowrap}
|
||||||
|
.table td{padding:9px 12px;border-bottom:1px solid var(--bordure);vertical-align:middle}
|
||||||
|
.table tbody tr:hover{background:var(--primaire-doux)}
|
||||||
|
.table .ref{font-weight:700;color:var(--primaire)}
|
||||||
|
.table .sous{color:var(--encre-2);font-size:12px;display:block}
|
||||||
|
.ligne-bloque td:first-child{box-shadow:inset 3px 0 0 var(--prio-bloque)}
|
||||||
|
.avatars{display:flex}
|
||||||
|
.avatars .avatar{width:24px;height:24px;font-size:10px;border:2px solid var(--surface);margin-left:-6px}
|
||||||
|
.avatars .avatar:first-child{margin-left:0}
|
||||||
|
|
||||||
|
/* Filtres */
|
||||||
|
.filtres{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||||
|
.select{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--bordure-forte);
|
||||||
|
border-radius:8px;padding:7px 11px;font-size:13px;background:var(--surface);color:var(--encre-2)}
|
||||||
|
.select b{color:var(--encre);font-weight:600}
|
||||||
|
|
||||||
|
/* Graphiques */
|
||||||
|
.graf{display:flex;flex-direction:column;gap:10px}
|
||||||
|
.barres-h .ligne-b{display:grid;grid-template-columns:110px 1fr 40px;align-items:center;gap:10px;font-size:13px}
|
||||||
|
.barres-h .fond-b{background:var(--graf-grille);border-radius:4px;height:14px;overflow:hidden}
|
||||||
|
.barres-h .rempli{height:100%;border-radius:4px 0 0 4px}
|
||||||
|
.barres-h .valeur{text-align:right;font-weight:600}
|
||||||
|
.legende{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--encre-2)}
|
||||||
|
.legende span{display:inline-flex;align-items:center;gap:6px}
|
||||||
|
.legende i{width:9px;height:9px;border-radius:3px}
|
||||||
|
|
||||||
|
/* Connexion */
|
||||||
|
.conn{min-height:640px;display:flex;align-items:center;justify-content:center;background:
|
||||||
|
linear-gradient(160deg,var(--fond) 55%,color-mix(in srgb,var(--primaire) 7%,var(--fond)))}
|
||||||
|
.carte-conn{width:400px;max-width:94%;background:var(--surface);border:1px solid var(--bordure);
|
||||||
|
border-radius:14px;padding:32px;display:flex;flex-direction:column;gap:14px}
|
||||||
|
.carte-conn .logo{padding:0 0 4px;color:var(--encre)}
|
||||||
|
.carte-conn .logo small{color:var(--encre-2)}
|
||||||
|
.champ label{display:block;font-size:12.5px;font-weight:600;color:var(--encre-2);margin-bottom:5px}
|
||||||
|
.champ input{width:100%;padding:9px 12px;border:1px solid var(--bordure-forte);border-radius:8px;
|
||||||
|
font:inherit;background:var(--surface);color:var(--encre)}
|
||||||
|
.champ input::placeholder{color:var(--encre-3)}
|
||||||
|
.sep{display:flex;align-items:center;gap:10px;color:var(--encre-3);font-size:11.5px;
|
||||||
|
letter-spacing:.06em;text-transform:uppercase;margin-top:6px}
|
||||||
|
.sep::before,.sep::after{content:"";flex:1;height:1px;background:var(--bordure)}
|
||||||
|
.demo-liste{display:flex;flex-direction:column;gap:6px}
|
||||||
|
.demo-liste button{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--bordure);
|
||||||
|
border-radius:8px;background:var(--surface);text-align:left;transition:border-color var(--transition)}
|
||||||
|
.demo-liste button:hover{border-color:var(--primaire);background:var(--primaire-doux)}
|
||||||
|
.demo-liste .role{margin-left:auto;font-size:11.5px;color:var(--encre-2)}
|
||||||
|
|
||||||
|
/* Fiche : listes de définitions */
|
||||||
|
.infos{display:grid;grid-template-columns:130px 1fr;row-gap:9px;font-size:13.5px}
|
||||||
|
.infos dt{color:var(--encre-2)}
|
||||||
|
.infos dd{font-weight:500}
|
||||||
|
.infos a{color:var(--primaire);text-decoration:none;font-weight:600}
|
||||||
|
.chrono{list-style:none;display:flex;flex-direction:column;gap:0;font-size:13px}
|
||||||
|
.chrono li{position:relative;padding:0 0 16px 22px}
|
||||||
|
.chrono li::before{content:"";position:absolute;left:5px;top:5px;width:8px;height:8px;border-radius:50%;
|
||||||
|
background:var(--bordure-forte)}
|
||||||
|
.chrono li.fait::before{background:var(--primaire)}
|
||||||
|
.chrono li::after{content:"";position:absolute;left:8.5px;top:15px;bottom:-2px;width:1px;background:var(--bordure)}
|
||||||
|
.chrono li:last-child::after{display:none}
|
||||||
|
.chrono .quand{color:var(--encre-3);font-size:11.5px}
|
||||||
|
.check{display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--bordure);font-size:13.5px}
|
||||||
|
.check:last-of-type{border-bottom:0}
|
||||||
|
.coche{width:19px;height:19px;border-radius:5px;border:1.5px solid var(--bordure-forte);flex:none;
|
||||||
|
display:flex;align-items:center;justify-content:center;color:#fff}
|
||||||
|
.coche.ok{background:var(--succes);border-color:var(--succes)}
|
||||||
|
.coche.na{background:var(--encre-3);border-color:var(--encre-3)}
|
||||||
|
.check .qui-fait{margin-left:auto;color:var(--encre-3);font-size:12px}
|
||||||
|
.total{display:flex;justify-content:space-between;font-weight:700;padding-top:10px;font-size:14px}
|
||||||
|
|
||||||
|
/* Bilan codé */
|
||||||
|
.bilan{display:grid;grid-template-columns:1fr 1fr;gap:10px 14px}
|
||||||
|
@media(max-width:900px){.bilan{grid-template-columns:1fr}}
|
||||||
|
.bilan .champ-b{font-size:12.5px}
|
||||||
|
.bilan .champ-b label{color:var(--encre-2);font-weight:600;display:block;margin-bottom:4px}
|
||||||
|
.bilan .champ-b label em{color:var(--danger);font-style:normal}
|
||||||
|
.faux-select{display:flex;justify-content:space-between;align-items:center;border:1px solid var(--bordure-forte);
|
||||||
|
border-radius:8px;padding:8px 11px;background:var(--surface);color:var(--encre)}
|
||||||
|
.faux-select.vide{color:var(--encre-3)}
|
||||||
|
|
||||||
|
/* Organes / documents */
|
||||||
|
.item-liste{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--bordure);font-size:13.5px}
|
||||||
|
.item-liste:last-child{border-bottom:0}
|
||||||
|
.item-liste .ico{width:34px;height:34px;border-radius:8px;background:var(--surface-2);display:flex;
|
||||||
|
align-items:center;justify-content:center;color:var(--encre-2);flex:none}
|
||||||
|
.item-liste .ico svg{width:16px;height:16px;stroke-width:1.75}
|
||||||
|
.item-liste .fin{margin-left:auto;display:flex;align-items:center;gap:10px;color:var(--encre-2);font-size:12.5px}
|
||||||
|
.qr{display:flex;gap:16px;align-items:center}
|
||||||
|
.qr svg{border:1px solid var(--bordure);border-radius:8px;background:#fff;padding:8px;flex:none}
|
||||||
|
|
||||||
|
/* Portail demandeur */
|
||||||
|
.portail{max-width:430px;margin:28px auto;display:flex;flex-direction:column;gap:14px}
|
||||||
|
.gros-toggle{display:flex;align-items:center;gap:12px;background:var(--prio-bloque-fond);
|
||||||
|
border:1px solid color-mix(in srgb,var(--prio-bloque) 30%,transparent);border-radius:10px;padding:12px 14px}
|
||||||
|
.gros-toggle b{color:var(--prio-bloque)}
|
||||||
|
.interrupteur{margin-left:auto;width:44px;height:24px;border-radius:999px;background:var(--bordure-forte);position:relative;flex:none}
|
||||||
|
.interrupteur::after{content:"";position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;background:#fff}
|
||||||
|
.zone-photo{border:1.5px dashed var(--bordure-forte);border-radius:10px;padding:18px;text-align:center;color:var(--encre-2);font-size:13px}
|
||||||
|
.suivi-simple{display:flex;align-items:center;gap:0;font-size:11.5px;color:var(--encre-3);margin-top:6px}
|
||||||
|
.suivi-simple .etape{display:flex;align-items:center;gap:6px}
|
||||||
|
.suivi-simple .etape.fait{color:var(--succes);font-weight:700}
|
||||||
|
.suivi-simple .lien-e{width:26px;height:1px;background:var(--bordure-forte);margin:0 6px}
|
||||||
|
|
||||||
|
/* Mobile */
|
||||||
|
.tels{display:flex;gap:24px;justify-content:center;flex-wrap:wrap}
|
||||||
|
.tel{width:300px;border:1px solid var(--bordure-forte);border-radius:28px;overflow:hidden;background:var(--fond);
|
||||||
|
box-shadow:var(--ombre-menu)}
|
||||||
|
.tel .encoche{height:26px;background:var(--nav-fond);display:flex;justify-content:center;align-items:center}
|
||||||
|
.tel .encoche i{width:70px;height:7px;border-radius:999px;background:rgb(255 255 255/.18)}
|
||||||
|
.tel .ecran-tel{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:520px}
|
||||||
|
.tel h3{font-size:16px;font-weight:700;display:flex;align-items:center;gap:8px}
|
||||||
|
.tel h3 .sync{margin-left:auto;font-size:10.5px;font-weight:700;color:var(--st-termine);
|
||||||
|
background:var(--st-termine-fond);padding:2px 8px;border-radius:999px}
|
||||||
|
.tel h3 .sync.off{color:var(--st-attente);background:var(--st-attente-fond)}
|
||||||
|
.carte-ot-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:12px;padding:12px}
|
||||||
|
.carte-ot-tel .type-t{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);
|
||||||
|
font-weight:700;display:flex;justify-content:space-between;align-items:center}
|
||||||
|
.carte-ot-tel b{display:block;margin:3px 0 2px;font-size:14.5px}
|
||||||
|
.carte-ot-tel .ou{color:var(--encre-2);font-size:12.5px}
|
||||||
|
.btn-tel{display:flex;justify-content:center;align-items:center;gap:8px;padding:12px;border-radius:10px;
|
||||||
|
font-weight:700;font-size:14.5px;background:var(--primaire);color:#fff;width:100%}
|
||||||
|
.btn-tel.vert{background:var(--succes)}
|
||||||
|
.btn-tel.gris{background:var(--bordure-forte);color:var(--surface)}
|
||||||
|
.paire-boutons{display:flex;gap:8px}
|
||||||
|
.btn-choix{flex:1;text-align:center;padding:9px;border:1.5px solid var(--bordure-forte);border-radius:9px;font-weight:600;font-size:13px}
|
||||||
|
.btn-choix.actif{border-color:var(--primaire);background:var(--primaire-doux);color:var(--primaire)}
|
||||||
|
.viseur{flex:1;min-height:330px;border-radius:12px;background:
|
||||||
|
radial-gradient(circle at 50% 45%, #2c3a52 0%, #131c2c 70%);position:relative;display:flex;
|
||||||
|
align-items:center;justify-content:center}
|
||||||
|
.viseur .cadre-qr{width:170px;height:170px;border-radius:14px;border:2.5px solid var(--safran);
|
||||||
|
box-shadow:0 0 0 2000px rgb(9 14 22/.45)}
|
||||||
|
.viseur p{position:absolute;bottom:14px;color:#dfe7f2;font-size:12.5px}
|
||||||
|
|
||||||
|
/* Design system : nuanciers */
|
||||||
|
.nuancier{display:flex;gap:8px;flex-wrap:wrap}
|
||||||
|
.puce-c{width:84px;font-size:10.5px;color:var(--encre-2)}
|
||||||
|
.puce-c i{display:block;height:40px;border-radius:8px;border:1px solid var(--bordure);margin-bottom:4px}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
/* ═══ Ajouts R1 (à reverser dans l'app après validation) ═══ */
|
||||||
|
/* Statuts d'ÉQUIPEMENT (distincts des statuts OT) */
|
||||||
|
.st.arret{color:var(--danger);background:var(--prio-bloque-fond)}
|
||||||
|
.st.maintenance{color:var(--st-encours);background:var(--st-encours-fond)}
|
||||||
|
|
||||||
|
/* Carte géographique (placeholder — Leaflet/OSM + PostGIS dans l'app) */
|
||||||
|
.geo{position:relative;border:1px solid var(--bordure);border-radius:var(--rayon);overflow:hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--graf-grille) 1px,transparent 1px),
|
||||||
|
linear-gradient(90deg,var(--graf-grille) 1px,transparent 1px),
|
||||||
|
linear-gradient(160deg,var(--surface-2),var(--surface));
|
||||||
|
background-size:34px 34px,34px 34px,100% 100%;min-height:320px}
|
||||||
|
.geo .voie{position:absolute;background:var(--bordure);border-radius:2px}
|
||||||
|
.geo .pin{position:absolute;display:flex;flex-direction:column;align-items:center;transform:translate(-50%,-100%)}
|
||||||
|
.geo .pin i{width:26px;height:26px;border-radius:50% 50% 50% 0;transform:rotate(-45deg);
|
||||||
|
background:var(--primaire);border:2px solid var(--surface);box-shadow:var(--ombre-menu)}
|
||||||
|
.geo .pin i::after{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--surface)}
|
||||||
|
.geo .pin.alerte i{background:var(--prio-bloque)}
|
||||||
|
.geo .pin b{margin-top:5px;font-size:11px;background:var(--surface);border:1px solid var(--bordure);
|
||||||
|
padding:2px 7px;border-radius:999px;white-space:nowrap}
|
||||||
|
.geo .attribution{position:absolute;right:8px;bottom:6px;font-size:10px;color:var(--encre-3)}
|
||||||
|
|
||||||
|
/* Arborescence des emplacements */
|
||||||
|
.arbre{list-style:none;padding:0;font-size:13.5px}
|
||||||
|
.arbre li{position:relative;padding:7px 0 7px 26px;border-bottom:1px solid var(--bordure)}
|
||||||
|
.arbre li:last-child{border-bottom:0}
|
||||||
|
.arbre li::before{content:"";position:absolute;left:8px;top:0;bottom:0;width:1px;background:var(--bordure)}
|
||||||
|
.arbre li::after{content:"";position:absolute;left:8px;top:50%;width:12px;height:1px;background:var(--bordure)}
|
||||||
|
.arbre li.racine{padding-left:0}
|
||||||
|
.arbre li.racine::before,.arbre li.racine::after{display:none}
|
||||||
|
.arbre .n2{margin-left:22px}
|
||||||
|
.arbre .meta{color:var(--encre-2);font-size:12px;margin-left:8px}
|
||||||
|
.arbre .fin{float:right;color:var(--encre-2);font-size:12px}
|
||||||
|
|
||||||
|
/* Formulaires R1 */
|
||||||
|
.form-grille{display:grid;grid-template-columns:1fr 1fr;gap:12px 14px}
|
||||||
|
@media(max-width:900px){.form-grille{grid-template-columns:1fr}}
|
||||||
|
.form-grille .pleine-l{grid-column:1/-1}
|
||||||
|
.ligne-organe{display:flex;gap:8px;align-items:center;padding:8px 0;border-bottom:1px solid var(--bordure)}
|
||||||
|
.ligne-organe:last-of-type{border-bottom:0}
|
||||||
|
.ligne-organe .faux-select{flex:1}
|
||||||
|
.btn-suppr{color:var(--encre-3);padding:6px;border-radius:6px}
|
||||||
|
.btn-suppr:hover{color:var(--danger);background:var(--prio-bloque-fond)}
|
||||||
|
|
||||||
|
/* Étiquette QR imprimable (A6) */
|
||||||
|
.zone-impression{display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap}
|
||||||
|
.etiquette{width:340px;background:#fff;color:#1b2534;border:1px solid var(--bordure-forte);
|
||||||
|
border-radius:12px;padding:22px;display:flex;flex-direction:column;gap:12px;
|
||||||
|
box-shadow:var(--ombre-menu)}
|
||||||
|
.etiquette .tete{display:flex;align-items:center;gap:10px;border-bottom:2px solid #1b2534;padding-bottom:10px}
|
||||||
|
.etiquette .tete .marque{display:flex;flex-direction:column;line-height:.55;font-size:12px;color:#dd8a0b}
|
||||||
|
.etiquette .tete b{font-size:20px;letter-spacing:.04em}
|
||||||
|
.etiquette .corps{display:flex;gap:16px;align-items:center}
|
||||||
|
.etiquette .qr-grand{flex:none;border:1px solid #dce3ec;border-radius:8px;padding:6px;background:#fff}
|
||||||
|
.etiquette h3{font-size:15px;font-weight:700;margin:0}
|
||||||
|
.etiquette .ou{font-size:12px;color:#55647a}
|
||||||
|
.etiquette .consigne{font-size:12.5px;background:#fdf3e3;border:1px dashed #dd8a0b;color:#8a5a06;
|
||||||
|
border-radius:8px;padding:8px 10px;text-align:center;font-weight:600}
|
||||||
|
.etiquette .ref{font-size:11px;color:#8494ab;text-align:center;font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
/* Modale (invitation) — présentée ouverte dans la maquette */
|
||||||
|
.voile{background:color-mix(in srgb,var(--encre) 32%,transparent);border-radius:var(--rayon);
|
||||||
|
padding:34px;display:flex;align-items:flex-start;justify-content:center}
|
||||||
|
.modale{width:430px;max-width:100%;background:var(--surface);border:1px solid var(--bordure);
|
||||||
|
border-radius:12px;box-shadow:var(--ombre-menu);padding:20px;display:flex;flex-direction:column;gap:12px}
|
||||||
|
.modale h3{font-size:15px;font-weight:700}
|
||||||
|
.modale .pied{display:flex;justify-content:flex-end;gap:8px;margin-top:4px}
|
||||||
|
|
||||||
|
/* Équipes */
|
||||||
|
.equipes{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
||||||
|
@media(max-width:1000px){.equipes{grid-template-columns:1fr}}
|
||||||
|
.equipe{border:1px solid var(--bordure);border-radius:var(--rayon);padding:12px 14px;display:flex;
|
||||||
|
flex-direction:column;gap:8px;background:var(--surface)}
|
||||||
|
.equipe b{font-size:13.5px}
|
||||||
|
.equipe .zone{font-size:12px;color:var(--encre-2)}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
/* ═══ Ajouts R3 (à reverser dans l'app après validation) ═══ */
|
||||||
|
/* Statuts de BON DE COMMANDE */
|
||||||
|
.st.brouillon{color:var(--st-annule);background:var(--st-annule-fond)}
|
||||||
|
.st.envoye{color:var(--st-ouvert);background:var(--st-ouvert-fond)}
|
||||||
|
.st.recu{color:var(--st-termine);background:var(--st-termine-fond)}
|
||||||
|
|
||||||
|
/* Alerte de stock sous seuil */
|
||||||
|
.stock-bas{display:inline-flex;align-items:center;gap:6px;color:var(--alerte);font-weight:700;font-size:12.5px}
|
||||||
|
.stock-bas::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--alerte)}
|
||||||
|
.ligne-seuil td:first-child{box-shadow:inset 3px 0 0 var(--alerte)}
|
||||||
|
|
||||||
|
/* Mouvements de stock : entrée / sortie */
|
||||||
|
.mvt{display:inline-flex;align-items:center;gap:5px;font-weight:700;font-size:12.5px}
|
||||||
|
.mvt.entree{color:var(--succes)}
|
||||||
|
.mvt.sortie{color:var(--danger)}
|
||||||
|
|
||||||
|
/* Documents (bibliothèque) */
|
||||||
|
.docs{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
|
||||||
|
@media(max-width:1100px){.docs{grid-template-columns:repeat(2,1fr)}}
|
||||||
|
.doc{border:1px solid var(--bordure);border-radius:var(--rayon);background:var(--surface);
|
||||||
|
padding:12px;display:flex;flex-direction:column;gap:8px;font-size:12.5px}
|
||||||
|
.doc .vignette{height:84px;border-radius:8px;background:var(--surface-2);display:flex;
|
||||||
|
align-items:center;justify-content:center;color:var(--encre-3);font-size:24px}
|
||||||
|
.doc b{font-size:13px;word-break:break-all}
|
||||||
|
.doc .meta{color:var(--encre-2);font-size:11.5px}
|
||||||
|
.type-doc{display:inline-flex;font-size:10.5px;font-weight:700;letter-spacing:.05em;
|
||||||
|
text-transform:uppercase;color:var(--primaire);background:var(--primaire-doux);
|
||||||
|
padding:2px 8px;border-radius:999px;align-self:flex-start}
|
||||||
|
|
||||||
|
/* Total & lignes de coûts */
|
||||||
|
.total{display:flex;justify-content:space-between;font-weight:700;padding-top:10px;font-size:14px}
|
||||||
|
.item-liste .ico{width:34px;height:34px;border-radius:8px;background:var(--surface-2);display:flex;
|
||||||
|
align-items:center;justify-content:center;color:var(--encre-2);flex:none}
|
||||||
|
.item-liste .ico svg{width:16px;height:16px;stroke-width:1.75}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- ═══════════ BARRE DE REVUE ═══════════ -->
|
||||||
|
<header class="revue">
|
||||||
|
<b>SIOP V2 · Maquettes R3 — Gestion</b>
|
||||||
|
<nav class="onglets" role="tablist" aria-label="Écrans">
|
||||||
|
<button role="tab" aria-selected="true" data-cible="stock">1 · Stock</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="fiche-piece">2 · Fiche pièce</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="bons">3 · Bons de commande</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="couts-ot">4 · Coûts sur OT</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="statistiques">5 · Statistiques</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="tiers">6 · Tiers</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="bibliotheque">7 · Bibliothèque</button>
|
||||||
|
</nav>
|
||||||
|
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ═══════════ 1 · STOCK ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="stock"><b>Intention :</b> le stock est <b>DÉRIVÉ des mouvements</b> (décision v1 éprouvée) — jamais de champ « quantité » éditable. Sous le seuil : strie ambre + alerte, et le bouton « préparer le BC » prérempli. Prix unitaire = dernier prix d'achat (figé à chaque consommation).</p>
|
||||||
|
<section class="ecran actif" id="stock">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Ressources</div>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3.3 8.3L12 13l8.7-4.7M12 13v9"/></svg>Stock & achats</a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 21a7 7 0 1 1 6-11l6 6v3h-3l-1-1-2 1-1-2-2 1z" transform="rotate(180 12 12)"/><circle cx="9" cy="9" r="1.5"/></svg>Tiers</a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Fichiers</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher une pièce…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-4)">NB</span>
|
||||||
|
<div class="qui"><b>Nadia Berrada</b><br><small>Gestionnaire ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Stock de pièces</h1><span class="filajout">42 références · <span class="stock-bas">2 sous le seuil</span></span>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn">Entrée de stock</button>
|
||||||
|
<button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouvelle pièce</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filtres">
|
||||||
|
<span class="select">Alerte : <b>Toutes</b> ▾</span>
|
||||||
|
<span class="select">Fournisseur : <b>Tous</b> ▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="carte" style="padding:0"><div class="table"><table>
|
||||||
|
<thead><tr><th>Réf.</th><th>Désignation</th><th>Stock</th><th>Seuil</th><th>Dernier PU</th><th>Fournisseur</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="ligne-seuil"><td class="ref num">P-0113</td><td>Contact de porte NC-31</td><td class="num"><b>2</b></td><td class="num">5</td><td class="num">85 MAD</td><td>Ascentech Maroc</td><td><span class="stock-bas">Sous le seuil</span> · <a href="#" style="color:var(--primaire);font-weight:600">Préparer le BC</a></td></tr>
|
||||||
|
<tr class="ligne-seuil"><td class="ref num">P-0087</td><td>Coulisseau de guide 16 mm</td><td class="num"><b>4</b></td><td class="num">6</td><td class="num">120 MAD</td><td>Ascentech Maroc</td><td><span class="stock-bas">Sous le seuil</span> · <a href="#" style="color:var(--primaire);font-weight:600">Préparer le BC</a></td></tr>
|
||||||
|
<tr><td class="ref num">P-0042</td><td>Graisse guide (cartouche 400 g)</td><td class="num">18</td><td class="num">8</td><td class="num">85 MAD</td><td>Lubmaroc</td><td></td></tr>
|
||||||
|
<tr><td class="ref num">P-0031</td><td>Bouton palier lumineux Ø22</td><td class="num">26</td><td class="num">10</td><td class="num">45 MAD</td><td>Ascentech Maroc</td><td></td></tr>
|
||||||
|
<tr><td class="ref num">P-0019</td><td>Cellule barrière porte (paire)</td><td class="num">7</td><td class="num">4</td><td class="num">640 MAD</td><td>SchindlerParts</td><td></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div></div>
|
||||||
|
<div class="carte" style="border-style:dashed;color:var(--encre-2);font-size:13px">
|
||||||
|
Le stock affiché = somme des mouvements (réceptions de BC, entrées manuelles, consommations d'OT,
|
||||||
|
ajustements d'inventaire motivés). <b>Aucune saisie directe de quantité.</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 2 · FICHE PIÈCE ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="fiche-piece" hidden><b>Intention :</b> l'histoire d'une pièce — chaque ligne de stock vient d'un mouvement daté, tracé, motivé (réception BC, consommation OT, ajustement). Le lien vers l'OT ou le BC est toujours cliquable.</p>
|
||||||
|
<section class="ecran" id="fiche-piece">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Ressources</div>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3.3 8.3L12 13l8.7-4.7M12 13v9"/></svg>Stock & achats</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-4)">NB</span>
|
||||||
|
<div class="qui"><b>Nadia Berrada</b><br><small>Gestionnaire ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="filajout"><a href="#">Stock</a> / P-0113</div>
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Contact de porte NC-31</h1><span class="stock-bas">Sous le seuil</span>
|
||||||
|
<div class="actions"><button class="btn">Ajustement d'inventaire</button><button class="btn prim">Préparer le BC</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="grille-2">
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Identité</h2>
|
||||||
|
<dl class="infos">
|
||||||
|
<dt>Référence</dt><dd class="num">P-0113</dd>
|
||||||
|
<dt>Stock actuel</dt><dd class="num"><b>2</b> (seuil : 5)</dd>
|
||||||
|
<dt>Dernier PU</dt><dd class="num">85 MAD <span style="color:var(--encre-2)">(BC-2026-0021, 12 juin)</span></dd>
|
||||||
|
<dt>Fournisseur</dt><dd><a href="#">Ascentech Maroc</a></dd>
|
||||||
|
<dt>Compatible</dt><dd>Otis Gen2 · Schindler 3300</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Mouvements <span style="text-transform:none;letter-spacing:0;color:var(--encre-3)">— le stock EST cette liste</span></h2>
|
||||||
|
<div class="table"><table>
|
||||||
|
<thead><tr><th>Date</th><th>Mouvement</th><th>Qté</th><th>Origine</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="num">14 juil.</td><td><span class="mvt sortie">− Sortie</span></td><td class="num">1</td><td><a href="#" class="ref num">OT-2026-0336</a> · Ahmed</td></tr>
|
||||||
|
<tr><td class="num">2 juil.</td><td><span class="mvt sortie">− Sortie</span></td><td class="num">2</td><td><a href="#" class="ref num">OT-2026-0329</a> · Fatima-Zahra</td></tr>
|
||||||
|
<tr><td class="num">12 juin</td><td><span class="mvt entree">+ Réception</span></td><td class="num">5</td><td><a href="#" class="ref num">BC-2026-0021</a> · 85 MAD/u</td></tr>
|
||||||
|
<tr><td class="num">3 mai</td><td><span class="mvt sortie">− Ajustement</span></td><td class="num">1</td><td>Inventaire — pièce endommagée (Nadia)</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 3 · BONS DE COMMANDE ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="bons" hidden><b>Intention :</b> le circuit d'achat minimal : Brouillon → Envoyé → Reçu. <b>La réception crée les mouvements d'entrée</b> (et fige le PU). Le BC préparé depuis une alerte arrive prérempli.</p>
|
||||||
|
<section class="ecran" id="bons">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Ressources</div>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3.3 8.3L12 13l8.7-4.7M12 13v9"/></svg>Stock & achats</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-4)">NB</span>
|
||||||
|
<div class="qui"><b>Nadia Berrada</b><br><small>Gestionnaire ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Bons de commande</h1>
|
||||||
|
<div class="actions"><button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouveau BC</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="grille-2" style="grid-template-columns:1.1fr 1fr">
|
||||||
|
<div class="carte" style="padding:0"><div class="table"><table>
|
||||||
|
<thead><tr><th>N°</th><th>Fournisseur</th><th>Lignes</th><th>Total</th><th>Statut</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr style="background:var(--primaire-doux)"><td class="ref num">BC-2026-0024</td><td>Ascentech Maroc</td><td class="num">2</td><td class="num">1 105 MAD</td><td><span class="st envoye">Envoyé</span></td></tr>
|
||||||
|
<tr><td class="ref num">BC-2026-0023</td><td>SchindlerParts</td><td class="num">1</td><td class="num">1 280 MAD</td><td><span class="st brouillon">Brouillon</span></td></tr>
|
||||||
|
<tr><td class="ref num">BC-2026-0021</td><td>Ascentech Maroc</td><td class="num">3</td><td class="num">1 940 MAD</td><td><span class="st recu">Reçu</span></td></tr>
|
||||||
|
<tr><td class="ref num">BC-2026-0019</td><td>Lubmaroc</td><td class="num">1</td><td class="num">850 MAD</td><td><span class="st recu">Reçu</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div></div>
|
||||||
|
<div class="panneau">
|
||||||
|
<h2 style="font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--encre-2)">BC-2026-0024 — Ascentech Maroc</h2>
|
||||||
|
<div class="table"><table>
|
||||||
|
<thead><tr><th>Pièce</th><th>Qté</th><th>PU</th><th>Total</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Contact de porte NC-31</td><td class="num">10</td><td class="num">85</td><td class="num">850</td></tr>
|
||||||
|
<tr><td>Coulisseau de guide 16 mm</td><td class="num">2</td><td class="num">127,50</td><td class="num">255</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
<div class="total"><span>Total</span><span class="num">1 105 MAD</span></div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button class="btn danger-l">Annuler le BC</button>
|
||||||
|
<button class="btn succes">Réceptionner (→ entrées de stock)</button>
|
||||||
|
</div>
|
||||||
|
<p style="font-size:12px;color:var(--encre-3)">La réception crée un mouvement d'entrée par ligne et fige le PU — le stock remonte, l'alerte s'éteint.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 4 · COÛTS SUR OT ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="couts-ot" hidden><b>Intention :</b> la carte « Pièces & main-d'œuvre » (fiche OT validée R0) devient réelle : consommer une pièce <b>décrémente le stock et FIGE le prix</b> ; la main-d'œuvre = heures × <b>taux figé</b> de la personne (géré dans Personnes). Le coût total suit l'OT pour toujours, même si prix et taux changent ensuite.</p>
|
||||||
|
<section class="ecran" id="couts-ot">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Exploitation</div>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M14.7 6.3a4.5 4.5 0 0 0-6 6L3 18v3h3l5.7-5.7a4.5 4.5 0 0 0 6-6l-2.6 2.6-2.4-2.4z"/></svg>Ordres de travail</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-3)">AB</span>
|
||||||
|
<div class="qui"><b>Ahmed Benali</b><br><small>Technicien ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="filajout"><a href="#">Ordres de travail</a> / OT-2026-0341</div>
|
||||||
|
<div class="entete-page"><h1>Bruit anormal en gaine</h1><span class="st encours">En cours</span></div>
|
||||||
|
<div class="grille-2">
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Pièces & main-d'œuvre</h2>
|
||||||
|
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8z"/></svg></span>Coulisseau de guide 16 mm × 2<span class="fin num">240 MAD</span></div>
|
||||||
|
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8z"/></svg></span>Graisse guide (cartouche) × 1<span class="fin num">85 MAD</span></div>
|
||||||
|
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg></span>Ahmed Benali · 1 h 30 × 120 MAD/h<span class="fin num">180 MAD</span></div>
|
||||||
|
<div class="total"><span>Coût total de l'OT</span><span class="num">505 MAD</span></div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:10px">
|
||||||
|
<button class="btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Consommer une pièce</button>
|
||||||
|
<button class="btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>Saisir du temps</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="voile" style="border-radius:var(--rayon)">
|
||||||
|
<div class="modale" style="position:static;transform:none;width:380px">
|
||||||
|
<h3>Consommer une pièce</h3>
|
||||||
|
<div class="champ"><label>Pièce</label><div class="faux-select">Coulisseau de guide 16 mm — stock : 4 <span>▾</span></div></div>
|
||||||
|
<div class="champ"><label for="cp-qte">Quantité</label><input id="cp-qte" value="2" class="num" style="width:90px"></div>
|
||||||
|
<div class="note-douce" style="font-size:12px;background:var(--surface-2);border-radius:8px;padding:8px 10px;color:var(--encre-2)">
|
||||||
|
Prix figé à la consommation : <b class="num">120 MAD/u</b> (dernier prix d'achat).
|
||||||
|
Le stock passera à <b class="num">2</b> — sous le seuil, une alerte partira.
|
||||||
|
</div>
|
||||||
|
<div class="pied"><button class="btn">Annuler</button><button class="btn prim">Consommer (− stock, prix figé)</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 5 · STATISTIQUES ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="statistiques" hidden><b>Intention :</b> M. Bennis lit en 30 s : coûts, pannes par organe (ENFIN alimenté par les bilans codés), taux de préventif, top équipements. Palette graphique validée CVD, étiquettes directes, chiffres tabulaires.</p>
|
||||||
|
<section class="ecran" id="statistiques">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Pilotage</div>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 3v18h18"/><rect x="7" y="12" width="3" height="6"/><rect x="12" y="8" width="3" height="10"/><rect x="17" y="5" width="3" height="13"/></svg>Statistiques</a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0M17 8a3 3 0 1 1 0 .01M16.5 13.5a6 6 0 0 1 5 6"/></svg>Personnes</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--safran)">RB</span>
|
||||||
|
<div class="qui"><b>Rachid Bennis</b><br><small>Vue seule ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Statistiques</h1>
|
||||||
|
<div class="actions"><span class="select">Période : <b>12 derniers mois</b> ▾</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpis" style="grid-template-columns:repeat(4,1fr)">
|
||||||
|
<div class="kpi"><div class="lib">Coût · juillet</div><div class="val num">18 450 <span style="font-size:14px;color:var(--encre-2)">MAD</span></div><div class="delta bon">−12 % vs juin</div></div>
|
||||||
|
<div class="kpi"><div class="lib">OT clôturés · 12 mois</div><div class="val num">241</div><div class="delta neutre">dont 148 préventifs</div></div>
|
||||||
|
<div class="kpi"><div class="lib">Taux de préventif</div><div class="val num">92 %</div><div class="delta bon">grilles faites / dues</div></div>
|
||||||
|
<div class="kpi"><div class="lib">Durée moyenne de résolution</div><div class="val num">2,4 j</div><div class="delta neutre">dépannages, 12 mois</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="grille-2">
|
||||||
|
<div class="carte graf">
|
||||||
|
<h2>Pannes par organe · 12 mois <span style="text-transform:none;letter-spacing:0;color:var(--encre-3)">(bilans codés)</span></h2>
|
||||||
|
<div class="barres-h">
|
||||||
|
<div class="ligne-b"><span>Portes</span><div class="fond-b"><div class="rempli" style="width:100%;background:var(--graf-1)"></div></div><span class="valeur num">34</span></div>
|
||||||
|
<div class="ligne-b"><span>Armoire cde</span><div class="fond-b"><div class="rempli" style="width:53%;background:var(--graf-1)"></div></div><span class="valeur num">18</span></div>
|
||||||
|
<div class="ligne-b"><span>Boutons</span><div class="fond-b"><div class="rempli" style="width:35%;background:var(--graf-1)"></div></div><span class="valeur num">12</span></div>
|
||||||
|
<div class="ligne-b"><span>Guides</span><div class="fond-b"><div class="rempli" style="width:26%;background:var(--graf-1)"></div></div><span class="valeur num">9</span></div>
|
||||||
|
<div class="ligne-b"><span>Treuil</span><div class="fond-b"><div class="rempli" style="width:21%;background:var(--graf-1)"></div></div><span class="valeur num">7</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte graf">
|
||||||
|
<h2>Coûts par mois <span style="text-transform:none;letter-spacing:0;color:var(--encre-3)">(pièces + main-d'œuvre, MAD)</span></h2>
|
||||||
|
<div class="barres-h">
|
||||||
|
<div class="ligne-b"><span>Mars</span><div class="fond-b"><div class="rempli" style="width:72%;background:var(--graf-2)"></div></div><span class="valeur num">21 k</span></div>
|
||||||
|
<div class="ligne-b"><span>Avril</span><div class="fond-b"><div class="rempli" style="width:60%;background:var(--graf-2)"></div></div><span class="valeur num">17 k</span></div>
|
||||||
|
<div class="ligne-b"><span>Mai</span><div class="fond-b"><div class="rempli" style="width:100%;background:var(--graf-2)"></div></div><span class="valeur num">29 k</span></div>
|
||||||
|
<div class="ligne-b"><span>Juin</span><div class="fond-b"><div class="rempli" style="width:73%;background:var(--graf-2)"></div></div><span class="valeur num">21 k</span></div>
|
||||||
|
<div class="ligne-b"><span>Juillet</span><div class="fond-b"><div class="rempli" style="width:64%;background:var(--graf-2)"></div></div><span class="valeur num">18,5 k</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Top équipements en coût · 12 mois</h2>
|
||||||
|
<div class="table"><table>
|
||||||
|
<thead><tr><th>Appareil</th><th>Site</th><th>Dépannages</th><th>Pièces</th><th>Main-d'œuvre</th><th>Total</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="ref num">B2</td><td>Tour Atlas</td><td class="num">9</td><td class="num">6 420</td><td class="num">3 240</td><td class="num"><b>9 660 MAD</b></td></tr>
|
||||||
|
<tr><td class="ref num">A1</td><td>Résidence Al Manar</td><td class="num">6</td><td class="num">2 210</td><td class="num">1 560</td><td class="num"><b>3 770 MAD</b></td></tr>
|
||||||
|
<tr><td class="ref num">C1</td><td>Anfa Place</td><td class="num">4</td><td class="num">1 890</td><td class="num">980</td><td class="num"><b>2 870 MAD</b></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 6 · TIERS ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="tiers" hidden><b>Intention :</b> l'annuaire minimal — fournisseurs de pièces et syndics/clients des sites. Un tiers se rattache aux BC (fournisseur) ou aux sites (syndic). Fiche simple, pas de CRM.</p>
|
||||||
|
<section class="ecran" id="tiers">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Ressources</div>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3.3 8.3L12 13l8.7-4.7M12 13v9"/></svg>Stock & achats</a>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 21a7 7 0 1 1 6-11l6 6v3h-3l-1-1-2 1-1-2-2 1z" transform="rotate(180 12 12)"/><circle cx="9" cy="9" r="1.5"/></svg>Tiers</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher un tiers…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-4)">NB</span>
|
||||||
|
<div class="qui"><b>Nadia Berrada</b><br><small>Gestionnaire ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Tiers</h1>
|
||||||
|
<div class="actions"><button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouveau tiers</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="carte" style="padding:0"><div class="table"><table>
|
||||||
|
<thead><tr><th>Nom</th><th>Type</th><th>Contact</th><th>Rattachements</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><b>Ascentech Maroc</b><span class="sous">Casablanca — pièces détachées</span></td><td>Fournisseur</td><td>M. Berrini · <span class="num">05 22 34 56 78</span></td><td class="num">2 BC en cours</td></tr>
|
||||||
|
<tr><td><b>SchindlerParts</b><span class="sous">Importateur agréé</span></td><td>Fournisseur</td><td>commandes@schindlerparts.ma</td><td class="num">1 BC en cours</td></tr>
|
||||||
|
<tr><td><b>Lubmaroc</b><span class="sous">Consommables</span></td><td>Fournisseur</td><td><span class="num">05 22 11 22 33</span></td><td class="num">—</td></tr>
|
||||||
|
<tr><td><b>Atlas Property Management</b><span class="sous">Syndic</span></td><td>Client / syndic</td><td>Mme Zerhouni · <span class="num">06 61 98 76 54</span></td><td>Tour Atlas</td></tr>
|
||||||
|
<tr><td><b>Syndic Al Manar</b></td><td>Client / syndic</td><td>M. Alami (gardien référent)</td><td>Résidence Al Manar</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 7 · BIBLIOTHÈQUE ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="bibliotheque" hidden><b>Intention :</b> notices, certificats et photos — <b>rattachés</b> à un appareil ou un OT, stockés MinIO derrière FileStorage. Types fermés, taille plafonnée, aperçu léger. La carte « Documents » des fiches devient réelle.</p>
|
||||||
|
<section class="ecran" id="bibliotheque">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Ressources</div>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M21 8l-9-5-9 5v8l9 5 9-5V8zM3.3 8.3L12 13l8.7-4.7M12 13v9"/></svg>Stock & achats</a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M9 21a7 7 0 1 1 6-11l6 6v3h-3l-1-1-2 1-1-2-2 1z" transform="rotate(180 12 12)"/><circle cx="9" cy="9" r="1.5"/></svg>Tiers</a>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>Fichiers</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher un document…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-4)">NB</span>
|
||||||
|
<div class="qui"><b>Nadia Berrada</b><br><small>Gestionnaire ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Bibliothèque</h1><span class="filajout">36 documents · 214 Mo</span>
|
||||||
|
<div class="actions"><button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 17V3M6 9l6-6 6 6M5 21h14"/></svg>Téléverser</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="filtres">
|
||||||
|
<span class="select">Type : <b>Tous</b> ▾</span>
|
||||||
|
<span class="select">Rattaché à : <b>Tout le parc</b> ▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="zone-photo" style="padding:22px">📁 Glissez un fichier ici — PDF, JPG, PNG · 20 Mo max · rattachez-le à un appareil ou un OT</div>
|
||||||
|
<div class="docs">
|
||||||
|
<div class="doc"><div class="vignette">📄</div><span class="type-doc">Notice</span><b>Notice Otis Gen2 Premier.pdf</b><span class="meta">Asc. A1 · 1,2 Mo · mars 2020</span></div>
|
||||||
|
<div class="doc"><div class="vignette">📄</div><span class="type-doc" style="color:var(--safran);background:var(--safran-doux)">Certificat</span><b>Certificat parachute 2026.pdf</b><span class="meta">Asc. A1 · 340 Ko · juil. 2026</span></div>
|
||||||
|
<div class="doc"><div class="vignette">🖼</div><span class="type-doc" style="color:var(--graf-3);background:var(--st-termine-fond)">Photo</span><b>photo-guide-frottement.jpg</b><span class="meta">OT-2026-0341 · 2,1 Mo · hier</span></div>
|
||||||
|
<div class="doc"><div class="vignette">📄</div><span class="type-doc">Notice</span><b>Schindler 3300 — manuel.pdf</b><span class="meta">Asc. B1 · B2 · 4,8 Mo</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="carte" style="border-style:dashed;color:var(--encre-2);font-size:13px">
|
||||||
|
Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) — le rattachement propre commence ici.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var onglets=document.querySelectorAll('.revue .onglets button');
|
||||||
|
var ecrans=document.querySelectorAll('.ecran');
|
||||||
|
var notes=document.querySelectorAll('.note-ecran');
|
||||||
|
onglets.forEach(function(b){b.addEventListener('click',function(){
|
||||||
|
onglets.forEach(function(x){x.setAttribute('aria-selected','false')});
|
||||||
|
b.setAttribute('aria-selected','true');
|
||||||
|
var cible=b.dataset.cible;
|
||||||
|
ecrans.forEach(function(e){e.classList.toggle('actif',e.id===cible)});
|
||||||
|
notes.forEach(function(n){n.hidden=(n.dataset.note!==cible)});
|
||||||
|
window.scrollTo({top:0});
|
||||||
|
})});
|
||||||
|
var ordre=['auto','light','dark'];var i=0;
|
||||||
|
var bt=document.getElementById('btn-theme');
|
||||||
|
bt.addEventListener('click',function(){
|
||||||
|
i=(i+1)%3;var m=ordre[i];
|
||||||
|
if(m==='auto'){document.documentElement.removeAttribute('data-theme')}
|
||||||
|
else{document.documentElement.setAttribute('data-theme',m)}
|
||||||
|
bt.textContent='Thème : '+(m==='auto'?'auto':(m==='light'?'clair':'sombre'));
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
3605
docs/openapi.json
3605
docs/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 l’API (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 l’OT — 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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
66
packages/shared/src/schemas/documents.ts
Normal file
66
packages/shared/src/schemas/documents.ts
Normal 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>;
|
||||||
@@ -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. */
|
||||||
|
|||||||
214
packages/shared/src/schemas/gestion.ts
Normal file
214
packages/shared/src/schemas/gestion.ts
Normal 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>;
|
||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
37
packages/shared/src/schemas/search.ts
Normal file
37
packages/shared/src/schemas/search.ts
Normal 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>;
|
||||||
@@ -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
10
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user