mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-09 13:11:54 +00:00
Compare commits
4 Commits
release/r0
...
release/r1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4d1ab7225 | ||
|
|
9778629827 | ||
|
|
266ffaaf1b | ||
|
|
6d9aafdab4 |
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -59,10 +59,10 @@ jobs:
|
|||||||
name: api (tests + couverture ≥ 70 %)
|
name: api (tests + couverture ≥ 70 %)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
# R0 : PostgreSQL nu suffit (r0_identity). Dès que des tests exigeront
|
# R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography).
|
||||||
# pgvector/PostGIS (R1+), passer sur l'image infra/postgres.
|
# pgvector arrivera en R5 (bascule alors sur l’image infra/postgres).
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18
|
image: postgis/postgis:18-3.6
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: siop
|
POSTGRES_USER: siop
|
||||||
POSTGRES_PASSWORD: siop
|
POSTGRES_PASSWORD: siop
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18
|
image: postgis/postgis:18-3.6
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: siop
|
POSTGRES_USER: siop
|
||||||
POSTGRES_PASSWORD: siop
|
POSTGRES_PASSWORD: siop
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
- ✅ **R0.11 `apps/web`** : React 19 + Vite + Tailwind v4, `tokens.css` copié tel quel + classes extraites de la maquette validée, Manrope auto-hébergée ; connexion + sélecteur démo (masqué si 404 — une seule source de vérité : l'API), coquille sidebar (rail safran, écrans futurs marqués R1-R3) / topbar (thème, chip DÉMO, sélecteur de rôle), page /design ; client typé `schema.d.ts` généré depuis `docs/openapi.json` et committé ; vérifié en navigateur réel (bascule de rôle 101 ms, bi-thème, 0 erreur console).
|
- ✅ **R0.11 `apps/web`** : React 19 + Vite + Tailwind v4, `tokens.css` copié tel quel + classes extraites de la maquette validée, Manrope auto-hébergée ; connexion + sélecteur démo (masqué si 404 — une seule source de vérité : l'API), coquille sidebar (rail safran, écrans futurs marqués R1-R3) / topbar (thème, chip DÉMO, sélecteur de rôle), page /design ; client typé `schema.d.ts` généré depuis `docs/openapi.json` et committé ; vérifié en navigateur réel (bascule de rôle 101 ms, bi-thème, 0 erreur console).
|
||||||
- ✅ **R0.12 — tests + CI** (5 jobs verts) : `lint` (ESLint 10 flat config racine, règle ADR-001 anti-import MinIO codée et vérifiée), `ci-contract` (régénération spec+client, diff bloquant), `api` (PostgreSQL 18 + Redis, migrate deploy, Jest couverture ≥ 70 % bloquante — mesurée 97,5 %), `web` (typecheck + vitest + build), `e2e` (Playwright : parcours démo, bascule < 3 s chronométrée, déconnexion). Badge au README.
|
- ✅ **R0.12 — tests + CI** (5 jobs verts) : `lint` (ESLint 10 flat config racine, règle ADR-001 anti-import MinIO codée et vérifiée), `ci-contract` (régénération spec+client, diff bloquant), `api` (PostgreSQL 18 + Redis, migrate deploy, Jest couverture ≥ 70 % bloquante — mesurée 97,5 %), `web` (typecheck + vitest + build), `e2e` (Playwright : parcours démo, bascule < 3 s chronométrée, déconnexion). Badge au README.
|
||||||
- ✅ **R0.13 — Dockerfiles + runbook Dokploy** : image api (multi-stage pnpm deploy, `prisma migrate deploy` au boot, seed optionnel `SEED_ON_START`, non-root, healthcheck ; `binaryTargets` explicites) ; image web (nginx, proxy `/api` résolu à la requête, fallback SPA, cache assets) ; `infra/docker-compose.dokploy.yml` (5 services `siop2-`, seul le web sur `dokploy-network`) ; runbook `docs/06-production/runbook-dokploy.md`. **Répétition locale complète validée** (migrate+seed au boot, parcours via nginx conteneurisé, double verrou ADR-002 observé). Le déploiement réel attend les accès au serveur du partenaire.
|
- ✅ **R0.13 — Dockerfiles + runbook Dokploy** : image api (multi-stage pnpm deploy, `prisma migrate deploy` au boot, seed optionnel `SEED_ON_START`, non-root, healthcheck ; `binaryTargets` explicites) ; image web (nginx, proxy `/api` résolu à la requête, fallback SPA, cache assets) ; `infra/docker-compose.dokploy.yml` (5 services `siop2-`, seul le web sur `dokploy-network`) ; runbook `docs/06-production/runbook-dokploy.md`. **Répétition locale complète validée** (migrate+seed au boot, parcours via nginx conteneurisé, double verrou ADR-002 observé). Le déploiement réel attend les accès au serveur du partenaire.
|
||||||
- 🚀 **R0 EN PRODUCTION (16/07/2026)** : `https://siop2.apps.enset.top` (Dokploy ENSET, profil démo, vérifiée : health ok, 7 comptes, demo-login, 401 sans jeton, HTTPS). La production client SPELEV attend les accès au serveur du partenaire.
|
- ✅ **R0 CLOSE** (tag `release/r0`) — en production : `https://siop2.apps.enset.top` (Dokploy ENSET, profil démo, vérifiée en ligne). Restes non bloquants : secret `DOKPLOY_WEBHOOK_URL` (CD), sauvegardes PostgreSQL Dokploy. Production client SPELEV : attend les accès au serveur du partenaire.
|
||||||
- 🔄 **R0 — reste** : secret `DOKPLOY_WEBHOOK_URL` pour activer le CD (runbook §4), sauvegardes PostgreSQL Dokploy, recette R0 avec le référent (revue pixel écrans ↔ maquettes) → ouverture de R1 Référentiel.
|
- ✅ **R1 — maquettes validées** (16/07) : `maquette-r1.html`, 7 écrans + 4 décisions (statuts d'équipement ≠ statuts OT ; organe sans emplacement par construction ; invitation par lien 7 j ; catégories jamais supprimées si utilisées).
|
||||||
|
- ✅ **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`.
|
||||||
|
- ✅ **R1.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` → source TS (leçon CJS/workspace).
|
||||||
|
- 🏁 **R1 CLOSE (16/07/2026, tag `release/r1`)** : recettée par le référent, déployée et vérifiée en ligne (migration + seed au boot, 5 sites / 8 appareils sur l'instance).
|
||||||
|
- 🔄 **R2 Exploitation — reprise ici, design d'abord** : maquettes R0 déjà validées pour tableau de bord / liste OT / fiche OT (bilan codé) / portail demandeur ; **à maquetter avant tout code : préventif** (gabarits à périodicité, grille du mois, checklist) et écrans secondaires (création OT/demande). Ensuite : modèle R2 (WorkOrder machine à états, Request 1-1, InterventionReport bilan codé, TaskTemplate/PreventivePlan/ChecklistItem, Meter) → API → web → mobile-ready.
|
||||||
- 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.
|
||||||
|
|||||||
20
apps/api/prisma/cleanup-e2e.ts
Normal file
20
apps/api/prisma/cleanup-e2e.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/** Purge des données créées par les parcours Playwright (motifs dédiés).
|
||||||
|
* Appelé par le globalSetup e2e — la base locale reste propre entre les runs. */
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await prisma.asset.deleteMany({ where: { reference: { startsWith: 'E2E-' } } });
|
||||||
|
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' }, parentId: { not: null } } });
|
||||||
|
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });
|
||||||
|
await prisma.user.deleteMany({ where: { email: { startsWith: 'recrue-' } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- Migration autosuffisante : les extensions requises sont créées si absentes
|
||||||
|
-- (la CI et tout environnement neuf n’ont pas notre init.sql).
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CategoryKind" AS ENUM ('EQUIPMENT', 'COMPONENT_TYPE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AssetStatus" AS ENUM ('IN_SERVICE', 'OUT_OF_SERVICE', 'UNDER_MAINTENANCE');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "activationExpiresAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "activationToken" TEXT,
|
||||||
|
ADD COLUMN "phone" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"kind" "CategoryKind" NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Location" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"parentId" UUID,
|
||||||
|
"address" TEXT,
|
||||||
|
"city" TEXT,
|
||||||
|
"guardianName" TEXT,
|
||||||
|
"guardianPhone" TEXT,
|
||||||
|
"latitude" DOUBLE PRECISION,
|
||||||
|
"longitude" DOUBLE PRECISION,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Asset" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"brand" TEXT NOT NULL,
|
||||||
|
"model" TEXT,
|
||||||
|
"serialNumber" TEXT,
|
||||||
|
"commissionedAt" TIMESTAMP(3),
|
||||||
|
"loadKg" INTEGER,
|
||||||
|
"floors" INTEGER,
|
||||||
|
"status" "AssetStatus" NOT NULL DEFAULT 'IN_SERVICE',
|
||||||
|
"categoryId" UUID NOT NULL,
|
||||||
|
"locationId" UUID NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AssetComponent" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"assetId" UUID NOT NULL,
|
||||||
|
"typeId" UUID NOT NULL,
|
||||||
|
"designation" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "AssetComponent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Team" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_TeamToUser" (
|
||||||
|
"A" UUID NOT NULL,
|
||||||
|
"B" UUID NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_TeamToUser_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Category_kind_name_key" ON "Category"("kind", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Location_parentId_idx" ON "Location"("parentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Asset_reference_key" ON "Asset"("reference");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Asset_locationId_idx" ON "Asset"("locationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Asset_categoryId_idx" ON "Asset"("categoryId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AssetComponent_assetId_idx" ON "AssetComponent"("assetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_TeamToUser_B_index" ON "_TeamToUser"("B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_activationToken_key" ON "User"("activationToken");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Location" ADD CONSTRAINT "Location_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- ————— PostGIS (hors périmètre Prisma, décision modèle R1) —————
|
||||||
|
-- Position générée depuis latitude/longitude : rien à synchroniser côté app,
|
||||||
|
-- la colonne est prête pour les requêtes spatiales (affectation auto, backlog).
|
||||||
|
ALTER TABLE "Location"
|
||||||
|
ADD COLUMN "position" geography(Point, 4326)
|
||||||
|
GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint("longitude", "latitude"), 4326)::geography) STORED;
|
||||||
|
|
||||||
|
CREATE INDEX "Location_position_gix" ON "Location" USING GIST ("position");
|
||||||
@@ -39,12 +39,105 @@ model User {
|
|||||||
email String @unique
|
email String @unique
|
||||||
displayName String
|
displayName String
|
||||||
passwordHash String? // null tant que le compte n'est pas activé (R1)
|
passwordHash String? // null tant que le compte n'est pas activé (R1)
|
||||||
|
phone String?
|
||||||
roleId String @db.Uuid
|
roleId String @db.Uuid
|
||||||
role Role @relation(fields: [roleId], references: [id])
|
role Role @relation(fields: [roleId], references: [id])
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
|
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
|
||||||
|
// Invitation (R1) : lien d'activation 7 jours, usage unique.
|
||||||
|
// Statut dérivé : invité = passwordHash null && token présent.
|
||||||
|
activationToken String? @unique
|
||||||
|
activationExpiresAt DateTime?
|
||||||
|
teams Team[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([roleId])
|
@@index([roleId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————— R1 — Référentiel (docs/03-architecture/modele-donnees.md §R1) —————
|
||||||
|
|
||||||
|
enum CategoryKind {
|
||||||
|
EQUIPMENT
|
||||||
|
COMPONENT_TYPE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AssetStatus {
|
||||||
|
IN_SERVICE
|
||||||
|
OUT_OF_SERVICE
|
||||||
|
UNDER_MAINTENANCE
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
kind CategoryKind
|
||||||
|
name String
|
||||||
|
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
||||||
|
assets Asset[]
|
||||||
|
components AssetComponent[]
|
||||||
|
|
||||||
|
@@unique([kind, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Location {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String
|
||||||
|
parentId String? @db.Uuid // site (null) → zone ; profondeur max 2 (service)
|
||||||
|
parent Location? @relation("LocationTree", fields: [parentId], references: [id])
|
||||||
|
children Location[] @relation("LocationTree")
|
||||||
|
address String?
|
||||||
|
city String?
|
||||||
|
guardianName String?
|
||||||
|
guardianPhone String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
|
// + colonne PostGIS générée (voir migration r1_referentiel) :
|
||||||
|
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
|
||||||
|
assets Asset[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([parentId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Asset {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // « A1 » — imprimée sur l'étiquette QR
|
||||||
|
brand String
|
||||||
|
model String?
|
||||||
|
serialNumber String?
|
||||||
|
commissionedAt DateTime?
|
||||||
|
loadKg Int?
|
||||||
|
floors Int?
|
||||||
|
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
||||||
|
categoryId String @db.Uuid
|
||||||
|
category Category @relation(fields: [categoryId], references: [id])
|
||||||
|
locationId String @db.Uuid
|
||||||
|
location Location @relation(fields: [locationId], references: [id])
|
||||||
|
components AssetComponent[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([locationId])
|
||||||
|
@@index([categoryId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Organe : PAS de colonne emplacement — « un organe n'a pas d'emplacement
|
||||||
|
// propre » est garanti par construction (décision maquettes R1).
|
||||||
|
model AssetComponent {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
assetId String @db.Uuid
|
||||||
|
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||||
|
typeId String @db.Uuid
|
||||||
|
type Category @relation(fields: [typeId], references: [id])
|
||||||
|
designation String?
|
||||||
|
|
||||||
|
@@index([assetId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Team {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
members User[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
|||||||
const passwordHash = await argon2.hash(
|
const passwordHash = await argon2.hash(
|
||||||
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
||||||
);
|
);
|
||||||
|
await seedUsers(prisma, roleIds, passwordHash);
|
||||||
|
await seedReferentiel(prisma);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedUsers(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
roleIds: Map<RoleName, string>,
|
||||||
|
passwordHash: string,
|
||||||
|
): Promise<void> {
|
||||||
for (const u of DEMO_USERS) {
|
for (const u of DEMO_USERS) {
|
||||||
// Les comptes démo appartiennent au seed : nom et rôle sont réalignés
|
// Les comptes démo appartiennent au seed : nom et rôle sont réalignés
|
||||||
// à chaque exécution (jamais le mot de passe d'un compte existant).
|
// à chaque exécution (jamais le mot de passe d'un compte existant).
|
||||||
@@ -148,6 +157,207 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ————— R1 — Référentiel (données des maquettes validées) —————
|
||||||
|
|
||||||
|
const EQUIPMENT_CATEGORIES = [
|
||||||
|
'Ascenseur électrique',
|
||||||
|
'Ascenseur hydraulique',
|
||||||
|
'Monte-charge',
|
||||||
|
'EPMR (plateforme PMR)',
|
||||||
|
];
|
||||||
|
const COMPONENT_TYPES = [
|
||||||
|
'Portes cabine / palières',
|
||||||
|
'Treuil / machinerie',
|
||||||
|
'Parachute',
|
||||||
|
'Armoire de commande',
|
||||||
|
'Boutons & signalisation',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** site → zones ; positions réelles Casablanca/Mohammedia. */
|
||||||
|
const SITES: {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
guardianName?: string;
|
||||||
|
guardianPhone?: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
zones: string[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
name: 'Tour Atlas', address: 'Bd de la Corniche, Aïn Diab', city: 'Casablanca',
|
||||||
|
guardianName: 'Karim Doukkali', guardianPhone: '06 61 23 45 67',
|
||||||
|
latitude: 33.6062, longitude: -7.6706,
|
||||||
|
zones: ['Hall principal', 'Tour bureaux (étages 1-24)', 'Parking sous-sol', 'Résidence (aile est)'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Résidence Al Manar', address: 'Bd Hassan II', city: 'Mohammedia',
|
||||||
|
guardianName: 'Hassan Alami',
|
||||||
|
latitude: 33.6866, longitude: -7.383,
|
||||||
|
zones: ['Hall principal'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Anfa Place', address: 'Bd de l’Océan Atlantique', city: 'Casablanca',
|
||||||
|
latitude: 33.5883, longitude: -7.6822,
|
||||||
|
zones: ['Galerie commerciale'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clinique Yasmine', address: 'Rue Ibn Rochd', city: 'Casablanca',
|
||||||
|
guardianName: 'Rachid Mansouri',
|
||||||
|
latitude: 33.5731, longitude: -7.6316,
|
||||||
|
zones: ['Bloc A'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Marina Center', address: 'Av. des FAR', city: 'Mohammedia',
|
||||||
|
latitude: 33.7, longitude: -7.39,
|
||||||
|
zones: ['Hall B'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ASSETS: {
|
||||||
|
reference: string; brand: string; model: string; serialNumber?: string;
|
||||||
|
commissionedAt?: string; loadKg?: number; floors?: number;
|
||||||
|
category: string; site: string; zone: string;
|
||||||
|
status?: 'IN_SERVICE' | 'OUT_OF_SERVICE' | 'UNDER_MAINTENANCE';
|
||||||
|
components?: { type: string; designation?: string }[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
reference: 'A1', brand: 'Otis', model: 'Gen2 Premier', serialNumber: 'OT-2020-4521',
|
||||||
|
commissionedAt: '2020-03-15', loadKg: 630, floors: 8,
|
||||||
|
category: 'Ascenseur électrique', site: 'Résidence Al Manar', zone: 'Hall principal',
|
||||||
|
components: [
|
||||||
|
{ type: 'Portes cabine / palières', designation: 'Fermator 40/10' },
|
||||||
|
{ type: 'Treuil / machinerie', designation: 'Gen2 gearless' },
|
||||||
|
{ type: 'Parachute' },
|
||||||
|
{ type: 'Armoire de commande', designation: 'MCS 220' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'A2', brand: 'Otis', model: 'Gen2', loadKg: 630, floors: 12,
|
||||||
|
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Hall principal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'B1', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||||
|
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'B2', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||||
|
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||||
|
status: 'OUT_OF_SERVICE',
|
||||||
|
components: [
|
||||||
|
{ type: 'Portes cabine / palières', designation: 'Sematic' },
|
||||||
|
{ type: 'Armoire de commande' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'M1', brand: 'Kone', model: 'TranSys', loadKg: 2000, floors: 3,
|
||||||
|
category: 'Monte-charge', site: 'Tour Atlas', zone: 'Parking sous-sol',
|
||||||
|
status: 'UNDER_MAINTENANCE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'C1', brand: 'Kone', model: 'MonoSpace 500', loadKg: 800, floors: 5,
|
||||||
|
category: 'Ascenseur électrique', site: 'Anfa Place', zone: 'Galerie commerciale',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'D1', brand: 'ThyssenKrupp', model: 'Evolution', loadKg: 1600, floors: 6,
|
||||||
|
category: 'Ascenseur électrique', site: 'Clinique Yasmine', zone: 'Bloc A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'E2', brand: 'Otis', model: 'HydroFit', loadKg: 630, floors: 4,
|
||||||
|
category: 'Ascenseur hydraulique', site: 'Marina Center', zone: 'Hall B',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEAMS: { name: string; description: string; memberEmails: string[] }[] = [
|
||||||
|
{
|
||||||
|
name: 'Casablanca Centre',
|
||||||
|
description: 'Tour Atlas · Anfa Place · Clinique Yasmine',
|
||||||
|
memberEmails: ['technicien@demo.siop.ma', 'technicien-limite@demo.siop.ma'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mohammedia',
|
||||||
|
description: 'Résidence Al Manar · Marina Center',
|
||||||
|
memberEmails: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedReferentiel(prisma: PrismaClient): Promise<void> {
|
||||||
|
const categoryIds = new Map<string, string>();
|
||||||
|
for (const [kind, names] of [
|
||||||
|
['EQUIPMENT', EQUIPMENT_CATEGORIES],
|
||||||
|
['COMPONENT_TYPE', COMPONENT_TYPES],
|
||||||
|
] as const) {
|
||||||
|
for (const name of names) {
|
||||||
|
const category = await prisma.category.upsert({
|
||||||
|
where: { kind_name: { kind, name } },
|
||||||
|
update: {},
|
||||||
|
create: { kind, name },
|
||||||
|
});
|
||||||
|
categoryIds.set(name, category.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas d'unicité en base sur (parent, nom) : idempotence par findFirst.
|
||||||
|
const zoneIds = new Map<string, string>(); // « site / zone » → id
|
||||||
|
for (const site of SITES) {
|
||||||
|
const { zones, ...data } = site;
|
||||||
|
let root = await prisma.location.findFirst({
|
||||||
|
where: { name: site.name, parentId: null },
|
||||||
|
});
|
||||||
|
root ??= await prisma.location.create({ data });
|
||||||
|
for (const zoneName of zones) {
|
||||||
|
let zone = await prisma.location.findFirst({
|
||||||
|
where: { name: zoneName, parentId: root.id },
|
||||||
|
});
|
||||||
|
zone ??= await prisma.location.create({
|
||||||
|
data: { name: zoneName, parentId: root.id, city: site.city },
|
||||||
|
});
|
||||||
|
zoneIds.set(`${site.name} / ${zoneName}`, zone.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const asset of ASSETS) {
|
||||||
|
const created = await prisma.asset.upsert({
|
||||||
|
where: { reference: asset.reference },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
reference: asset.reference,
|
||||||
|
brand: asset.brand,
|
||||||
|
model: asset.model,
|
||||||
|
serialNumber: asset.serialNumber,
|
||||||
|
commissionedAt: asset.commissionedAt ? new Date(asset.commissionedAt) : undefined,
|
||||||
|
loadKg: asset.loadKg,
|
||||||
|
floors: asset.floors,
|
||||||
|
status: asset.status ?? 'IN_SERVICE',
|
||||||
|
categoryId: categoryIds.get(asset.category)!,
|
||||||
|
locationId: zoneIds.get(`${asset.site} / ${asset.zone}`)!,
|
||||||
|
},
|
||||||
|
include: { _count: { select: { components: true } } },
|
||||||
|
});
|
||||||
|
if (asset.components?.length && created._count.components === 0) {
|
||||||
|
await prisma.assetComponent.createMany({
|
||||||
|
data: asset.components.map((c) => ({
|
||||||
|
assetId: created.id,
|
||||||
|
typeId: categoryIds.get(c.type)!,
|
||||||
|
designation: c.designation,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const team of TEAMS) {
|
||||||
|
await prisma.team.upsert({
|
||||||
|
where: { name: team.name },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: team.name,
|
||||||
|
description: team.description,
|
||||||
|
members: { connect: team.memberEmails.map((email) => ({ email })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 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();
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
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 { AssetsModule } from './assets/assets.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';
|
||||||
|
import { CategoriesModule } from './categories/categories.module';
|
||||||
import { demoModeEnabled } from './config/env';
|
import { demoModeEnabled } from './config/env';
|
||||||
import { FilesModule } from './files/files.module';
|
import { FilesModule } from './files/files.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { LocationsModule } from './locations/locations.module';
|
||||||
|
import { TeamsModule } from './teams/teams.module';
|
||||||
import { PermissionsGuard } from './permissions/permissions.guard';
|
import { PermissionsGuard } from './permissions/permissions.guard';
|
||||||
import { PermissionsModule } from './permissions/permissions.module';
|
import { PermissionsModule } from './permissions/permissions.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
@@ -28,6 +32,11 @@ export class AppModule {
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
|
// R1 — référentiel
|
||||||
|
CategoriesModule,
|
||||||
|
LocationsModule,
|
||||||
|
AssetsModule,
|
||||||
|
TeamsModule,
|
||||||
// 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] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
73
apps/api/src/assets/assets.controller.ts
Normal file
73
apps/api/src/assets/assets.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AssetComponentCreateSchema,
|
||||||
|
AssetCreateSchema,
|
||||||
|
AssetUpdateSchema,
|
||||||
|
type AssetComponentCreate,
|
||||||
|
type AssetCreate,
|
||||||
|
type AssetUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
|
@Controller('assets')
|
||||||
|
export class AssetsController {
|
||||||
|
constructor(private readonly assetsService: AssetsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('ASSETS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.assetsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('ASSETS', 'view')
|
||||||
|
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.assetsService.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('ASSETS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(AssetCreateSchema)) body: AssetCreate) {
|
||||||
|
return this.assetsService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(AssetUpdateSchema)) body: AssetUpdate,
|
||||||
|
) {
|
||||||
|
return this.assetsService.update(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/components')
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
addComponent(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(AssetComponentCreateSchema)) body: AssetComponentCreate,
|
||||||
|
) {
|
||||||
|
return this.assetsService.addComponent(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/components/:componentId')
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
@HttpCode(204)
|
||||||
|
removeComponent(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('componentId', ParseUUIDPipe) componentId: string,
|
||||||
|
) {
|
||||||
|
return this.assetsService.removeComponent(id, componentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/assets/assets.module.ts
Normal file
10
apps/api/src/assets/assets.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AssetsController } from './assets.controller';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AssetsController],
|
||||||
|
providers: [AssetsService],
|
||||||
|
exports: [AssetsService],
|
||||||
|
})
|
||||||
|
export class AssetsModule {}
|
||||||
183
apps/api/src/assets/assets.service.ts
Normal file
183
apps/api/src/assets/assets.service.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
AssetComponentCreate,
|
||||||
|
AssetComponentDto,
|
||||||
|
AssetCreate,
|
||||||
|
AssetDetail,
|
||||||
|
AssetDto,
|
||||||
|
AssetsResponse,
|
||||||
|
AssetUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const assetInclude = {
|
||||||
|
category: true,
|
||||||
|
location: { include: { parent: true } },
|
||||||
|
_count: { select: { components: true } },
|
||||||
|
} satisfies Prisma.AssetInclude;
|
||||||
|
|
||||||
|
type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<AssetsResponse> {
|
||||||
|
const rows = await this.prisma.asset.findMany({
|
||||||
|
include: assetInclude,
|
||||||
|
orderBy: { reference: 'asc' },
|
||||||
|
});
|
||||||
|
return { assets: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<AssetDetail> {
|
||||||
|
const row = await this.prisma.asset.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { ...assetInclude, components: { include: { type: true } } },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('Appareil inconnu');
|
||||||
|
return {
|
||||||
|
...this.toDto(row),
|
||||||
|
components: row.components.map((c) => this.toComponentDto(c)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: AssetCreate): Promise<AssetDetail> {
|
||||||
|
await this.assertEquipmentCategory(dto.categoryId);
|
||||||
|
await this.assertLocation(dto.locationId);
|
||||||
|
if (dto.components?.length) {
|
||||||
|
await this.assertComponentTypes(dto.components.map((c) => c.typeId));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.asset.create({
|
||||||
|
data: {
|
||||||
|
reference: dto.reference,
|
||||||
|
brand: dto.brand,
|
||||||
|
model: dto.model,
|
||||||
|
serialNumber: dto.serialNumber,
|
||||||
|
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||||
|
loadKg: dto.loadKg,
|
||||||
|
floors: dto.floors,
|
||||||
|
categoryId: dto.categoryId,
|
||||||
|
locationId: dto.locationId,
|
||||||
|
components: dto.components?.length
|
||||||
|
? { create: dto.components }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return this.get(created.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Cette référence est déjà utilisée');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: AssetUpdate): Promise<AssetDetail> {
|
||||||
|
if (dto.categoryId) await this.assertEquipmentCategory(dto.categoryId);
|
||||||
|
if (dto.locationId) await this.assertLocation(dto.locationId);
|
||||||
|
try {
|
||||||
|
await this.prisma.asset.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Appareil inconnu');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Cette référence est déjà utilisée');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addComponent(
|
||||||
|
assetId: string,
|
||||||
|
dto: AssetComponentCreate,
|
||||||
|
): Promise<AssetComponentDto> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||||
|
if (!asset) throw new NotFoundException('Appareil inconnu');
|
||||||
|
await this.assertComponentTypes([dto.typeId]);
|
||||||
|
const created = await this.prisma.assetComponent.create({
|
||||||
|
data: { assetId, ...dto },
|
||||||
|
include: { type: true },
|
||||||
|
});
|
||||||
|
return this.toComponentDto(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeComponent(assetId: string, componentId: string): Promise<void> {
|
||||||
|
const { count } = await this.prisma.assetComponent.deleteMany({
|
||||||
|
where: { id: componentId, assetId },
|
||||||
|
});
|
||||||
|
if (count === 0) throw new NotFoundException('Organe inconnu');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertEquipmentCategory(categoryId: string): Promise<void> {
|
||||||
|
const category = await this.prisma.category.findUnique({ where: { id: categoryId } });
|
||||||
|
if (!category || category.kind !== 'EQUIPMENT' || !category.isActive) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'La catégorie choisie n’est pas une catégorie d’équipement active',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertLocation(locationId: string): Promise<void> {
|
||||||
|
const location = await this.prisma.location.findUnique({ where: { id: locationId } });
|
||||||
|
if (!location) throw new BadRequestException('Emplacement inconnu');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertComponentTypes(typeIds: string[]): Promise<void> {
|
||||||
|
const types = await this.prisma.category.findMany({
|
||||||
|
where: { id: { in: typeIds } },
|
||||||
|
});
|
||||||
|
const valid =
|
||||||
|
types.length === new Set(typeIds).size &&
|
||||||
|
types.every((t) => t.kind === 'COMPONENT_TYPE' && t.isActive);
|
||||||
|
if (!valid) {
|
||||||
|
throw new BadRequestException('Chaque organe doit avoir un type d’organe actif');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toComponentDto(c: { id: string; typeId: string; designation: string | null; type: { name: string } }): AssetComponentDto {
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
typeId: c.typeId,
|
||||||
|
typeName: c.type.name,
|
||||||
|
designation: c.designation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: AssetRow): AssetDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
brand: row.brand,
|
||||||
|
model: row.model,
|
||||||
|
serialNumber: row.serialNumber,
|
||||||
|
commissionedAt: row.commissionedAt?.toISOString() ?? null,
|
||||||
|
loadKg: row.loadKg,
|
||||||
|
floors: row.floors,
|
||||||
|
status: row.status,
|
||||||
|
categoryId: row.categoryId,
|
||||||
|
categoryName: row.category.name,
|
||||||
|
locationId: row.locationId,
|
||||||
|
locationName: row.location.name,
|
||||||
|
siteName: row.location.parent?.name ?? row.location.name,
|
||||||
|
componentCount: row._count.components,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||||
import { LoginRequestSchema, type LoginRequest } from '@siop/shared';
|
import {
|
||||||
|
ActivateRequestSchema,
|
||||||
|
LoginRequestSchema,
|
||||||
|
type ActivateRequest,
|
||||||
|
type LoginRequest,
|
||||||
|
} from '@siop/shared';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { Public } from './public.decorator';
|
import { Public } from './public.decorator';
|
||||||
@@ -14,4 +19,12 @@ export class AuthController {
|
|||||||
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
|
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
|
||||||
return this.authService.login(body.email, body.password);
|
return this.authService.login(body.email, body.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Publique par nature : la personne n'a pas encore de compte actif. */
|
||||||
|
@Public()
|
||||||
|
@Post('activate')
|
||||||
|
@HttpCode(200)
|
||||||
|
activate(@Body(new ZodValidationPipe(ActivateRequestSchema)) body: ActivateRequest) {
|
||||||
|
return this.authService.activate(body.token, body.password);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -75,6 +76,35 @@ export class AuthService {
|
|||||||
return this.issueToken(user);
|
return this.issueToken(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** R1 — activation d'un compte invité : lien 7 jours, usage unique,
|
||||||
|
* choisit le mot de passe et connecte directement. */
|
||||||
|
async activate(token: string, password: string): Promise<AuthResponse> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { activationToken: token },
|
||||||
|
include: { role: true },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!user ||
|
||||||
|
!user.isActive ||
|
||||||
|
!user.activationExpiresAt ||
|
||||||
|
user.activationExpiresAt < new Date()
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Ce lien d’activation est invalide ou a expiré — demandez un nouveau lien',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const activated = await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
passwordHash: await argon2.hash(password),
|
||||||
|
activationToken: null,
|
||||||
|
activationExpiresAt: null,
|
||||||
|
},
|
||||||
|
include: { role: true },
|
||||||
|
});
|
||||||
|
return this.issueToken(activated);
|
||||||
|
}
|
||||||
|
|
||||||
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
|
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
|
||||||
// Identité seule — les droits restent en base (invariant R0)
|
// Identité seule — les droits restent en base (invariant R0)
|
||||||
const accessToken = await this.jwtService.signAsync({
|
const accessToken = await this.jwtService.signAsync({
|
||||||
|
|||||||
44
apps/api/src/categories/categories.controller.ts
Normal file
44
apps/api/src/categories/categories.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
CategoryCreateSchema,
|
||||||
|
CategoryUpdateSchema,
|
||||||
|
type CategoryCreate,
|
||||||
|
type CategoryUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
|
||||||
|
@Controller('categories')
|
||||||
|
export class CategoriesController {
|
||||||
|
constructor(private readonly categoriesService: CategoriesService) {}
|
||||||
|
|
||||||
|
/** Donnée de référence lue par les formulaires — authentification seule. */
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.categoriesService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('SETTINGS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(CategoryCreateSchema)) body: CategoryCreate) {
|
||||||
|
return this.categoriesService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('SETTINGS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(CategoryUpdateSchema)) body: CategoryUpdate,
|
||||||
|
) {
|
||||||
|
return this.categoriesService.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/categories/categories.module.ts
Normal file
10
apps/api/src/categories/categories.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CategoriesController } from './categories.controller';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CategoriesController],
|
||||||
|
providers: [CategoriesService],
|
||||||
|
exports: [CategoriesService],
|
||||||
|
})
|
||||||
|
export class CategoriesModule {}
|
||||||
75
apps/api/src/categories/categories.service.ts
Normal file
75
apps/api/src/categories/categories.service.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
CategoriesResponse,
|
||||||
|
Category,
|
||||||
|
CategoryCreate,
|
||||||
|
CategoryUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoriesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<CategoriesResponse> {
|
||||||
|
const rows = await this.prisma.category.findMany({
|
||||||
|
include: { _count: { select: { assets: true, components: true } } },
|
||||||
|
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
categories: rows.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
kind: c.kind,
|
||||||
|
name: c.name,
|
||||||
|
isActive: c.isActive,
|
||||||
|
usageCount: c.kind === 'EQUIPMENT' ? c._count.assets : c._count.components,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CategoryCreate): Promise<Category> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.category.create({ data: dto });
|
||||||
|
return { ...created, usageCount: 0 };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renommage / (dés)activation — la suppression n'existe pas (invariant R1). */
|
||||||
|
async update(id: string, dto: CategoryUpdate): Promise<Category> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: { _count: { select: { assets: true, components: true } } },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
kind: updated.kind,
|
||||||
|
name: updated.name,
|
||||||
|
isActive: updated.isActive,
|
||||||
|
usageCount:
|
||||||
|
updated.kind === 'EQUIPMENT'
|
||||||
|
? updated._count.assets
|
||||||
|
: updated._count.components,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Catégorie inconnue');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/locations/locations.controller.ts
Normal file
44
apps/api/src/locations/locations.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
LocationCreateSchema,
|
||||||
|
LocationUpdateSchema,
|
||||||
|
type LocationCreate,
|
||||||
|
type LocationUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { LocationsService } from './locations.service';
|
||||||
|
|
||||||
|
@Controller('locations')
|
||||||
|
export class LocationsController {
|
||||||
|
constructor(private readonly locationsService: LocationsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('LOCATIONS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.locationsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('LOCATIONS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(LocationCreateSchema)) body: LocationCreate) {
|
||||||
|
return this.locationsService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('LOCATIONS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(LocationUpdateSchema)) body: LocationUpdate,
|
||||||
|
) {
|
||||||
|
return this.locationsService.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/locations/locations.module.ts
Normal file
10
apps/api/src/locations/locations.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LocationsController } from './locations.controller';
|
||||||
|
import { LocationsService } from './locations.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [LocationsController],
|
||||||
|
providers: [LocationsService],
|
||||||
|
exports: [LocationsService],
|
||||||
|
})
|
||||||
|
export class LocationsModule {}
|
||||||
113
apps/api/src/locations/locations.service.ts
Normal file
113
apps/api/src/locations/locations.service.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
LocationCreate,
|
||||||
|
LocationDto,
|
||||||
|
LocationsResponse,
|
||||||
|
LocationUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
type LocationRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentId: string | null;
|
||||||
|
address: string | null;
|
||||||
|
city: string | null;
|
||||||
|
guardianName: string | null;
|
||||||
|
guardianPhone: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
_count: { assets: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocationsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
|
||||||
|
async list(): Promise<LocationsResponse> {
|
||||||
|
const rows: LocationRow[] = await this.prisma.location.findMany({
|
||||||
|
include: { _count: { select: { assets: true } } },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
const childAssets = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.parentId) {
|
||||||
|
childAssets.set(
|
||||||
|
row.parentId,
|
||||||
|
(childAssets.get(row.parentId) ?? 0) + row._count.assets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
locations: rows.map((row) =>
|
||||||
|
this.toDto(row, row._count.assets + (childAssets.get(row.id) ?? 0)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: LocationCreate): Promise<LocationDto> {
|
||||||
|
await this.assertDepth(dto.parentId);
|
||||||
|
const created = await this.prisma.location.create({
|
||||||
|
data: dto,
|
||||||
|
include: { _count: { select: { assets: true } } },
|
||||||
|
});
|
||||||
|
return this.toDto(created, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: LocationUpdate): Promise<LocationDto> {
|
||||||
|
const existing = await this.prisma.location.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { children: true, assets: true } } },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('Emplacement inconnu');
|
||||||
|
if (dto.parentId) {
|
||||||
|
if (dto.parentId === id) {
|
||||||
|
throw new BadRequestException('Un emplacement ne peut pas être son propre parent');
|
||||||
|
}
|
||||||
|
if (existing._count.children > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Ce site a des zones : il ne peut pas devenir une zone (hiérarchie limitée à 2 niveaux)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertDepth(dto.parentId);
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.location.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: { _count: { select: { assets: true } } },
|
||||||
|
});
|
||||||
|
return this.toDto(updated, updated._count.assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invariant R1 : site → zone, jamais plus profond. */
|
||||||
|
private async assertDepth(parentId?: string): Promise<void> {
|
||||||
|
if (!parentId) return;
|
||||||
|
const parent = await this.prisma.location.findUnique({ where: { id: parentId } });
|
||||||
|
if (!parent) throw new BadRequestException('Emplacement parent inconnu');
|
||||||
|
if (parent.parentId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Hiérarchie limitée à 2 niveaux : une zone ne peut pas contenir d’emplacement',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
parentId: row.parentId,
|
||||||
|
address: row.address,
|
||||||
|
city: row.city,
|
||||||
|
guardianName: row.guardianName,
|
||||||
|
guardianPhone: row.guardianPhone,
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude,
|
||||||
|
assetCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/teams/teams.controller.ts
Normal file
44
apps/api/src/teams/teams.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
TeamCreateSchema,
|
||||||
|
TeamUpdateSchema,
|
||||||
|
type TeamCreate,
|
||||||
|
type TeamUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { TeamsService } from './teams.service';
|
||||||
|
|
||||||
|
@Controller('teams')
|
||||||
|
export class TeamsController {
|
||||||
|
constructor(private readonly teamsService: TeamsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.teamsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(TeamCreateSchema)) body: TeamCreate) {
|
||||||
|
return this.teamsService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(TeamUpdateSchema)) body: TeamUpdate,
|
||||||
|
) {
|
||||||
|
return this.teamsService.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/teams/teams.module.ts
Normal file
10
apps/api/src/teams/teams.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TeamsController } from './teams.controller';
|
||||||
|
import { TeamsService } from './teams.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TeamsController],
|
||||||
|
providers: [TeamsService],
|
||||||
|
exports: [TeamsService],
|
||||||
|
})
|
||||||
|
export class TeamsModule {}
|
||||||
92
apps/api/src/teams/teams.service.ts
Normal file
92
apps/api/src/teams/teams.service.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type { Team, TeamCreate, TeamsResponse, TeamUpdate } from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const teamInclude = {
|
||||||
|
members: { include: { role: true }, orderBy: { displayName: 'asc' } },
|
||||||
|
} satisfies Prisma.TeamInclude;
|
||||||
|
|
||||||
|
type TeamRow = Prisma.TeamGetPayload<{ include: typeof teamInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<TeamsResponse> {
|
||||||
|
const rows = await this.prisma.team.findMany({
|
||||||
|
include: teamInclude,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
return { teams: rows.map((t) => this.toDto(t)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: TeamCreate): Promise<Team> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.team.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
members: dto.memberIds?.length
|
||||||
|
? { connect: dto.memberIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: teamInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom d’équipe existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: TeamUpdate): Promise<Team> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.team.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
members: dto.memberIds
|
||||||
|
? { set: dto.memberIds.map((memberId) => ({ id: memberId })) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: teamInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Équipe inconnue');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom d’équipe existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: TeamRow): Team {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
members: row.members.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
displayName: m.displayName,
|
||||||
|
roleName: m.role.name,
|
||||||
|
initials: m.displayName
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0]!.toUpperCase())
|
||||||
|
.join(''),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,41 @@
|
|||||||
import { Controller, Get, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
import type { MeResponse, RoleName } from '@siop/shared';
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
InvitationCreateSchema,
|
||||||
|
UserUpdateSchema,
|
||||||
|
type InvitationCreate,
|
||||||
|
type MeResponse,
|
||||||
|
type RoleName,
|
||||||
|
type UserUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
import {
|
import {
|
||||||
AuthenticatedUser,
|
AuthenticatedUser,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
} from '../auth/current-user.decorator';
|
} from '../auth/current-user.decorator';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
import { PermissionsService } from '../permissions/permissions.service';
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
@Controller('users')
|
@Controller()
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly permissionsService: PermissionsService,
|
private readonly permissionsService: PermissionsService,
|
||||||
|
private readonly usersService: UsersService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** R0 : lecture du profil courant (gestion complète des utilisateurs en R1). */
|
/** Profil courant — pas de permission : chacun lit le sien. */
|
||||||
@Get('me')
|
@Get('users/me')
|
||||||
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
|
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: current.userId },
|
where: { id: current.userId },
|
||||||
@@ -31,4 +51,37 @@ export class UsersController {
|
|||||||
permissions: await this.permissionsService.getForRole(user.roleId),
|
permissions: await this.permissionsService.getForRole(user.roleId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('users')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.usersService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('roles')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||||
|
roles() {
|
||||||
|
return this.usersService.roles();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/invitations')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'create')
|
||||||
|
invite(@Body(new ZodValidationPipe(InvitationCreateSchema)) body: InvitationCreate) {
|
||||||
|
return this.usersService.invite(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/:id/invitation')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||||
|
resendInvitation(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.usersService.resendInvitation(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('users/:id')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(UserUpdateSchema)) body: UserUpdate,
|
||||||
|
) {
|
||||||
|
return this.usersService.update(id, body);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UsersController } from './users.controller';
|
import { UsersController } from './users.controller';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [UsersController],
|
controllers: [UsersController],
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
141
apps/api/src/users/users.service.ts
Normal file
141
apps/api/src/users/users.service.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
INVITATION_TTL_DAYS,
|
||||||
|
type InvitationCreate,
|
||||||
|
type InvitationResponse,
|
||||||
|
type RoleName,
|
||||||
|
type RolesResponse,
|
||||||
|
type UserAdmin,
|
||||||
|
type UsersResponse,
|
||||||
|
type UserUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const userInclude = {
|
||||||
|
role: true,
|
||||||
|
teams: { orderBy: { name: 'asc' } },
|
||||||
|
} satisfies Prisma.UserInclude;
|
||||||
|
|
||||||
|
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<UsersResponse> {
|
||||||
|
const rows = await this.prisma.user.findMany({
|
||||||
|
include: userInclude,
|
||||||
|
orderBy: { displayName: 'asc' },
|
||||||
|
});
|
||||||
|
return { users: rows.map((u) => this.toDto(u)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async roles(): Promise<RolesResponse> {
|
||||||
|
const roles = await this.prisma.role.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
return { roles: roles.map((r) => ({ id: r.id, name: r.name as RoleName })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invitation : compte créé SANS mot de passe + lien d'activation 7 jours.
|
||||||
|
* L'envoi d'email viendra plus tard — le web affiche le lien à copier. */
|
||||||
|
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
||||||
|
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||||
|
if (!role) throw new NotFoundException('Rôle inconnu');
|
||||||
|
try {
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: dto.email,
|
||||||
|
displayName: dto.displayName,
|
||||||
|
phone: dto.phone,
|
||||||
|
roleId: dto.roleId,
|
||||||
|
teams: dto.teamIds?.length
|
||||||
|
? { connect: dto.teamIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
...this.freshToken(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
activationToken: user.activationToken!,
|
||||||
|
expiresAt: user.activationExpiresAt!.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Cet email a déjà un compte');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resendInvitation(userId: string): Promise<InvitationResponse> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) throw new NotFoundException('Personne inconnue');
|
||||||
|
if (user.passwordHash) {
|
||||||
|
throw new ConflictException('Ce compte est déjà activé');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: this.freshToken(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
userId: updated.id,
|
||||||
|
activationToken: updated.activationToken!,
|
||||||
|
expiresAt: updated.activationExpiresAt!.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
displayName: dto.displayName,
|
||||||
|
phone: dto.phone,
|
||||||
|
roleId: dto.roleId,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
teams: dto.teamIds
|
||||||
|
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: userInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Personne inconnue');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private freshToken() {
|
||||||
|
return {
|
||||||
|
activationToken: randomBytes(32).toString('base64url'),
|
||||||
|
activationExpiresAt: new Date(
|
||||||
|
Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: UserRow): UserAdmin {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
displayName: row.displayName,
|
||||||
|
phone: row.phone,
|
||||||
|
role: { id: row.role.id, name: row.role.name as RoleName },
|
||||||
|
teams: row.teams.map((t) => ({ id: t.id, name: t.name })),
|
||||||
|
status: !row.isActive
|
||||||
|
? 'disabled'
|
||||||
|
: row.passwordHash
|
||||||
|
? 'active'
|
||||||
|
: 'invited',
|
||||||
|
isDemo: row.isDemo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
221
apps/api/test/administration.e2e-spec.ts
Normal file
221
apps/api/test/administration.e2e-spec.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* E2E R1 — administration du référentiel : équipes, renommages, mises à jour
|
||||||
|
* et TOUS les refus (conflits 409, inconnus 404, invalides 400) — le contrat
|
||||||
|
* d'erreur fait partie du contrat.
|
||||||
|
*/
|
||||||
|
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 GHOST = '00000000-0000-4000-8000-000000000000';
|
||||||
|
|
||||||
|
describe('Administration du référentiel (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let admin: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const suffix = `adm-${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 compte = body.accounts.find(
|
||||||
|
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||||
|
);
|
||||||
|
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.team.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await prisma.category.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = () => ({ Authorization: `Bearer ${admin}` });
|
||||||
|
|
||||||
|
it('équipes : création, doublon 409, membres, 404', async () => {
|
||||||
|
const { body: users } = await http().get('/users').set(auth());
|
||||||
|
const ahmed = users.users.find(
|
||||||
|
(u: { email: string }) => u.email === 'technicien@demo.siop.ma',
|
||||||
|
);
|
||||||
|
|
||||||
|
const team = await http()
|
||||||
|
.post('/teams')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Équipe ${suffix}`, description: 'Zone de test' })
|
||||||
|
.expect(201);
|
||||||
|
expect(team.body.members).toHaveLength(0);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.post('/teams')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Équipe ${suffix}` })
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
const updated = await http()
|
||||||
|
.patch(`/teams/${team.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ memberIds: [ahmed.id], description: 'Zone mise à jour' })
|
||||||
|
.expect(200);
|
||||||
|
expect(updated.body.members.map((m: { id: string }) => m.id)).toEqual([ahmed.id]);
|
||||||
|
|
||||||
|
const { body: teams } = await http().get('/teams').set(auth()).expect(200);
|
||||||
|
expect(teams.teams.some((t: { id: string }) => t.id === team.body.id)).toBe(true);
|
||||||
|
|
||||||
|
await http().patch(`/teams/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
|
||||||
|
await http()
|
||||||
|
.patch(`/teams/${team.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: 'Casablanca Centre' })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catégories : création, doublon 409, renommage, 404', async () => {
|
||||||
|
const created = await http()
|
||||||
|
.post('/categories')
|
||||||
|
.set(auth())
|
||||||
|
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/categories')
|
||||||
|
.set(auth())
|
||||||
|
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
|
||||||
|
.expect(409);
|
||||||
|
const renamed = await http()
|
||||||
|
.patch(`/categories/${created.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Catégorie ${suffix} v2` })
|
||||||
|
.expect(200);
|
||||||
|
expect(renamed.body.name).toBe(`Catégorie ${suffix} v2`);
|
||||||
|
await http()
|
||||||
|
.patch(`/categories/${GHOST}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: 'X' })
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emplacements : renommage, parent inconnu, auto-parent, site avec zones, 404', async () => {
|
||||||
|
const site = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Site ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
const zone = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Zone ${suffix}`, parentId: site.body.id })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.patch(`/locations/${zone.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ guardianName: 'Gardien Test', guardianPhone: '06 00 00 00 00' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Orphelin ${suffix}`, parentId: GHOST })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.patch(`/locations/${zone.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ parentId: zone.body.id })
|
||||||
|
.expect(400);
|
||||||
|
// Le site a une zone : il ne peut pas devenir zone lui-même
|
||||||
|
await http()
|
||||||
|
.patch(`/locations/${site.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ parentId: zone.body.id })
|
||||||
|
.expect(400);
|
||||||
|
await http().patch(`/locations/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appareils : statut, référence en conflit, emplacement inconnu, 404', async () => {
|
||||||
|
const { body } = await http().get('/assets').set(auth());
|
||||||
|
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
const b1 = body.assets.find((a: { reference: string }) => a.reference === 'B1');
|
||||||
|
|
||||||
|
const updated = await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ status: 'UNDER_MAINTENANCE' })
|
||||||
|
.expect(200);
|
||||||
|
expect(updated.body.status).toBe('UNDER_MAINTENANCE');
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ status: 'IN_SERVICE' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ reference: a1.reference })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ locationId: GHOST })
|
||||||
|
.expect(400);
|
||||||
|
await http().get(`/assets/${GHOST}`).set(auth()).expect(404);
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${GHOST}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ brand: 'X' })
|
||||||
|
.expect(404);
|
||||||
|
await http()
|
||||||
|
.post(`/assets/${GHOST}/components`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ typeId: GHOST })
|
||||||
|
.expect(404);
|
||||||
|
await http()
|
||||||
|
.delete(`/assets/${b1.id}/components/${GHOST}`)
|
||||||
|
.set(auth())
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('personnes : mise à jour rôle/équipes, invitation sur rôle inconnu, 404', async () => {
|
||||||
|
const { body: users } = await http().get('/users').set(auth());
|
||||||
|
const youssef = users.users.find(
|
||||||
|
(u: { email: string }) => u.email === 'technicien-limite@demo.siop.ma',
|
||||||
|
);
|
||||||
|
const { body: teams } = await http().get('/teams').set(auth());
|
||||||
|
const mohammedia = teams.teams.find((t: { name: string }) => t.name === 'Mohammedia');
|
||||||
|
|
||||||
|
const updated = await http()
|
||||||
|
.patch(`/users/${youssef.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ phone: '06 12 34 56 78', teamIds: [mohammedia.id] })
|
||||||
|
.expect(200);
|
||||||
|
expect(updated.body.teams.map((t: { name: string }) => t.name)).toContain('Mohammedia');
|
||||||
|
// remise en l'état (seed idempotent : les équipes ne sont pas réécrites)
|
||||||
|
const casa = teams.teams.find((t: { name: string }) => t.name === 'Casablanca Centre');
|
||||||
|
await http()
|
||||||
|
.patch(`/users/${youssef.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ teamIds: [casa.id] })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await http().patch(`/users/${GHOST}`).set(auth()).send({ phone: '0' }).expect(404);
|
||||||
|
await http()
|
||||||
|
.post('/users/invitations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ email: `x-${suffix}@spelev.ma`, displayName: 'X', roleId: GHOST })
|
||||||
|
.expect(404);
|
||||||
|
await http().post(`/users/${GHOST}/invitation`).set(auth()).expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
128
apps/api/test/invitation.e2e-spec.ts
Normal file
128
apps/api/test/invitation.e2e-spec.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* E2E R1 — invitation & activation : lien 7 jours, usage unique,
|
||||||
|
* aucun compte actif avant activation (décision maquettes R1).
|
||||||
|
*/
|
||||||
|
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('Invitation → activation (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let admin: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const email = `invite-${Date.now().toString(36)}@spelev.ma`;
|
||||||
|
|
||||||
|
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 compte = body.accounts.find(
|
||||||
|
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||||
|
);
|
||||||
|
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.user.deleteMany({ where: { email } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invite, empêche la connexion avant activation, active, connecte', async () => {
|
||||||
|
const { body: roles } = await http()
|
||||||
|
.get('/roles')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(200);
|
||||||
|
const technicien = roles.roles.find(
|
||||||
|
(r: { name: string }) => r.name === 'Technicien',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Invitation → compte « invited », lien émis
|
||||||
|
const invitation = await http()
|
||||||
|
.post('/users/invitations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ email, displayName: 'Recrue Test', roleId: technicien.id })
|
||||||
|
.expect(201);
|
||||||
|
expect(invitation.body.activationToken).toBeTruthy();
|
||||||
|
|
||||||
|
const { body: users } = await http()
|
||||||
|
.get('/users')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const invited = users.users.find((u: { email: string }) => u.email === email);
|
||||||
|
expect(invited.status).toBe('invited');
|
||||||
|
|
||||||
|
// 2. Pas de connexion possible avant activation
|
||||||
|
await http()
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({ email, password: 'MotDePasse!123' })
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
// 3. Renvoi du lien : l'ancien devient invalide (usage unique)
|
||||||
|
const renvoi = await http()
|
||||||
|
.post(`/users/${invitation.body.userId}/invitation`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: invitation.body.activationToken, password: 'MotDePasse!123' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
// 4. Activation → connecté directement, statut « active »
|
||||||
|
const activation = await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: renvoi.body.activationToken, password: 'MotDePasse!123' })
|
||||||
|
.expect(200);
|
||||||
|
expect(activation.body.user.role.name).toBe('Technicien');
|
||||||
|
await http()
|
||||||
|
.get('/users/me')
|
||||||
|
.set('Authorization', `Bearer ${activation.body.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 5. Le lien est consommé ; la connexion classique fonctionne désormais
|
||||||
|
await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: renvoi.body.activationToken, password: 'Autre!12345' })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({ email, password: 'MotDePasse!123' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 6. Renvoyer un lien sur un compte activé → refus
|
||||||
|
await http()
|
||||||
|
.post(`/users/${invitation.body.userId}/invitation`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un email déjà connu (409) et un lien fantaisiste (400)', async () => {
|
||||||
|
const { body: roles } = await http()
|
||||||
|
.get('/roles')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
await http()
|
||||||
|
.post('/users/invitations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
email: 'dispatcher@demo.siop.ma',
|
||||||
|
displayName: 'Doublon',
|
||||||
|
roleId: roles.roles[0].id,
|
||||||
|
})
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: 'jeton-inexistant-123', password: 'MotDePasse!123' })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
185
apps/api/test/referentiel.e2e-spec.ts
Normal file
185
apps/api/test/referentiel.e2e-spec.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* E2E R1 — référentiel : parcours de recette (site → zone → appareil → organes)
|
||||||
|
* + invariants (profondeur 2, types d'organes, matrice de permissions vivante).
|
||||||
|
*/
|
||||||
|
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('Référentiel (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let admin: string; // jetons
|
||||||
|
let technicien: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const suffix = Date.now().toString(36); // données de test uniques et repérables
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
const res = await http().post('/auth/demo-login').send({ userId: compte.id });
|
||||||
|
return res.body.accessToken as string;
|
||||||
|
};
|
||||||
|
admin = await login('Administrateur');
|
||||||
|
technicien = await login('Technicien');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.asset.deleteMany({ where: { reference: { contains: suffix } } });
|
||||||
|
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parcours de recette : site → zone → appareil avec organes', async () => {
|
||||||
|
const site = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
name: `Site Recette ${suffix}`,
|
||||||
|
city: 'Casablanca',
|
||||||
|
latitude: 33.59,
|
||||||
|
longitude: -7.61,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect(site.body.parentId).toBeNull();
|
||||||
|
|
||||||
|
const zone = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Hall Recette ${suffix}`, parentId: site.body.id })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const { body: cats } = await http()
|
||||||
|
.get('/categories')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const equipement = cats.categories.find(
|
||||||
|
(c: { kind: string }) => c.kind === 'EQUIPMENT',
|
||||||
|
);
|
||||||
|
const typeOrgane = cats.categories.find(
|
||||||
|
(c: { kind: string; isActive: boolean }) => c.kind === 'COMPONENT_TYPE' && c.isActive,
|
||||||
|
);
|
||||||
|
|
||||||
|
const asset = await http()
|
||||||
|
.post('/assets')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
reference: `T-${suffix}`,
|
||||||
|
brand: 'Otis',
|
||||||
|
model: 'Gen2',
|
||||||
|
categoryId: equipement.id,
|
||||||
|
locationId: zone.body.id,
|
||||||
|
components: [{ typeId: typeOrgane.id, designation: 'Organe de test' }],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect(asset.body.components).toHaveLength(1);
|
||||||
|
expect(asset.body.siteName).toBe(`Site Recette ${suffix}`);
|
||||||
|
|
||||||
|
// Ajout puis retrait d'un organe
|
||||||
|
const organe = await http()
|
||||||
|
.post(`/assets/${asset.body.id}/components`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ typeId: typeOrgane.id })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.delete(`/assets/${asset.body.id}/components/${organe.body.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse une hiérarchie de profondeur 3 (site → zone → ?)', async () => {
|
||||||
|
const site = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Site Profond ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
const zone = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Zone Profonde ${suffix}`, parentId: site.body.id })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Sous-zone ${suffix}`, parentId: zone.body.id })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un appareil dont la catégorie n’est pas un équipement', async () => {
|
||||||
|
const { body: cats } = await http()
|
||||||
|
.get('/categories')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const typeOrgane = cats.categories.find((c: { kind: string }) => c.kind === 'COMPONENT_TYPE');
|
||||||
|
const { body: locs } = await http()
|
||||||
|
.get('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
await http()
|
||||||
|
.post('/assets')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
reference: `KO-${suffix}`,
|
||||||
|
brand: 'Test',
|
||||||
|
categoryId: typeOrgane.id, // kind COMPONENT_TYPE → refus
|
||||||
|
locationId: locs.locations[0].id,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la matrice vit : un Technicien lit le parc mais ne crée rien', async () => {
|
||||||
|
await http().get('/assets').set('Authorization', `Bearer ${technicien}`).expect(200);
|
||||||
|
await http().get('/locations').set('Authorization', `Bearer ${technicien}`).expect(200);
|
||||||
|
await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${technicien}`)
|
||||||
|
.send({ name: `Interdit ${suffix}` })
|
||||||
|
.expect(403);
|
||||||
|
await http().get('/users').set('Authorization', `Bearer ${technicien}`).expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le seed de la maquette est en place (A1 et ses organes, statuts)', async () => {
|
||||||
|
const { body } = await http()
|
||||||
|
.get('/assets')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(200);
|
||||||
|
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
expect(a1.siteName).toBe('Résidence Al Manar');
|
||||||
|
expect(a1.componentCount).toBe(4);
|
||||||
|
const b2 = body.assets.find((a: { reference: string }) => a.reference === 'B2');
|
||||||
|
expect(b2.status).toBe('OUT_OF_SERVICE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catégorie utilisée : désactivable, jamais supprimable (la route n’existe pas)', async () => {
|
||||||
|
const { body: cats } = await http()
|
||||||
|
.get('/categories')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const used = cats.categories.find((c: { usageCount: number }) => c.usageCount > 0);
|
||||||
|
await http()
|
||||||
|
.delete(`/categories/${used.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(404); // pas de DELETE dans le contrat
|
||||||
|
const off = await http()
|
||||||
|
.patch(`/categories/${used.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ isActive: false })
|
||||||
|
.expect(200);
|
||||||
|
expect(off.body.isActive).toBe(false);
|
||||||
|
await http()
|
||||||
|
.patch(`/categories/${used.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ isActive: true })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
/** Seed idempotent avant le parcours : mêmes comptes en local et en CI. */
|
/** Seed idempotent + purge des restes du run précédent : mêmes données
|
||||||
|
* en local et en CI. */
|
||||||
export default function globalSetup() {
|
export default function globalSetup() {
|
||||||
execSync('pnpm --filter @siop/api seed', {
|
const cwd = fileURLToPath(new URL('../../..', import.meta.url));
|
||||||
cwd: fileURLToPath(new URL('../../..', import.meta.url)),
|
execSync('pnpm --filter @siop/api exec tsx prisma/cleanup-e2e.ts', {
|
||||||
|
cwd,
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: process.env,
|
env: process.env,
|
||||||
});
|
});
|
||||||
|
execSync('pnpm --filter @siop/api seed', { cwd, stdio: 'inherit', env: process.env });
|
||||||
}
|
}
|
||||||
|
|||||||
96
apps/web/e2e/parcours-r1.spec.ts
Normal file
96
apps/web/e2e/parcours-r1.spec.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recette R1 (plan de releases) : créer un site → un ascenseur → ses organes
|
||||||
|
* → imprimer l'étiquette QR → inviter un technicien (activation incluse).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const suffix = Date.now().toString(36);
|
||||||
|
|
||||||
|
test('recette R1 complète, de la création du site à l’activation de l’invité', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Connexion démo (Administrateur — création tous azimuts)
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Yasmine Alaoui/ }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
|
||||||
|
|
||||||
|
// 1 · Sites : le parc seedé est là, on crée un site
|
||||||
|
await page.getByRole('link', { name: 'Sites' }).click();
|
||||||
|
await expect(page.getByRole('cell', { name: /Tour Atlas/ })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '+ Nouveau site' }).click();
|
||||||
|
await page.getByLabel('Nom du site *').fill(`Site E2E ${suffix}`);
|
||||||
|
await page.getByLabel('Ville').fill('Casablanca');
|
||||||
|
await page.getByRole('button', { name: 'Créer le site' }).click();
|
||||||
|
|
||||||
|
// → fiche du site créé ; on lui ajoute une zone
|
||||||
|
await expect(page.getByRole('heading', { name: `Site E2E ${suffix}` })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '+ Ajouter un emplacement' }).click();
|
||||||
|
await page.getByLabel('Nom de la zone *').fill(`Hall E2E ${suffix}`);
|
||||||
|
await page.getByRole('button', { name: 'Ajouter', exact: true }).click();
|
||||||
|
await expect(page.locator('.arbre')).toContainText(`Hall E2E ${suffix}`);
|
||||||
|
|
||||||
|
// 2 · Nouvel ascenseur avec un organe
|
||||||
|
await page.getByRole('button', { name: '+ Nouvel ascenseur' }).click();
|
||||||
|
await page.getByLabel('Référence *').fill(`E2E-${suffix}`);
|
||||||
|
await page.getByLabel('Marque *').fill('Otis');
|
||||||
|
await page.getByLabel('Catégorie *').selectOption({ label: 'Ascenseur électrique' });
|
||||||
|
await page.getByLabel('Emplacement dans le site *').selectOption({ label: `Hall E2E ${suffix}` });
|
||||||
|
await page.getByLabel("Type de l'organe 1").selectOption({ label: 'Portes cabine / palières' });
|
||||||
|
await page.getByLabel("Désignation de l'organe 1").fill('Fermator 40/10');
|
||||||
|
await page.getByRole('button', { name: "Créer l'ascenseur (QR généré)" }).click();
|
||||||
|
|
||||||
|
// → fiche appareil : identité, organe, QR
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: new RegExp(`Ascenseur E2E-${suffix}`) }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText('Portes cabine / palières')).toBeVisible();
|
||||||
|
|
||||||
|
// 3 · Étiquette QR imprimable
|
||||||
|
await page.getByRole('button', { name: 'Étiquette QR' }).click();
|
||||||
|
await expect(page.locator('.etiquette')).toContainText(`Ascenseur E2E-${suffix}`);
|
||||||
|
await expect(page.locator('.etiquette .consigne')).toContainText('Scannez ce code');
|
||||||
|
|
||||||
|
// 4 · Inviter un technicien → récupérer le lien d'activation
|
||||||
|
await page.getByRole('link', { name: 'Personnes' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Inviter' }).click();
|
||||||
|
await page.getByLabel('Nom complet *').fill('Recrue E2E');
|
||||||
|
await page.getByLabel('Email professionnel *').fill(`recrue-${suffix}@spelev.ma`);
|
||||||
|
await page.getByLabel('Rôle *').selectOption({ label: 'Technicien' });
|
||||||
|
await page.getByRole('button', { name: "Envoyer l'invitation" }).click();
|
||||||
|
const lien = await page.getByTestId('lien-activation').textContent();
|
||||||
|
expect(lien).toContain('/activation?token=');
|
||||||
|
await page.getByRole('button', { name: 'Fermer' }).click();
|
||||||
|
await expect(page.getByRole('cell', { name: /Recrue E2E/ })).toBeVisible();
|
||||||
|
await expect(page.getByText('Invitation envoyée')).toBeVisible();
|
||||||
|
|
||||||
|
// 5 · La recrue active son compte via le lien et entre directement
|
||||||
|
await page.locator('.compte').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Se déconnecter' }).click();
|
||||||
|
await page.goto(lien!);
|
||||||
|
await page.getByLabel('Mot de passe (8 caractères minimum)').fill('MotDePasse!123');
|
||||||
|
await page.getByLabel('Confirmez le mot de passe').fill('MotDePasse!123');
|
||||||
|
await page.getByRole('button', { name: 'Activer mon compte' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
|
||||||
|
await expect(page.locator('.topbar')).toContainText('Recrue E2E');
|
||||||
|
await expect(page.locator('.topbar')).toContainText('Technicien');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('la matrice pilote l’UI : un technicien voit le parc sans boutons de création', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto('/connexion');
|
||||||
|
await page.getByRole('button', { name: /Ahmed Benali/ }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible();
|
||||||
|
|
||||||
|
// Catégories (SETTINGS) absent de sa sidebar ; Ascenseurs accessible
|
||||||
|
await expect(page.locator('.nav')).not.toContainText('Catégories');
|
||||||
|
await page.getByRole('link', { name: 'Ascenseurs' }).click();
|
||||||
|
await expect(page.getByRole('cell', { name: 'A1', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: '+ Nouvel ascenseur' })).toHaveCount(0);
|
||||||
|
|
||||||
|
// Fiche : statuts visibles, pas de retrait d'organe
|
||||||
|
await page.getByRole('cell', { name: 'A1', exact: true }).click();
|
||||||
|
await expect(page.getByText('Treuil / machinerie')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /Retirer/ })).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -15,21 +15,26 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/manrope": "^5.1.0",
|
"@fontsource/manrope": "^5.1.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.19",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
"@siop/shared": "workspace:*",
|
"@siop/shared": "workspace:*",
|
||||||
"@tanstack/react-query": "^5.62.0",
|
"@tanstack/react-query": "^5.62.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"openapi-fetch": "^0.13.4",
|
"openapi-fetch": "^0.13.4",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
"react-router-dom": "^7.1.0",
|
"react-router-dom": "^7.1.0",
|
||||||
"tailwind-merge": "^2.6.0"
|
"tailwind-merge": "^2.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/vite": "^4.1.0",
|
"@tailwindcss/vite": "^4.1.0",
|
||||||
|
"@types/leaflet": "^1.9.21",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
|||||||
@@ -1,36 +1,43 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
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 { Coquille } from '@/layout/coquille';
|
import { Coquille } from '@/layout/coquille';
|
||||||
|
import PageActivation from '@/pages/activation';
|
||||||
|
import PageAscenseurs from '@/pages/ascenseurs';
|
||||||
|
import PageCategories from '@/pages/categories';
|
||||||
import PageConnexion from '@/pages/connexion';
|
import PageConnexion from '@/pages/connexion';
|
||||||
import PageDesign from '@/pages/design';
|
import PageDesign from '@/pages/design';
|
||||||
|
import PageEtiquette from '@/pages/etiquette';
|
||||||
|
import PageFicheAscenseur from '@/pages/fiche-ascenseur';
|
||||||
|
import PageFicheSite from '@/pages/fiche-site';
|
||||||
|
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
|
||||||
|
import PagePersonnes from '@/pages/personnes';
|
||||||
|
import PageSites from '@/pages/sites';
|
||||||
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dansCoquille = (page: ReactNode) => <Coquille>{page}</Coquille>;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/connexion" element={<PageConnexion />} />
|
<Route path="/connexion" element={<PageConnexion />} />
|
||||||
<Route
|
<Route path="/activation" element={<PageActivation />} />
|
||||||
path="/"
|
<Route path="/" element={dansCoquille(<PageTableauDeBord />)} />
|
||||||
element={
|
<Route path="/design" element={dansCoquille(<PageDesign />)} />
|
||||||
<Coquille>
|
<Route path="/sites" element={dansCoquille(<PageSites />)} />
|
||||||
<PageTableauDeBord />
|
<Route path="/sites/:id" element={dansCoquille(<PageFicheSite />)} />
|
||||||
</Coquille>
|
<Route path="/ascenseurs" element={dansCoquille(<PageAscenseurs />)} />
|
||||||
}
|
<Route path="/ascenseurs/nouveau" element={dansCoquille(<PageNouvelAscenseur />)} />
|
||||||
/>
|
<Route path="/ascenseurs/:id" element={dansCoquille(<PageFicheAscenseur />)} />
|
||||||
<Route
|
<Route path="/ascenseurs/:id/etiquette" element={dansCoquille(<PageEtiquette />)} />
|
||||||
path="/design"
|
<Route path="/personnes" element={dansCoquille(<PagePersonnes />)} />
|
||||||
element={
|
<Route path="/categories" element={dansCoquille(<PageCategories />)} />
|
||||||
<Coquille>
|
|
||||||
<PageDesign />
|
|
||||||
</Coquille>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
190
apps/web/src/api/referentiel.ts
Normal file
190
apps/web/src/api/referentiel.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type {
|
||||||
|
AssetComponentCreate,
|
||||||
|
AssetCreate,
|
||||||
|
AssetUpdate,
|
||||||
|
CategoryCreate,
|
||||||
|
CategoryUpdate,
|
||||||
|
InvitationCreate,
|
||||||
|
LocationCreate,
|
||||||
|
TeamCreate,
|
||||||
|
UserUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
/** Hooks R1 — chaque appel passe par le client typé (règle d'or). */
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLocations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['locations'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/locations'))).locations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAssets() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['assets'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAsset(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['assets', id],
|
||||||
|
enabled: !!id,
|
||||||
|
queryFn: async () =>
|
||||||
|
unwrap(await api.GET('/assets/{id}', { params: { path: { id: id! } } })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCategories() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['categories'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/categories'))).categories,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTeams() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['teams'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/teams'))).teams,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUsers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['users'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/users'))).users,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRoles() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['roles'],
|
||||||
|
queryFn: async () => (await unwrap(await api.GET('/roles'))).roles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidate(...keys: string[]) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return () =>
|
||||||
|
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateLocation() {
|
||||||
|
const invalidate = useInvalidate('locations');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: LocationCreate) =>
|
||||||
|
unwrap(await api.POST('/locations', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateAsset() {
|
||||||
|
const invalidate = useInvalidate('assets', 'locations');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssetCreate) => unwrap(await api.POST('/assets', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateAsset(id: string) {
|
||||||
|
const invalidate = useInvalidate('assets');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssetUpdate) =>
|
||||||
|
unwrap(await api.PATCH('/assets/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddComponent(assetId: string) {
|
||||||
|
const invalidate = useInvalidate('assets');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: AssetComponentCreate) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/assets/{id}/components', {
|
||||||
|
params: { path: { id: assetId } },
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRemoveComponent(assetId: string) {
|
||||||
|
const invalidate = useInvalidate('assets');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (componentId: string) => {
|
||||||
|
const res = await api.DELETE('/assets/{id}/components/{componentId}', {
|
||||||
|
params: { path: { id: assetId, componentId } },
|
||||||
|
});
|
||||||
|
if (res.response.status >= 400) throw new Error('Suppression impossible');
|
||||||
|
},
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCategory() {
|
||||||
|
const invalidate = useInvalidate('categories');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: CategoryCreate) =>
|
||||||
|
unwrap(await api.POST('/categories', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCategory() {
|
||||||
|
const invalidate = useInvalidate('categories');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: CategoryUpdate & { id: string }) =>
|
||||||
|
unwrap(await api.PATCH('/categories/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateTeam() {
|
||||||
|
const invalidate = useInvalidate('teams', 'users');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: TeamCreate) => unwrap(await api.POST('/teams', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInviteUser() {
|
||||||
|
const invalidate = useInvalidate('users');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: InvitationCreate) =>
|
||||||
|
unwrap(await api.POST('/users/invitations', { body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResendInvitation() {
|
||||||
|
const invalidate = useInvalidate('users');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (userId: string) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST('/users/{id}/invitation', { params: { path: { id: userId } } }),
|
||||||
|
),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateUser() {
|
||||||
|
const invalidate = useInvalidate('users', 'teams');
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, ...body }: UserUpdate & { id: string }) =>
|
||||||
|
unwrap(await api.PATCH('/users/{id}', { params: { path: { id } }, body })),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
1166
apps/web/src/api/schema.d.ts
vendored
1166
apps/web/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
19
apps/web/src/auth/use-permissions.ts
Normal file
19
apps/web/src/auth/use-permissions.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ObjectCategory, PermissionRight } from '@siop/shared';
|
||||||
|
import { useMe } from './use-auth';
|
||||||
|
|
||||||
|
/** Lecture UI de la matrice (l'API re-vérifie chaque requête, invariant R0). */
|
||||||
|
export function usePermissions() {
|
||||||
|
const { data: me } = useMe();
|
||||||
|
const can = (category: ObjectCategory, right: PermissionRight): boolean => {
|
||||||
|
const entry = me?.permissions.find((p) => p.objectCategory === category);
|
||||||
|
if (!entry) return false;
|
||||||
|
switch (right) {
|
||||||
|
case 'view': return entry.canView;
|
||||||
|
case 'viewOther': return entry.canViewOther;
|
||||||
|
case 'create': return entry.canCreate;
|
||||||
|
case 'edit': return entry.canEdit;
|
||||||
|
case 'delete': return entry.canDelete;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { can, chargé: !!me };
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { RoleName } from '@siop/shared';
|
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
/** Couleur d'avatar par rôle (tokens uniquement — répartition de la maquette). */
|
/** Couleur d'avatar par rôle (tokens uniquement — répartition de la maquette). */
|
||||||
const ROLE_TOKEN: Record<RoleName, string> = {
|
const ROLE_TOKEN: Record<string, string> = {
|
||||||
Administrateur: 'var(--graf-1)',
|
Administrateur: 'var(--graf-1)',
|
||||||
Dispatcher: 'var(--primaire)',
|
Dispatcher: 'var(--primaire)',
|
||||||
Technicien: 'var(--graf-3)',
|
Technicien: 'var(--graf-3)',
|
||||||
@@ -18,13 +17,14 @@ export function Avatar({
|
|||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
initials: string;
|
initials: string;
|
||||||
roleName?: RoleName;
|
roleName?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const couleur = roleName ? ROLE_TOKEN[roleName] : undefined;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn('avatar', className)}
|
className={cn('avatar', className)}
|
||||||
style={roleName ? { background: ROLE_TOKEN[roleName] } : undefined}
|
style={couleur ? { background: couleur } : undefined}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
|
|||||||
77
apps/web/src/components/carte-geo.tsx
Normal file
77
apps/web/src/components/carte-geo.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { divIcon } from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
import { MapContainer, Marker, TileLayer, Tooltip, useMapEvents } from 'react-leaflet';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
/** Carte Leaflet/OSM (positions PostGIS) — pins au style de la maquette. */
|
||||||
|
|
||||||
|
export interface PinGeo {
|
||||||
|
id: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
libelle: string;
|
||||||
|
alerte?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CENTRE_CASABLANCA: [number, number] = [33.59, -7.6];
|
||||||
|
|
||||||
|
function iconePin(alerte?: boolean) {
|
||||||
|
return divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div class="pin-geo${alerte ? ' alerte' : ''}"></div>`,
|
||||||
|
iconSize: [26, 26],
|
||||||
|
iconAnchor: [13, 26],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClicCarte({ onClick }: { onClick?: (lat: number, lng: number) => void }) {
|
||||||
|
useMapEvents({
|
||||||
|
click(e) {
|
||||||
|
onClick?.(Number(e.latlng.lat.toFixed(5)), Number(e.latlng.lng.toFixed(5)));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CarteGeo({
|
||||||
|
pins,
|
||||||
|
className,
|
||||||
|
zoom = 11,
|
||||||
|
centre,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
pins: PinGeo[];
|
||||||
|
className?: string;
|
||||||
|
zoom?: number;
|
||||||
|
centre?: [number, number];
|
||||||
|
onClick?: (lat: number, lng: number) => void;
|
||||||
|
}) {
|
||||||
|
const centreEffectif =
|
||||||
|
centre ??
|
||||||
|
(pins.length
|
||||||
|
? ([pins[0]!.latitude, pins[0]!.longitude] as [number, number])
|
||||||
|
: CENTRE_CASABLANCA);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('geo', className)}>
|
||||||
|
<MapContainer center={centreEffectif} zoom={zoom} scrollWheelZoom={false}>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<ClicCarte onClick={onClick} />
|
||||||
|
{pins.map((pin) => (
|
||||||
|
<Marker
|
||||||
|
key={pin.id}
|
||||||
|
position={[pin.latitude, pin.longitude]}
|
||||||
|
icon={iconePin(pin.alerte)}
|
||||||
|
eventHandlers={pin.onClick ? { click: pin.onClick } : undefined}
|
||||||
|
>
|
||||||
|
<Tooltip direction="bottom" offset={[0, 6]}>{pin.libelle}</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,6 +89,12 @@ export const IcoPersonnes = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const IcoCategories = () => (
|
||||||
|
<svg {...base}>
|
||||||
|
<path d="M4 7h16M4 12h16M4 17h10" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const IcoRecherche = () => (
|
export const IcoRecherche = () => (
|
||||||
<svg {...base}>
|
<svg {...base}>
|
||||||
<circle cx="11" cy="11" r="7" />
|
<circle cx="11" cy="11" r="7" />
|
||||||
|
|||||||
13
apps/web/src/components/statut-equipement.tsx
Normal file
13
apps/web/src/components/statut-equipement.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ASSET_STATUS_LABELS, type AssetStatus } from '@siop/shared';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
|
||||||
|
const CLASSES: Record<AssetStatus, string> = {
|
||||||
|
IN_SERVICE: 'termine',
|
||||||
|
OUT_OF_SERVICE: 'arret',
|
||||||
|
UNDER_MAINTENANCE: 'maintenance',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Statut d'ÉQUIPEMENT — pastille + libellé, jamais la couleur seule (charte). */
|
||||||
|
export function StatutEquipement({ statut }: { statut: AssetStatus }) {
|
||||||
|
return <span className={cn('st', CLASSES[statut])}>{ASSET_STATUS_LABELS[statut]}</span>;
|
||||||
|
}
|
||||||
29
apps/web/src/components/ui/modale.tsx
Normal file
29
apps/web/src/components/ui/modale.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
/** Modale au style de la maquette, portée par Radix (focus, Échap, overlay). */
|
||||||
|
export function Modale({
|
||||||
|
titre,
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
titre: string;
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (ouverte: boolean) => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={ouverte} onOpenChange={surFermeture}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="voile-radix" />
|
||||||
|
<Dialog.Content className="modale" aria-describedby={undefined}>
|
||||||
|
<Dialog.Title asChild>
|
||||||
|
<h3>{titre}</h3>
|
||||||
|
</Dialog.Title>
|
||||||
|
{children}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,8 +6,11 @@ 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 type { ObjectCategory, PermissionRight } from '@siop/shared';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
import {
|
import {
|
||||||
IcoAscenseurs,
|
IcoAscenseurs,
|
||||||
|
IcoCategories,
|
||||||
IcoDemandes,
|
IcoDemandes,
|
||||||
IcoFichiers,
|
IcoFichiers,
|
||||||
IcoOrdresDeTravail,
|
IcoOrdresDeTravail,
|
||||||
@@ -29,11 +32,17 @@ import {
|
|||||||
} from '@/theme/theme';
|
} from '@/theme/theme';
|
||||||
|
|
||||||
/** Navigation de la maquette validée. Les écrans à venir restent visibles
|
/** Navigation de la maquette validée. Les écrans à venir restent visibles
|
||||||
* (périmètre annoncé) mais inertes, marqués de leur release. */
|
* (périmètre annoncé) mais inertes, marqués de leur release ; les écrans
|
||||||
const NAVIGATION: {
|
* livrés sont filtrés par la matrice (canView). */
|
||||||
groupe: string;
|
interface LienNav {
|
||||||
liens: { libelle: string; icone: () => ReactNode; route?: string; release?: string }[];
|
libelle: string;
|
||||||
}[] = [
|
icone: () => ReactNode;
|
||||||
|
route?: string;
|
||||||
|
release?: string;
|
||||||
|
permission?: [ObjectCategory, PermissionRight];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
|
||||||
{
|
{
|
||||||
groupe: 'Exploitation',
|
groupe: 'Exploitation',
|
||||||
liens: [
|
liens: [
|
||||||
@@ -46,8 +55,9 @@ const NAVIGATION: {
|
|||||||
{
|
{
|
||||||
groupe: 'Parc',
|
groupe: 'Parc',
|
||||||
liens: [
|
liens: [
|
||||||
{ libelle: 'Ascenseurs', icone: IcoAscenseurs, release: 'R1' },
|
{ libelle: 'Ascenseurs', icone: IcoAscenseurs, route: '/ascenseurs', permission: ['ASSETS', 'view'] },
|
||||||
{ libelle: 'Sites', icone: IcoSites, release: 'R1' },
|
{ libelle: 'Sites', icone: IcoSites, route: '/sites', permission: ['LOCATIONS', 'view'] },
|
||||||
|
{ libelle: 'Catégories', icone: IcoCategories, route: '/categories', permission: ['SETTINGS', 'view'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -62,7 +72,7 @@ const NAVIGATION: {
|
|||||||
groupe: 'Pilotage',
|
groupe: 'Pilotage',
|
||||||
liens: [
|
liens: [
|
||||||
{ libelle: 'Statistiques', icone: IcoStatistiques, release: 'R3' },
|
{ libelle: 'Statistiques', icone: IcoStatistiques, release: 'R3' },
|
||||||
{ libelle: 'Personnes', icone: IcoPersonnes, release: 'R1' },
|
{ libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -79,6 +89,7 @@ export function Coquille({ children }: { children: ReactNode }) {
|
|||||||
const { data: demoAccounts } = useDemoAccounts();
|
const { data: demoAccounts } = useDemoAccounts();
|
||||||
const demoLogin = useDemoLogin();
|
const demoLogin = useDemoLogin();
|
||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
|
const { can } = usePermissions();
|
||||||
const [themeMode, setThemeMode] = useState<ThemeMode>(getThemeMode);
|
const [themeMode, setThemeMode] = useState<ThemeMode>(getThemeMode);
|
||||||
|
|
||||||
if (!getToken()) return <Navigate to="/connexion" replace />;
|
if (!getToken()) return <Navigate to="/connexion" replace />;
|
||||||
@@ -103,12 +114,14 @@ export function Coquille({ children }: { children: ReactNode }) {
|
|||||||
{NAVIGATION.map(({ groupe, liens }) => (
|
{NAVIGATION.map(({ groupe, liens }) => (
|
||||||
<nav key={groupe} aria-label={groupe}>
|
<nav key={groupe} aria-label={groupe}>
|
||||||
<div className="groupe">{groupe}</div>
|
<div className="groupe">{groupe}</div>
|
||||||
{liens.map(({ libelle, icone: Icone, route, release }) =>
|
{liens
|
||||||
|
.filter(({ permission }) => !permission || can(...permission))
|
||||||
|
.map(({ libelle, icone: Icone, route, release }) =>
|
||||||
route ? (
|
route ? (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={libelle}
|
key={libelle}
|
||||||
to={route}
|
to={route}
|
||||||
end
|
end={route === '/'}
|
||||||
className={({ isActive }) => (isActive ? 'actif' : undefined)}
|
className={({ isActive }) => (isActive ? 'actif' : undefined)}
|
||||||
>
|
>
|
||||||
<Icone />
|
<Icone />
|
||||||
|
|||||||
29
apps/web/src/lib/lieux.test.ts
Normal file
29
apps/web/src/lib/lieux.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { construireArbre } from './lieux';
|
||||||
|
import type { LocationDto } from '@siop/shared';
|
||||||
|
|
||||||
|
const loc = (over: Partial<LocationDto>): LocationDto => ({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: 'X',
|
||||||
|
parentId: null,
|
||||||
|
address: null,
|
||||||
|
city: null,
|
||||||
|
guardianName: null,
|
||||||
|
guardianPhone: null,
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
assetCount: 0,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('construireArbre (liste plate → site → zones)', () => {
|
||||||
|
it('rattache les zones à leur site et trie par nombre d’appareils', () => {
|
||||||
|
const atlas = loc({ name: 'Tour Atlas', assetCount: 4 });
|
||||||
|
const manar = loc({ name: 'Al Manar', assetCount: 1 });
|
||||||
|
const hall = loc({ name: 'Hall', parentId: atlas.id, assetCount: 2 });
|
||||||
|
const arbre = construireArbre([manar, atlas, hall]);
|
||||||
|
expect(arbre.map((s) => s.name)).toEqual(['Tour Atlas', 'Al Manar']);
|
||||||
|
expect(arbre[0]!.zones.map((z) => z.name)).toEqual(['Hall']);
|
||||||
|
expect(arbre[1]!.zones).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
apps/web/src/lib/lieux.ts
Normal file
21
apps/web/src/lib/lieux.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { LocationDto } from '@siop/shared';
|
||||||
|
|
||||||
|
export interface SiteAvecZones extends LocationDto {
|
||||||
|
zones: LocationDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L'API renvoie une liste plate ; l'arbre (site → zones) se construit ici. */
|
||||||
|
export function construireArbre(locations: LocationDto[]): SiteAvecZones[] {
|
||||||
|
const sites = locations
|
||||||
|
.filter((l) => l.parentId === null)
|
||||||
|
.map((site) => ({
|
||||||
|
...site,
|
||||||
|
zones: locations.filter((l) => l.parentId === site.id),
|
||||||
|
}));
|
||||||
|
return sites.sort((a, b) => b.assetCount - a.assetCount || a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateFr(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(iso));
|
||||||
|
}
|
||||||
96
apps/web/src/pages/activation.tsx
Normal file
96
apps/web/src/pages/activation.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { api, setToken } from '@/api/client';
|
||||||
|
import { Logo } from '@/components/logo';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
/** Cible du lien d'invitation (R1) : la personne choisit son mot de passe et
|
||||||
|
* entre directement. Même moule visuel que la connexion (écran validé R0). */
|
||||||
|
export default function PageActivation() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const token = params.get('token') ?? '';
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmation, setConfirmation] = useState('');
|
||||||
|
|
||||||
|
const activation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const { data, error } = await api.POST('/auth/activate', {
|
||||||
|
body: { token, password },
|
||||||
|
});
|
||||||
|
if (error || !data) {
|
||||||
|
throw new Error('Ce lien est invalide ou a expiré — demandez un nouveau lien.');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setToken(data.accessToken);
|
||||||
|
queryClient.clear();
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const surEnvoi = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
activation.mutate();
|
||||||
|
};
|
||||||
|
const differents = confirmation.length > 0 && password !== confirmation;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="conn">
|
||||||
|
<div className="carte-conn">
|
||||||
|
<Logo sousTitre="Activation de votre compte" />
|
||||||
|
{token ? (
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-[14px]" aria-label="Activation">
|
||||||
|
<p className="text-[13px] text-encre-2">
|
||||||
|
Bienvenue ! Choisissez votre mot de passe pour activer votre compte.
|
||||||
|
</p>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="act-mdp">Mot de passe (8 caractères minimum)</label>
|
||||||
|
<input
|
||||||
|
id="act-mdp"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="act-conf">Confirmez le mot de passe</label>
|
||||||
|
<input
|
||||||
|
id="act-conf"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={confirmation}
|
||||||
|
onChange={(e) => setConfirmation(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{differents ? (
|
||||||
|
<p className="erreur-form">Les deux saisies ne correspondent pas.</p>
|
||||||
|
) : null}
|
||||||
|
{activation.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{activation.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="prim"
|
||||||
|
className="justify-center"
|
||||||
|
disabled={activation.isPending || differents || password.length < 8}
|
||||||
|
>
|
||||||
|
Activer mon compte
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<p className="erreur-form" role="alert">
|
||||||
|
Lien incomplet — utilisez le lien d'activation transmis par votre administrateur.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/web/src/pages/ascenseurs.tsx
Normal file
97
apps/web/src/pages/ascenseurs.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
|
||||||
|
import { useAssets } from '@/api/referentiel';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { StatutEquipement } from '@/components/statut-equipement';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
/** Écran 3 des maquettes R1 : inventaire dense du parc. */
|
||||||
|
export default function PageAscenseurs() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: assets } = useAssets();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [site, setSite] = useState('');
|
||||||
|
const [statut, setStatut] = useState<'' | AssetStatus>('');
|
||||||
|
|
||||||
|
const tous = assets ?? [];
|
||||||
|
const sites = [...new Set(tous.map((a) => a.siteName))].sort();
|
||||||
|
const visibles = tous.filter(
|
||||||
|
(a) => (!site || a.siteName === site) && (!statut || a.status === statut),
|
||||||
|
);
|
||||||
|
const arret = tous.filter((a) => a.status === 'OUT_OF_SERVICE').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Ascenseurs</h1>
|
||||||
|
<span className="filajout">
|
||||||
|
{tous.length} appareils{arret ? ` · ${arret} à l’arrêt` : ''}
|
||||||
|
</span>
|
||||||
|
<div className="actions">
|
||||||
|
{can('ASSETS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => navigate('/ascenseurs/nouveau')}>
|
||||||
|
+ Nouvel ascenseur
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filtres">
|
||||||
|
<select aria-label="Filtrer par site" value={site} onChange={(e) => setSite(e.target.value)}>
|
||||||
|
<option value="">Site : tous</option>
|
||||||
|
{sites.map((s) => (
|
||||||
|
<option key={s} value={s}>{s}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="Filtrer par statut"
|
||||||
|
value={statut}
|
||||||
|
onChange={(e) => setStatut(e.target.value as '' | AssetStatus)}
|
||||||
|
>
|
||||||
|
<option value="">Statut : tous</option>
|
||||||
|
{ASSET_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>{ASSET_STATUS_LABELS[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Appareil</th>
|
||||||
|
<th>Marque · modèle</th>
|
||||||
|
<th>Site / emplacement</th>
|
||||||
|
<th>Catégorie</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th>Organes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibles.map((a) => (
|
||||||
|
<tr
|
||||||
|
key={a.id}
|
||||||
|
className={a.status === 'OUT_OF_SERVICE' ? 'ligne-bloque' : undefined}
|
||||||
|
onClick={() => navigate(`/ascenseurs/${a.id}`)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<td className="ref num">{a.reference}</td>
|
||||||
|
<td>{a.brand} {a.model ?? ''}</td>
|
||||||
|
<td>
|
||||||
|
{a.siteName}
|
||||||
|
{a.locationName !== a.siteName ? (
|
||||||
|
<span className="sous">{a.locationName}</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>{a.categoryName}</td>
|
||||||
|
<td><StatutEquipement statut={a.status} /></td>
|
||||||
|
<td className="num">{a.componentCount}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
apps/web/src/pages/categories.tsx
Normal file
136
apps/web/src/pages/categories.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { CATEGORY_KINDS, type Category, type CategoryKind } from '@siop/shared';
|
||||||
|
import { useCategories, useCreateCategory, useUpdateCategory } from '@/api/referentiel';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const TITRES: Record<CategoryKind, string> = {
|
||||||
|
EQUIPMENT: 'Catégories d’équipement',
|
||||||
|
COMPONENT_TYPE: 'Types d’organes',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Écran 7 des maquettes R1 : référentiels administrables — renommer,
|
||||||
|
* ajouter, (dés)activer. Jamais de suppression si utilisé. */
|
||||||
|
export default function PageCategories() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page"><h1>Catégories</h1></div>
|
||||||
|
<div className="grille-2">
|
||||||
|
{CATEGORY_KINDS.map((kind) => (
|
||||||
|
<ListeCategories key={kind} kind={kind} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||||
|
Une catégorie <b>utilisée</b> ne peut pas être supprimée — seulement renommée ou
|
||||||
|
désactivée (elle disparaît des sélecteurs, l'historique reste lisible).
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListeCategories({ kind }: { kind: CategoryKind }) {
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const creation = useCreateCategory();
|
||||||
|
const [nom, setNom] = useState('');
|
||||||
|
|
||||||
|
const liste = (categories ?? []).filter((c) => c.kind === kind);
|
||||||
|
const peutEditer = can('SETTINGS', 'edit');
|
||||||
|
|
||||||
|
const surAjout = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!nom.trim()) return;
|
||||||
|
creation.mutate({ kind, name: nom.trim() }, { onSuccess: () => setNom('') });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>{TITRES[kind]}</h2>
|
||||||
|
{liste.map((categorie) => (
|
||||||
|
<LigneCategorie key={categorie.id} categorie={categorie} peutEditer={peutEditer} />
|
||||||
|
))}
|
||||||
|
{can('SETTINGS', 'create') ? (
|
||||||
|
<form className="ligne-organe" style={{ border: 0, marginTop: 6 }} onSubmit={surAjout}>
|
||||||
|
<input
|
||||||
|
aria-label={`Nouvelle entrée — ${TITRES[kind]}`}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
placeholder={kind === 'EQUIPMENT' ? 'Nouvelle catégorie…' : 'Nouveau type…'}
|
||||||
|
value={nom}
|
||||||
|
onChange={(e) => setNom(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||||
|
Ajouter
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
{creation.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LigneCategorie({
|
||||||
|
categorie,
|
||||||
|
peutEditer,
|
||||||
|
}: {
|
||||||
|
categorie: Category;
|
||||||
|
peutEditer: boolean;
|
||||||
|
}) {
|
||||||
|
const maj = useUpdateCategory();
|
||||||
|
const [enEdition, setEnEdition] = useState(false);
|
||||||
|
const [nom, setNom] = useState(categorie.name);
|
||||||
|
|
||||||
|
const renommer = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
maj.mutate({ id: categorie.id, name: nom.trim() }, { onSuccess: () => setEnEdition(false) });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (enEdition) {
|
||||||
|
return (
|
||||||
|
<form className="ligne-organe" onSubmit={renommer}>
|
||||||
|
<input
|
||||||
|
aria-label={`Renommer ${categorie.name}`}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
value={nom}
|
||||||
|
onChange={(e) => setNom(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="prim" disabled={maj.isPending}>OK</Button>
|
||||||
|
<Button onClick={() => setEnEdition(false)}>Annuler</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="item-liste" style={!categorie.isActive ? { color: 'var(--encre-3)' } : undefined}>
|
||||||
|
{categorie.name}
|
||||||
|
{!categorie.isActive ? <span style={{ fontSize: 11 }}> (désactivée)</span> : null}
|
||||||
|
<span className="fin">
|
||||||
|
<span className="num">
|
||||||
|
{categorie.usageCount} {categorie.kind === 'EQUIPMENT' ? 'appareils' : ''}
|
||||||
|
</span>
|
||||||
|
{peutEditer ? (
|
||||||
|
<>
|
||||||
|
·{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="border-0 bg-transparent font-semibold text-primaire"
|
||||||
|
onClick={() => setEnEdition(true)}
|
||||||
|
>
|
||||||
|
Renommer
|
||||||
|
</button>
|
||||||
|
·{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="border-0 bg-transparent font-semibold text-primaire"
|
||||||
|
onClick={() => maj.mutate({ id: categorie.id, isActive: !categorie.isActive })}
|
||||||
|
>
|
||||||
|
{categorie.isActive ? 'Désactiver' : 'Réactiver'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/web/src/pages/etiquette.tsx
Normal file
76
apps/web/src/pages/etiquette.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { useAsset } from '@/api/referentiel';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
/** Écran 5 des maquettes R1 : l'étiquette A6 imprimable — objet PAPIER
|
||||||
|
* (noir sur blanc, même en thème sombre). window.print n'imprime qu'elle. */
|
||||||
|
export default function PageEtiquette() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data: asset } = useAsset(id);
|
||||||
|
if (!asset) return null;
|
||||||
|
|
||||||
|
const url = `${window.location.origin}/q/${asset.reference}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/ascenseurs">Ascenseurs</Link> /{' '}
|
||||||
|
<Link to={`/ascenseurs/${asset.id}`}>Ascenseur {asset.reference}</Link> / Étiquette QR
|
||||||
|
</div>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Étiquette cabine — Ascenseur {asset.reference}</h1>
|
||||||
|
<div className="actions">
|
||||||
|
<Button variant="prim" onClick={() => window.print()}>
|
||||||
|
Imprimer (A6)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="zone-impression">
|
||||||
|
<div className="etiquette">
|
||||||
|
<div className="tete">
|
||||||
|
<span className="marque" aria-hidden="true">▲<br />▼</span>
|
||||||
|
<b>SIOP</b>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#55647a' }}>
|
||||||
|
Maintenance d'ascenseurs
|
||||||
|
<br />
|
||||||
|
SPELEV
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="corps">
|
||||||
|
<QRCodeSVG className="qr-grand" value={url} size={120} marginSize={1} />
|
||||||
|
<div>
|
||||||
|
<h3>Ascenseur {asset.reference}</h3>
|
||||||
|
<div className="ou">
|
||||||
|
{asset.siteName}
|
||||||
|
<br />
|
||||||
|
{asset.locationName !== asset.siteName ? `${asset.locationName} — ` : ''}
|
||||||
|
{asset.brand} {asset.model ?? ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="consigne">
|
||||||
|
Un problème ? Une personne bloquée ?
|
||||||
|
<br />
|
||||||
|
📱 Scannez ce code pour signaler
|
||||||
|
</div>
|
||||||
|
<div className="ref">
|
||||||
|
SIOP · {asset.reference}
|
||||||
|
{asset.serialNumber ? ` · ${asset.serialNumber}` : ''} · {url.replace(/^https?:\/\//, '')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ flex: 1, minWidth: 260 }}>
|
||||||
|
<h2>Ce que fait le scan</h2>
|
||||||
|
<dl className="infos" style={{ gridTemplateColumns: '96px 1fr' }}>
|
||||||
|
<dt>Gardien</dt>
|
||||||
|
<dd>ouvrira le <b>portail de signalement</b>, équipement prérempli (R2)</dd>
|
||||||
|
<dt>Technicien</dt>
|
||||||
|
<dd>connecté → ouvre la <b>fiche de l'appareil</b></dd>
|
||||||
|
<dt>Impression</dt>
|
||||||
|
<dd>A6 autocollant, noir sur blanc</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
apps/web/src/pages/fiche-ascenseur.tsx
Normal file
202
apps/web/src/pages/fiche-ascenseur.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
|
||||||
|
import {
|
||||||
|
useAddComponent,
|
||||||
|
useAsset,
|
||||||
|
useCategories,
|
||||||
|
useRemoveComponent,
|
||||||
|
useUpdateAsset,
|
||||||
|
} from '@/api/referentiel';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { StatutEquipement } from '@/components/statut-equipement';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Modale } from '@/components/ui/modale';
|
||||||
|
import { formatDateFr } from '@/lib/lieux';
|
||||||
|
|
||||||
|
/** Fiche appareil — écran validé en R0 (maquette-web, écran 5) ; historique
|
||||||
|
* (R2) et documents (R3) sont annoncés, pas simulés. */
|
||||||
|
export default function PageFicheAscenseur() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: asset } = useAsset(id);
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [modaleOrgane, setModaleOrgane] = useState(false);
|
||||||
|
const retrait = useRemoveComponent(id ?? '');
|
||||||
|
const majStatut = useUpdateAsset(id ?? '');
|
||||||
|
|
||||||
|
if (!asset) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/ascenseurs">Ascenseurs</Link> / Ascenseur {asset.reference}
|
||||||
|
</div>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>
|
||||||
|
Ascenseur {asset.reference} — {asset.brand} {asset.model ?? ''}
|
||||||
|
</h1>
|
||||||
|
<StatutEquipement statut={asset.status} />
|
||||||
|
<div className="actions">
|
||||||
|
{can('ASSETS', 'edit') ? (
|
||||||
|
<select
|
||||||
|
aria-label="Changer le statut"
|
||||||
|
className="btn"
|
||||||
|
value={asset.status}
|
||||||
|
onChange={(e) => majStatut.mutate({ status: e.target.value as AssetStatus })}
|
||||||
|
>
|
||||||
|
{ASSET_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>{ASSET_STATUS_LABELS[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/etiquette`)}>
|
||||||
|
Étiquette QR
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Identité</h2>
|
||||||
|
<dl className="infos">
|
||||||
|
<dt>N° de série</dt>
|
||||||
|
<dd className="num">{asset.serialNumber ?? '—'}</dd>
|
||||||
|
<dt>Marque · modèle</dt>
|
||||||
|
<dd>{asset.brand} — {asset.model ?? '—'}</dd>
|
||||||
|
<dt>Mise en service</dt>
|
||||||
|
<dd className="num">{formatDateFr(asset.commissionedAt)}</dd>
|
||||||
|
<dt>Site</dt>
|
||||||
|
<dd>
|
||||||
|
{asset.siteName}
|
||||||
|
{asset.locationName !== asset.siteName ? ` — ${asset.locationName}` : ''}
|
||||||
|
</dd>
|
||||||
|
<dt>Catégorie</dt>
|
||||||
|
<dd>{asset.categoryName}</dd>
|
||||||
|
<dt>Charge · niveaux</dt>
|
||||||
|
<dd className="num">
|
||||||
|
{asset.loadKg ? `${asset.loadKg} kg` : '—'} · {asset.floors ?? '—'} niveaux
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Étiquette cabine</h2>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={`${window.location.origin}/q/${asset.reference}`}
|
||||||
|
size={86}
|
||||||
|
marginSize={2}
|
||||||
|
className="rounded-lg border border-bordure bg-white p-1"
|
||||||
|
/>
|
||||||
|
<div className="text-[12.5px] text-encre-2">
|
||||||
|
Collée en cabine.<br />
|
||||||
|
Scan gardien → signalement prérempli (R2).<br />
|
||||||
|
Scan technicien → cette fiche.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Organes</h2>
|
||||||
|
{asset.components.map((c) => (
|
||||||
|
<div className="item-liste" key={c.id}>
|
||||||
|
<div>
|
||||||
|
<b>{c.typeName}</b>
|
||||||
|
{c.designation ? <span className="sous">{c.designation}</span> : null}
|
||||||
|
</div>
|
||||||
|
<span className="fin">
|
||||||
|
{can('ASSETS', 'edit') ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-suppr"
|
||||||
|
aria-label={`Retirer ${c.typeName}`}
|
||||||
|
onClick={() => retrait.mutate(c.id)}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{asset.components.length === 0 ? (
|
||||||
|
<p className="text-encre-3">Aucun organe déclaré.</p>
|
||||||
|
) : null}
|
||||||
|
{can('ASSETS', 'edit') ? (
|
||||||
|
<Button variant="ghost" className="mt-2" onClick={() => setModaleOrgane(true)}>
|
||||||
|
+ Ajouter un organe
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||||
|
<h2>Historique</h2>
|
||||||
|
<p className="text-encre-3">
|
||||||
|
Les interventions de cet appareil apparaîtront ici avec l’exploitation (R2).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||||
|
<h2>Documents</h2>
|
||||||
|
<p className="text-encre-3">
|
||||||
|
Notices et certificats rejoindront la bibliothèque en R3.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NouvelOrgane assetId={asset.id} ouverte={modaleOrgane} surFermeture={setModaleOrgane} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NouvelOrgane({
|
||||||
|
assetId,
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
assetId: string;
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const ajout = useAddComponent(assetId);
|
||||||
|
const types = (categories ?? []).filter((c) => c.kind === 'COMPONENT_TYPE' && c.isActive);
|
||||||
|
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
ajout.mutate(
|
||||||
|
{
|
||||||
|
typeId: String(donnees.get('typeId')),
|
||||||
|
designation: String(donnees.get('designation')) || undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => surFermeture(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modale titre="Ajouter un organe" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="no-type">Type d’organe *</label>
|
||||||
|
<select id="no-type" name="typeId" required defaultValue="">
|
||||||
|
<option value="" disabled>Choisir…</option>
|
||||||
|
{types.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="no-designation">Désignation</label>
|
||||||
|
<input id="no-designation" name="designation" placeholder="Ex. : Fermator 40/10" />
|
||||||
|
</div>
|
||||||
|
{ajout.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{ajout.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={ajout.isPending}>Ajouter</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
apps/web/src/pages/fiche-site.tsx
Normal file
165
apps/web/src/pages/fiche-site.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useAssets, useCreateLocation, useLocations } from '@/api/referentiel';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { CarteGeo } from '@/components/carte-geo';
|
||||||
|
import { StatutEquipement } from '@/components/statut-equipement';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Modale } from '@/components/ui/modale';
|
||||||
|
|
||||||
|
/** Écran 2 des maquettes R1 : identité du site, hiérarchie d'emplacements,
|
||||||
|
* appareils rattachés. Un organe n'apparaît jamais ici : il suit son appareil. */
|
||||||
|
export default function PageFicheSite() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: locations } = useLocations();
|
||||||
|
const { data: assets } = useAssets();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [modaleZone, setModaleZone] = useState(false);
|
||||||
|
|
||||||
|
const site = locations?.find((l) => l.id === id);
|
||||||
|
if (!site) return null;
|
||||||
|
const zones = (locations ?? []).filter((l) => l.parentId === site.id);
|
||||||
|
const appareils = (assets ?? []).filter((a) => a.siteName === site.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/sites">Sites</Link> / {site.name}
|
||||||
|
</div>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>{site.name}</h1>
|
||||||
|
<div className="actions">
|
||||||
|
{can('ASSETS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => navigate(`/ascenseurs/nouveau?site=${site.id}`)}>
|
||||||
|
+ Nouvel ascenseur
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Identité</h2>
|
||||||
|
<dl className="infos">
|
||||||
|
<dt>Adresse</dt>
|
||||||
|
<dd>{[site.address, site.city].filter(Boolean).join(' — ') || '—'}</dd>
|
||||||
|
<dt>Position</dt>
|
||||||
|
<dd className="num">
|
||||||
|
{site.latitude != null
|
||||||
|
? `${site.latitude} N · ${Math.abs(site.longitude!)} O`
|
||||||
|
: 'non posée'}
|
||||||
|
</dd>
|
||||||
|
<dt>Gardien</dt>
|
||||||
|
<dd>
|
||||||
|
{site.guardianName ?? '—'}
|
||||||
|
{site.guardianPhone ? (
|
||||||
|
<span className="num"> · {site.guardianPhone}</span>
|
||||||
|
) : null}
|
||||||
|
</dd>
|
||||||
|
<dt>Appareils</dt>
|
||||||
|
<dd className="num">{site.assetCount}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{site.latitude != null && site.longitude != null ? (
|
||||||
|
<CarteGeo
|
||||||
|
className="min-h-[200px]"
|
||||||
|
zoom={15}
|
||||||
|
centre={[site.latitude, site.longitude]}
|
||||||
|
pins={[{ id: site.id, latitude: site.latitude, longitude: site.longitude, libelle: site.name }]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="carte text-encre-3">Position non posée — modifiez le site pour la placer.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Emplacements & appareils</h2>
|
||||||
|
<ul className="arbre">
|
||||||
|
<li className="racine">
|
||||||
|
<b>{site.name}</b>
|
||||||
|
<span className="meta">site</span>
|
||||||
|
<span className="fin">{site.assetCount} appareils</span>
|
||||||
|
</li>
|
||||||
|
{zones.map((zone) => (
|
||||||
|
<li key={zone.id}>
|
||||||
|
<b>{zone.name}</b>
|
||||||
|
<span className="meta">zone</span>
|
||||||
|
<span className="fin">
|
||||||
|
{appareils
|
||||||
|
.filter((a) => a.locationId === zone.id)
|
||||||
|
.map((a) => a.reference)
|
||||||
|
.join(' · ') || '—'}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{can('LOCATIONS', 'create') ? (
|
||||||
|
<Button variant="ghost" className="mt-2" onClick={() => setModaleZone(true)}>
|
||||||
|
+ Ajouter un emplacement
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Ascenseurs du site</h2>
|
||||||
|
{appareils.map((a) => (
|
||||||
|
<div className="item-liste" key={a.id}>
|
||||||
|
<Link to={`/ascenseurs/${a.id}`} style={{ color: 'var(--primaire)', fontWeight: 600 }}>
|
||||||
|
{a.reference} — {a.brand} {a.model ?? ''}
|
||||||
|
</Link>
|
||||||
|
<span className="fin">
|
||||||
|
<StatutEquipement statut={a.status} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{appareils.length === 0 ? (
|
||||||
|
<p className="text-encre-3">Aucun appareil rattaché pour l’instant.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NouvelleZone siteId={site.id} ouverte={modaleZone} surFermeture={setModaleZone} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NouvelleZone({
|
||||||
|
siteId,
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
siteId: string;
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const creation = useCreateLocation();
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const nom = String(new FormData(e.currentTarget).get('name'));
|
||||||
|
creation.mutate(
|
||||||
|
{ name: nom, parentId: siteId },
|
||||||
|
{ onSuccess: () => surFermeture(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modale titre="Ajouter un emplacement" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="nz-nom">Nom de la zone *</label>
|
||||||
|
<input id="nz-nom" name="name" required placeholder="Ex. : Hall principal" />
|
||||||
|
</div>
|
||||||
|
<p className="note-douce">
|
||||||
|
Hiérarchie limitée à deux niveaux : site → zone. Les organes, eux,
|
||||||
|
suivent leur appareil — jamais d’emplacement propre.
|
||||||
|
</p>
|
||||||
|
{creation.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||||
|
Ajouter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
apps/web/src/pages/nouvel-ascenseur.tsx
Normal file
206
apps/web/src/pages/nouvel-ascenseur.tsx
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useCategories, useCreateAsset, useLocations } from '@/api/referentiel';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { construireArbre } from '@/lib/lieux';
|
||||||
|
|
||||||
|
interface LigneOrgane {
|
||||||
|
cle: number;
|
||||||
|
typeId: string;
|
||||||
|
designation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Écran 4 des maquettes R1 — le parcours de recette : identité →
|
||||||
|
* rattachement (site → zone) → organes ligne à ligne. QR généré à la création. */
|
||||||
|
export default function PageNouvelAscenseur() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const { data: locations } = useLocations();
|
||||||
|
const { data: categories } = useCategories();
|
||||||
|
const creation = useCreateAsset();
|
||||||
|
|
||||||
|
const sites = construireArbre(locations ?? []);
|
||||||
|
const [siteId, setSiteId] = useState(params.get('site') ?? '');
|
||||||
|
const [organes, setOrganes] = useState<LigneOrgane[]>([
|
||||||
|
{ cle: 1, typeId: '', designation: '' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const siteChoisi = sites.find((s) => s.id === siteId);
|
||||||
|
const equipements = (categories ?? []).filter((c) => c.kind === 'EQUIPMENT' && c.isActive);
|
||||||
|
const typesOrganes = (categories ?? []).filter(
|
||||||
|
(c) => c.kind === 'COMPONENT_TYPE' && c.isActive,
|
||||||
|
);
|
||||||
|
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
const champ = (nom: string) => String(donnees.get(nom) ?? '').trim();
|
||||||
|
const dateMes = champ('commissionedAt');
|
||||||
|
creation.mutate(
|
||||||
|
{
|
||||||
|
reference: champ('reference'),
|
||||||
|
brand: champ('brand'),
|
||||||
|
model: champ('model') || undefined,
|
||||||
|
serialNumber: champ('serialNumber') || undefined,
|
||||||
|
commissionedAt: dateMes ? new Date(dateMes).toISOString() : undefined,
|
||||||
|
loadKg: champ('loadKg') ? Number(champ('loadKg')) : undefined,
|
||||||
|
floors: champ('floors') ? Number(champ('floors')) : undefined,
|
||||||
|
categoryId: champ('categoryId'),
|
||||||
|
locationId: champ('locationId'),
|
||||||
|
components: organes
|
||||||
|
.filter((o) => o.typeId)
|
||||||
|
.map((o) => ({ typeId: o.typeId, designation: o.designation || undefined })),
|
||||||
|
},
|
||||||
|
{ onSuccess: (asset) => navigate(`/ascenseurs/${asset.id}`) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 860 }} className="flex flex-col gap-4">
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/ascenseurs">Ascenseurs</Link> / Nouvel ascenseur
|
||||||
|
</div>
|
||||||
|
<div className="entete-page"><h1>Nouvel ascenseur</h1></div>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-4">
|
||||||
|
<div className="carte">
|
||||||
|
<h2>1 · Identité</h2>
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="na-ref">Référence *</label>
|
||||||
|
<input id="na-ref" name="reference" required placeholder="Ex. : B3" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="na-serie">N° de série</label>
|
||||||
|
<input id="na-serie" name="serialNumber" placeholder="Plaque constructeur" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="na-marque">Marque *</label>
|
||||||
|
<input id="na-marque" name="brand" required placeholder="Ex. : Schindler" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="na-modele">Modèle</label>
|
||||||
|
<input id="na-modele" name="model" placeholder="Ex. : 3300" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="na-mes">Mise en service</label>
|
||||||
|
<input id="na-mes" name="commissionedAt" type="date" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="na-categorie">Catégorie *</label>
|
||||||
|
<select id="na-categorie" name="categoryId" required defaultValue="">
|
||||||
|
<option value="" disabled>Choisir…</option>
|
||||||
|
{equipements.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="na-charge">Charge (kg)</label>
|
||||||
|
<input id="na-charge" name="loadKg" type="number" min="1" placeholder="630" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="na-niveaux">Niveaux desservis</label>
|
||||||
|
<input id="na-niveaux" name="floors" type="number" min="1" placeholder="8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte">
|
||||||
|
<h2>2 · Rattachement</h2>
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="na-site">Site *</label>
|
||||||
|
<select
|
||||||
|
id="na-site"
|
||||||
|
required
|
||||||
|
value={siteId}
|
||||||
|
onChange={(e) => setSiteId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Choisir…</option>
|
||||||
|
{sites.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="na-zone">Emplacement dans le site *</label>
|
||||||
|
<select id="na-zone" name="locationId" required defaultValue="" disabled={!siteChoisi}>
|
||||||
|
<option value="" disabled>{siteChoisi ? 'Choisir…' : 'Choisissez un site'}</option>
|
||||||
|
{siteChoisi ? (
|
||||||
|
<option value={siteChoisi.id}>— le site lui-même —</option>
|
||||||
|
) : null}
|
||||||
|
{siteChoisi?.zones.map((z) => (
|
||||||
|
<option key={z.id} value={z.id}>{z.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte">
|
||||||
|
<h2>
|
||||||
|
3 · Organes{' '}
|
||||||
|
<span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>
|
||||||
|
— suivis individuellement (pannes par organe, essais réglementaires)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{organes.map((organe, index) => (
|
||||||
|
<div className="ligne-organe" key={organe.cle}>
|
||||||
|
<select
|
||||||
|
aria-label={`Type de l'organe ${index + 1}`}
|
||||||
|
value={organe.typeId}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOrganes((o) =>
|
||||||
|
o.map((x) => (x.cle === organe.cle ? { ...x, typeId: e.target.value } : x)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Type…</option>
|
||||||
|
{typesOrganes.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
aria-label={`Désignation de l'organe ${index + 1}`}
|
||||||
|
style={{ flex: 2 }}
|
||||||
|
placeholder="Désignation…"
|
||||||
|
value={organe.designation}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOrganes((o) =>
|
||||||
|
o.map((x) =>
|
||||||
|
x.cle === organe.cle ? { ...x, designation: e.target.value } : x,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-suppr"
|
||||||
|
aria-label={`Retirer l'organe ${index + 1}`}
|
||||||
|
onClick={() => setOrganes((o) => o.filter((x) => x.cle !== organe.cle))}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={() =>
|
||||||
|
setOrganes((o) => [...o, { cle: Date.now(), typeId: '', designation: '' }])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Ajouter un organe
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{creation.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button onClick={() => navigate('/ascenseurs')}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||||
|
Créer l'ascenseur (QR généré)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
297
apps/web/src/pages/personnes.tsx
Normal file
297
apps/web/src/pages/personnes.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import type { InvitationResponse } from '@siop/shared';
|
||||||
|
import {
|
||||||
|
useCreateTeam,
|
||||||
|
useInviteUser,
|
||||||
|
useResendInvitation,
|
||||||
|
useRoles,
|
||||||
|
useTeams,
|
||||||
|
useUsers,
|
||||||
|
} from '@/api/referentiel';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Avatar, initialsOf } from '@/components/avatar';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Modale } from '@/components/ui/modale';
|
||||||
|
|
||||||
|
/** Écran 6 des maquettes R1 : personnes (invitation par lien 7 j, jamais de
|
||||||
|
* mot de passe créé pour autrui) et équipes par zone. */
|
||||||
|
export default function PagePersonnes() {
|
||||||
|
const { data: users } = useUsers();
|
||||||
|
const { data: teams } = useTeams();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [modaleInvitation, setModaleInvitation] = useState(false);
|
||||||
|
const [modaleEquipe, setModaleEquipe] = useState(false);
|
||||||
|
const [lien, setLien] = useState<InvitationResponse | null>(null);
|
||||||
|
const renvoi = useResendInvitation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Personnes & équipes</h1>
|
||||||
|
<div className="actions">
|
||||||
|
{can('PEOPLE_TEAMS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => setModaleInvitation(true)}>
|
||||||
|
+ Inviter
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Personne</th>
|
||||||
|
<th>Rôle</th>
|
||||||
|
<th>Équipe</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(users ?? []).map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-[9px]">
|
||||||
|
<Avatar
|
||||||
|
initials={initialsOf(u.displayName)}
|
||||||
|
roleName={u.role.name}
|
||||||
|
className="h-[26px] w-[26px] text-[10.5px]"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<b>{u.displayName}</b>
|
||||||
|
<span className="sous">{u.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{u.role.name}</td>
|
||||||
|
<td>{u.teams.map((t) => t.name).join(', ') || '—'}</td>
|
||||||
|
<td>
|
||||||
|
{u.status === 'active' ? (
|
||||||
|
<span className="st termine">Actif</span>
|
||||||
|
) : u.status === 'invited' ? (
|
||||||
|
<span className="st attente">Invitation envoyée</span>
|
||||||
|
) : (
|
||||||
|
<span className="st annule">Désactivé</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{u.status === 'invited' && can('PEOPLE_TEAMS', 'edit') ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-[12px]"
|
||||||
|
disabled={renvoi.isPending}
|
||||||
|
onClick={() => renvoi.mutate(u.id, { onSuccess: setLien })}
|
||||||
|
>
|
||||||
|
Renvoyer le lien
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Équipes</h2>
|
||||||
|
<div className="equipes">
|
||||||
|
{(teams ?? []).map((t) => (
|
||||||
|
<div className="equipe" key={t.id}>
|
||||||
|
<b>{t.name}</b>
|
||||||
|
<span className="zone">{t.description ?? '—'}</span>
|
||||||
|
<div className="avatars">
|
||||||
|
{t.members.map((m) => (
|
||||||
|
<Avatar
|
||||||
|
key={m.id}
|
||||||
|
initials={m.initials}
|
||||||
|
roleName={m.roleName}
|
||||||
|
className="h-6 w-6 text-[10px]"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{t.members.length === 0 ? (
|
||||||
|
<span className="text-[12px] text-encre-3">aucun membre</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{can('PEOPLE_TEAMS', 'create') ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="equipe items-center justify-center text-encre-3"
|
||||||
|
style={{ borderStyle: 'dashed', cursor: 'pointer' }}
|
||||||
|
onClick={() => setModaleEquipe(true)}
|
||||||
|
>
|
||||||
|
<b style={{ fontWeight: 600 }}>+ Nouvelle équipe</b>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModaleInvitation
|
||||||
|
ouverte={modaleInvitation}
|
||||||
|
surFermeture={setModaleInvitation}
|
||||||
|
surLien={setLien}
|
||||||
|
/>
|
||||||
|
<ModaleEquipe ouverte={modaleEquipe} surFermeture={setModaleEquipe} />
|
||||||
|
<ModaleLien lien={lien} surFermeture={() => setLien(null)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModaleInvitation({
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
surLien,
|
||||||
|
}: {
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
surLien: (l: InvitationResponse) => void;
|
||||||
|
}) {
|
||||||
|
const { data: roles } = useRoles();
|
||||||
|
const { data: teams } = useTeams();
|
||||||
|
const invitation = useInviteUser();
|
||||||
|
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
const teamId = String(donnees.get('teamId'));
|
||||||
|
invitation.mutate(
|
||||||
|
{
|
||||||
|
email: String(donnees.get('email')),
|
||||||
|
displayName: String(donnees.get('displayName')),
|
||||||
|
roleId: String(donnees.get('roleId')),
|
||||||
|
teamIds: teamId ? [teamId] : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (reponse) => {
|
||||||
|
surFermeture(false);
|
||||||
|
surLien(reponse);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modale titre="Inviter une personne" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="inv-nom">Nom complet *</label>
|
||||||
|
<input id="inv-nom" name="displayName" required placeholder="Prénom Nom" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="inv-email">Email professionnel *</label>
|
||||||
|
<input id="inv-email" name="email" type="email" required placeholder="prenom.nom@spelev.ma" />
|
||||||
|
</div>
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="inv-role">Rôle *</label>
|
||||||
|
<select id="inv-role" name="roleId" required defaultValue="">
|
||||||
|
<option value="" disabled>Choisir…</option>
|
||||||
|
{(roles ?? []).map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="inv-equipe">Équipe (optionnel)</label>
|
||||||
|
<select id="inv-equipe" name="teamId" defaultValue="">
|
||||||
|
<option value="">—</option>
|
||||||
|
{(teams ?? []).map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="note-douce">
|
||||||
|
La personne recevra un <b>lien d'activation valable 7 jours</b> pour choisir
|
||||||
|
son mot de passe. Aucun compte n'est actif avant cela.
|
||||||
|
</p>
|
||||||
|
{invitation.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{invitation.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={invitation.isPending}>
|
||||||
|
Envoyer l'invitation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** L'envoi d'email n'existe pas encore : le lien se copie ici (décision R1). */
|
||||||
|
function ModaleLien({
|
||||||
|
lien,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
lien: InvitationResponse | null;
|
||||||
|
surFermeture: () => void;
|
||||||
|
}) {
|
||||||
|
const [copie, setCopie] = useState(false);
|
||||||
|
if (!lien) return null;
|
||||||
|
const url = `${window.location.origin}/activation?token=${lien.activationToken}`;
|
||||||
|
return (
|
||||||
|
<Modale titre="Lien d'activation émis" ouverte surFermeture={surFermeture}>
|
||||||
|
<p className="text-[13px] text-encre-2">
|
||||||
|
Transmettez ce lien à la personne (valable jusqu'au{' '}
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(lien.expiresAt))}) :
|
||||||
|
</p>
|
||||||
|
<div className="lien-copiable" data-testid="lien-activation">{url}</div>
|
||||||
|
<div className="pied">
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(url);
|
||||||
|
setCopie(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copie ? 'Copié ✓' : 'Copier le lien'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="prim" onClick={surFermeture}>Fermer</Button>
|
||||||
|
</div>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModaleEquipe({
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const creation = useCreateTeam();
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
creation.mutate(
|
||||||
|
{
|
||||||
|
name: String(donnees.get('name')),
|
||||||
|
description: String(donnees.get('description')) || undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: () => surFermeture(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Modale titre="Nouvelle équipe" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="eq-nom">Nom *</label>
|
||||||
|
<input id="eq-nom" name="name" required placeholder="Ex. : Rabat Agdal" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="eq-desc">Zone couverte</label>
|
||||||
|
<input id="eq-desc" name="description" placeholder="Sites couverts…" />
|
||||||
|
</div>
|
||||||
|
{creation.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
apps/web/src/pages/sites.tsx
Normal file
172
apps/web/src/pages/sites.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useCreateLocation, useLocations } from '@/api/referentiel';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { CarteGeo } from '@/components/carte-geo';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Modale } from '@/components/ui/modale';
|
||||||
|
import { construireArbre } from '@/lib/lieux';
|
||||||
|
|
||||||
|
/** Écran 1 des maquettes R1 : liste dense + carte OSM (positions PostGIS). */
|
||||||
|
export default function PageSites() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: locations } = useLocations();
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const [modale, setModale] = useState(false);
|
||||||
|
|
||||||
|
const sites = construireArbre(locations ?? []);
|
||||||
|
const totalAppareils = sites.reduce((n, s) => n + s.assetCount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Sites</h1>
|
||||||
|
<span className="filajout">
|
||||||
|
{sites.length} sites · {totalAppareils} ascenseurs
|
||||||
|
</span>
|
||||||
|
<div className="actions">
|
||||||
|
{can('LOCATIONS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => setModale(true)}>
|
||||||
|
+ Nouveau site
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Site</th>
|
||||||
|
<th>Ascenseurs</th>
|
||||||
|
<th>Gardien</th>
|
||||||
|
<th>Zones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sites.map((site) => (
|
||||||
|
<tr
|
||||||
|
key={site.id}
|
||||||
|
onClick={() => navigate(`/sites/${site.id}`)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<b>{site.name}</b>
|
||||||
|
<span className="sous">
|
||||||
|
{[site.address, site.city].filter(Boolean).join(', ') || '—'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">{site.assetCount}</td>
|
||||||
|
<td>{site.guardianName ?? '—'}</td>
|
||||||
|
<td className="num">{site.zones.length}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CarteGeo
|
||||||
|
pins={sites
|
||||||
|
.filter((s) => s.latitude != null && s.longitude != null)
|
||||||
|
.map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
latitude: s.latitude!,
|
||||||
|
longitude: s.longitude!,
|
||||||
|
libelle: `${s.name} · ${s.assetCount}`,
|
||||||
|
onClick: () => navigate(`/sites/${s.id}`),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NouveauSite ouverte={modale} surFermeture={setModale} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NouveauSite({
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const creation = useCreateLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [position, setPosition] = useState<{ lat: number; lng: number } | null>(null);
|
||||||
|
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const donnees = new FormData(e.currentTarget);
|
||||||
|
creation.mutate(
|
||||||
|
{
|
||||||
|
name: String(donnees.get('name')),
|
||||||
|
address: String(donnees.get('address')) || undefined,
|
||||||
|
city: String(donnees.get('city')) || undefined,
|
||||||
|
guardianName: String(donnees.get('guardianName')) || undefined,
|
||||||
|
guardianPhone: String(donnees.get('guardianPhone')) || undefined,
|
||||||
|
latitude: position?.lat,
|
||||||
|
longitude: position?.lng,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (site) => {
|
||||||
|
surFermeture(false);
|
||||||
|
navigate(`/sites/${site.id}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modale titre="Nouveau site" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="ns-nom">Nom du site *</label>
|
||||||
|
<input id="ns-nom" name="name" required placeholder="Ex. : Résidence Les Palmiers" />
|
||||||
|
</div>
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="ns-adresse">Adresse</label>
|
||||||
|
<input id="ns-adresse" name="address" placeholder="Bd, rue…" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="ns-ville">Ville</label>
|
||||||
|
<input id="ns-ville" name="city" placeholder="Casablanca" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="ns-gardien">Gardien</label>
|
||||||
|
<input id="ns-gardien" name="guardianName" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="ns-tel">Téléphone gardien</label>
|
||||||
|
<input id="ns-tel" name="guardianPhone" placeholder="06 …" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label>Position — cliquez sur la carte</label>
|
||||||
|
<CarteGeo
|
||||||
|
className="min-h-[180px]"
|
||||||
|
zoom={11}
|
||||||
|
onClick={(lat, lng) => setPosition({ lat, lng })}
|
||||||
|
pins={
|
||||||
|
position
|
||||||
|
? [{ id: 'nouveau', latitude: position.lat, longitude: position.lng, libelle: 'Nouveau site' }]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[12px] text-encre-3 num">
|
||||||
|
{position ? `${position.lat} N · ${position.lng} E` : 'Aucune position posée'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{creation.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||||
|
Créer le site
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -536,6 +536,220 @@ table {
|
|||||||
margin: 6px 4px;
|
margin: 6px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══ R1 — Référentiel (classes extraites de maquette-r1.html validée) ═══ */
|
||||||
|
/* 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 — le conteneur ; Leaflet rend à l'intérieur */
|
||||||
|
.geo {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--bordure);
|
||||||
|
border-radius: var(--rayon);
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 320px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.geo .leaflet-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
/* Pin maquette (divIcon Leaflet) */
|
||||||
|
.pin-geo {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50% 50% 50% 0;
|
||||||
|
transform: rotate(-45deg) translate(4px, -4px);
|
||||||
|
background: var(--primaire);
|
||||||
|
border: 2px solid var(--surface);
|
||||||
|
box-shadow: var(--ombre-menu);
|
||||||
|
}
|
||||||
|
.pin-geo::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.pin-geo.alerte { background: var(--prio-bloque); }
|
||||||
|
|
||||||
|
/* 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 .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; }
|
||||||
|
.champ select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid var(--bordure-forte);
|
||||||
|
border-radius: var(--rayon-controle);
|
||||||
|
font: inherit;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--encre);
|
||||||
|
}
|
||||||
|
.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 select, .ligne-organe input {
|
||||||
|
padding: 8px 11px;
|
||||||
|
border: 1px solid var(--bordure-forte);
|
||||||
|
border-radius: var(--rayon-controle);
|
||||||
|
font: inherit;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--encre);
|
||||||
|
}
|
||||||
|
.btn-suppr { color: var(--encre-3); padding: 6px; border-radius: 6px; border: 0; background: none; }
|
||||||
|
.btn-suppr:hover { color: var(--danger); background: var(--prio-bloque-fond); }
|
||||||
|
|
||||||
|
/* Étiquette QR imprimable (A6 — objet papier, blanc même en thème sombre) */
|
||||||
|
.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: 0.55; font-size: 12px; color: #dd8a0b; }
|
||||||
|
.etiquette .tete b { font-size: 20px; letter-spacing: 0.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; }
|
||||||
|
@media print {
|
||||||
|
body * { visibility: hidden; }
|
||||||
|
.etiquette, .etiquette * { visibility: visible; }
|
||||||
|
.etiquette { position: fixed; inset: 0 auto auto 0; box-shadow: none; margin: 10mm; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modale (Radix Dialog) — style maquette */
|
||||||
|
.voile-radix {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, var(--encre) 32%, transparent);
|
||||||
|
z-index: 70;
|
||||||
|
}
|
||||||
|
.modale {
|
||||||
|
position: fixed;
|
||||||
|
top: 10vh;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 460px;
|
||||||
|
max-width: 94vw;
|
||||||
|
max-height: 82vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
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;
|
||||||
|
z-index: 71;
|
||||||
|
}
|
||||||
|
.modale h3 { font-size: 15px; font-weight: 700; }
|
||||||
|
.modale .pied { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||||
|
.note-douce {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--encre-2);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.lien-copiable {
|
||||||
|
font-family: var(--police-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
word-break: break-all;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px dashed var(--bordure-forte);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* É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); }
|
||||||
|
|
||||||
|
/* Tables denses (liste ascenseurs / sites / personnes) */
|
||||||
|
.table { width: 100%; overflow-x: auto; }
|
||||||
|
.table table { width: 100%; font-size: 13.5px; }
|
||||||
|
.table th {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.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); }
|
||||||
|
.filajout { color: var(--encre-3); font-size: 12.5px; }
|
||||||
|
.filajout a { color: var(--encre-2); text-decoration: none; }
|
||||||
|
.filtres { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.filtres select {
|
||||||
|
border: 1px solid var(--bordure-forte);
|
||||||
|
border-radius: var(--rayon-controle);
|
||||||
|
padding: 7px 11px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--encre);
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
.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 .fin { margin-left: auto; display: flex; align-items: center; gap: 10px; color: var(--encre-2); font-size: 12.5px; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
/* ═══ Page /design : nuanciers ═══ */
|
/* ═══ Page /design : nuanciers ═══ */
|
||||||
.nuancier {
|
.nuancier {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ export default defineConfig({
|
|||||||
include: ['src/**/*.test.{ts,tsx}'],
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: { '@': path.resolve(__dirname, 'src') },
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
// Le paquet partagé est compilé en CJS pour l'API ; côté Vite on
|
||||||
|
// consomme sa SOURCE TypeScript (exports nommés, HMR, pas de rebuild).
|
||||||
|
'@siop/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
// Même topologie qu'en prod (nginx : /api → apps/api sans préfixe)
|
// Même topologie qu'en prod (nginx : /api → apps/api sans préfixe)
|
||||||
|
|||||||
863
docs/02-design/maquettes/maquette-r1.html
Normal file
863
docs/02-design/maquettes/maquette-r1.html
Normal file
@@ -0,0 +1,863 @@
|
|||||||
|
<!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 · R1 Référentiel</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
SIOP V2 — Maquettes HD de la release R1 « Référentiel » (design-first).
|
||||||
|
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE APPLICATIF (principe n°1).
|
||||||
|
La CSS est RÉPLIQUÉE de maquette-web.html (validée le 15/07/2026) ;
|
||||||
|
les seuls ajouts R1 sont dans le second bloc <style> (commenté).
|
||||||
|
L'écran « fiche ascenseur » (validé en R0) n'est pas redessiné ici.
|
||||||
|
-->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- ═══════════ BARRE DE REVUE ═══════════ -->
|
||||||
|
<header class="revue">
|
||||||
|
<b>SIOP V2 · Maquettes R1 — Référentiel</b>
|
||||||
|
<nav class="onglets" role="tablist" aria-label="Écrans">
|
||||||
|
<button role="tab" aria-selected="true" data-cible="sites">1 · Sites</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="fiche-site">2 · Fiche site</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="ascenseurs">3 · Ascenseurs</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="nouvel-ascenseur">4 · Nouvel ascenseur</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="etiquette">5 · Étiquette QR</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="personnes">6 · Personnes & équipes</button>
|
||||||
|
<button role="tab" aria-selected="false" data-cible="categories">7 · Catégories</button>
|
||||||
|
</nav>
|
||||||
|
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ═══════════ 1 · SITES ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="sites"><b>Intention :</b> le parc vu par la géographie — liste dense à gauche, <b>carte PostGIS</b> à droite (Leaflet/OSM dans l'app). Un site en alerte (OT « personne bloquée ») porte un pin rouge. La hiérarchie fine (halls, sous-sols) vit dans la fiche site, pas ici.</p>
|
||||||
|
<section class="ecran actif" id="sites">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Parc</div>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0z"/><circle cx="12" cy="10" r="3"/></svg>Sites</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 site, une ville…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
|
||||||
|
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Sites</h1><span class="filajout">12 sites · 34 ascenseurs</span>
|
||||||
|
<div class="actions"><button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouveau site</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="filtres">
|
||||||
|
<span class="select">Ville : <b>Toutes</b> ▾</span>
|
||||||
|
<span class="select">Trier : <b>Ascenseurs ↓</b> ▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="grille-2">
|
||||||
|
<div class="carte" style="padding:0">
|
||||||
|
<div class="table"><table>
|
||||||
|
<thead><tr><th>Site</th><th>Ascenseurs</th><th>Gardien</th><th>OT ouverts</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><b>Tour Atlas</b><span class="sous">Bd de la Corniche, Casablanca</span></td><td class="num">6</td><td>Karim Doukkali</td><td><span class="prio bloque"><i></i>1 urgence</span></td></tr>
|
||||||
|
<tr><td><b>Résidence Al Manar</b><span class="sous">Bd Hassan II, Mohammedia</span></td><td class="num">4</td><td>Hassan Alami</td><td class="num">2</td></tr>
|
||||||
|
<tr><td><b>Anfa Place</b><span class="sous">Bd de l'Océan Atlantique, Casablanca</span></td><td class="num">5</td><td>—</td><td class="num">1</td></tr>
|
||||||
|
<tr><td><b>Clinique Yasmine</b><span class="sous">Rue Ibn Rochd, Casablanca</span></td><td class="num">3</td><td>Rachid Mansouri</td><td class="num">0</td></tr>
|
||||||
|
<tr><td><b>Marina Center</b><span class="sous">Av. des FAR, Mohammedia</span></td><td class="num">2</td><td>—</td><td class="num">0</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
</div>
|
||||||
|
<div class="geo">
|
||||||
|
<div class="voie" style="left:0;right:0;top:38%;height:8px;transform:rotate(-4deg)"></div>
|
||||||
|
<div class="voie" style="left:22%;top:0;bottom:0;width:6px;transform:rotate(8deg)"></div>
|
||||||
|
<div class="voie" style="left:58%;top:0;bottom:0;width:5px;transform:rotate(-12deg)"></div>
|
||||||
|
<div class="pin alerte" style="left:30%;top:34%"><i></i><b>Tour Atlas · 6</b></div>
|
||||||
|
<div class="pin" style="left:66%;top:26%"><i></i><b>Al Manar · 4</b></div>
|
||||||
|
<div class="pin" style="left:47%;top:62%"><i></i><b>Anfa Place · 5</b></div>
|
||||||
|
<div class="pin" style="left:76%;top:70%"><i></i><b>Clinique Yasmine · 3</b></div>
|
||||||
|
<div class="attribution">Carte : OpenStreetMap (app) — positions PostGIS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 2 · FICHE SITE ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="fiche-site" hidden><b>Intention :</b> l'adresse, le contact terrain et la <b>hiérarchie d'emplacements</b> (site → hall/zone) avec les appareils rattachés. Règle métier : un ascenseur est rattaché à un emplacement ; un <b>organe</b>, lui, n'a jamais d'emplacement propre (il suit son ascenseur).</p>
|
||||||
|
<section class="ecran" id="fiche-site">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Parc</div>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0z"/><circle cx="12" cy="10" r="3"/></svg>Sites</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
|
||||||
|
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="filajout"><a href="#">Sites</a> / Tour Atlas</div>
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Tour Atlas</h1><span class="prio bloque"><i></i>1 urgence en cours</span>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn">Modifier</button>
|
||||||
|
<button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouvel ascenseur</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grille-3">
|
||||||
|
<div class="carte" style="grid-column:span 2">
|
||||||
|
<h2>Identité</h2>
|
||||||
|
<dl class="infos">
|
||||||
|
<dt>Adresse</dt><dd>Bd de la Corniche, Aïn Diab — Casablanca</dd>
|
||||||
|
<dt>Position</dt><dd class="num">33.6062 N · 7.6706 O <span style="color:var(--encre-2)">(posée sur la carte)</span></dd>
|
||||||
|
<dt>Gardien</dt><dd>Karim Doukkali · <span class="num">06 61 23 45 67</span></dd>
|
||||||
|
<dt>Syndic</dt><dd>Atlas Property Management</dd>
|
||||||
|
<dt>Contrat</dt><dd><span class="st termine">Actif</span> · 6 appareils au préventif</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="geo" style="min-height:200px">
|
||||||
|
<div class="voie" style="left:0;right:0;top:52%;height:7px;transform:rotate(-3deg)"></div>
|
||||||
|
<div class="pin" style="left:52%;top:48%"><i></i><b>Tour Atlas</b></div>
|
||||||
|
<div class="attribution">OSM (app)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grille-2">
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Emplacements & appareils</h2>
|
||||||
|
<ul class="arbre">
|
||||||
|
<li class="racine"><b>Tour Atlas</b><span class="meta">site</span><span class="fin">6 appareils</span></li>
|
||||||
|
<li><b>Hall principal</b><span class="meta">zone</span><span class="fin">Asc. A1 · A2</span></li>
|
||||||
|
<li><b>Tour bureaux (étages 1-24)</b><span class="meta">zone</span><span class="fin">Asc. B1 · <a href="#" style="color:var(--prio-bloque);font-weight:700">B2 ⚠</a></span></li>
|
||||||
|
<li><b>Parking sous-sol</b><span class="meta">zone</span><span class="fin">Monte-charge M1</span></li>
|
||||||
|
<li><b>Résidence (aile est)</b><span class="meta">zone</span><span class="fin">Asc. C1</span></li>
|
||||||
|
</ul>
|
||||||
|
<button class="btn ghost" style="margin-top:8px"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Ajouter un emplacement</button>
|
||||||
|
</div>
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Ascenseurs du site</h2>
|
||||||
|
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg></span><a href="#" style="color:var(--primaire);font-weight:600">B2 — Schindler 3300</a><span class="fin"><span class="st arret">À l'arrêt</span></span></div>
|
||||||
|
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg></span><a href="#" style="color:var(--primaire);font-weight:600">A1 — Otis Gen2</a><span class="fin"><span class="st termine">En service</span></span></div>
|
||||||
|
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg></span><a href="#" style="color:var(--primaire);font-weight:600">A2 — Otis Gen2</a><span class="fin"><span class="st termine">En service</span></span></div>
|
||||||
|
<div class="item-liste"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg></span><a href="#" style="color:var(--primaire);font-weight:600">M1 — Monte-charge Kone</a><span class="fin"><span class="st maintenance">En maintenance</span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 3 · ASCENSEURS ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="ascenseurs" hidden><b>Intention :</b> l'inventaire du parc, table dense « bureau ». Le <b>statut d'équipement</b> (En service / À l'arrêt / En maintenance) est distinct des statuts d'OT — un appareil à l'arrêt saute aux yeux. Référence cliquable → fiche validée en R0.</p>
|
||||||
|
<section class="ecran" id="ascenseurs">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Parc</div>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0z"/><circle cx="12" cy="10" r="3"/></svg>Sites</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 appareil, un n° de série…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
|
||||||
|
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Ascenseurs</h1><span class="filajout">34 appareils · 2 à l'arrêt</span>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn">Exporter</button>
|
||||||
|
<button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Nouvel ascenseur</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filtres">
|
||||||
|
<span class="select">Site : <b>Tous</b> ▾</span>
|
||||||
|
<span class="select">Statut : <b>Tous</b> ▾</span>
|
||||||
|
<span class="select">Catégorie : <b>Toutes</b> ▾</span>
|
||||||
|
<span class="select">Marque : <b>Toutes</b> ▾</span>
|
||||||
|
</div>
|
||||||
|
<div class="carte" style="padding:0">
|
||||||
|
<div class="table"><table>
|
||||||
|
<thead><tr><th>Appareil</th><th>Marque · modèle</th><th>Site / emplacement</th><th>Catégorie</th><th>Statut</th><th>Prochaine grille</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="ligne-bloque"><td class="ref num">B2</td><td>Schindler 3300</td><td>Tour Atlas<span class="sous">Tour bureaux</span></td><td>Électrique</td><td><span class="st arret">À l'arrêt</span></td><td><span class="prio bloque"><i></i>Personne bloquée</span></td></tr>
|
||||||
|
<tr><td class="ref num">A1</td><td>Otis Gen2 Premier</td><td>Résidence Al Manar<span class="sous">Hall principal</span></td><td>Électrique</td><td><span class="st termine">En service</span></td><td class="num">1 août</td></tr>
|
||||||
|
<tr><td class="ref num">C1</td><td>Kone MonoSpace 500</td><td>Anfa Place<span class="sous">Galerie commerciale</span></td><td>Électrique</td><td><span class="st termine">En service</span></td><td class="num">1 août</td></tr>
|
||||||
|
<tr><td class="ref num">M1</td><td>Kone TranSys</td><td>Tour Atlas<span class="sous">Parking sous-sol</span></td><td>Monte-charge</td><td><span class="st maintenance">En maintenance</span></td><td class="num">12 août</td></tr>
|
||||||
|
<tr><td class="ref num">D1</td><td>ThyssenKrupp Evolution</td><td>Clinique Yasmine<span class="sous">Bloc A</span></td><td>Électrique</td><td><span class="st termine">En service</span></td><td class="num">3 août</td></tr>
|
||||||
|
<tr><td class="ref num">E2</td><td>Otis HydroFit</td><td>Marina Center<span class="sous">Hall B</span></td><td>Hydraulique</td><td><span class="st termine">En service</span></td><td class="num">9 août</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 4 · NOUVEL ASCENSEUR ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="nouvel-ascenseur" hidden><b>Intention :</b> le parcours de recette R1 — identité, rattachement (site → emplacement), puis <b>organes</b> ajoutés ligne à ligne. Un organe n'a pas d'emplacement propre : il suit son appareil (contrainte en base). Le QR est généré à la création.</p>
|
||||||
|
<section class="ecran" id="nouvel-ascenseur">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Parc</div>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0z"/><circle cx="12" cy="10" r="3"/></svg>Sites</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
|
||||||
|
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page" style="max-width:860px">
|
||||||
|
<div class="filajout"><a href="#">Ascenseurs</a> / Nouvel ascenseur</div>
|
||||||
|
<div class="entete-page"><h1>Nouvel ascenseur</h1></div>
|
||||||
|
<div class="carte">
|
||||||
|
<h2>1 · Identité</h2>
|
||||||
|
<div class="form-grille">
|
||||||
|
<div class="champ"><label for="na-ref">Référence <em style="color:var(--danger)">*</em></label><input id="na-ref" placeholder="Ex. : B3"></div>
|
||||||
|
<div class="champ"><label for="na-serie">N° de série</label><input id="na-serie" placeholder="Plaque constructeur"></div>
|
||||||
|
<div class="champ"><label>Marque <em style="color:var(--danger)">*</em></label><div class="faux-select">Schindler <span>▾</span></div></div>
|
||||||
|
<div class="champ"><label for="na-modele">Modèle</label><input id="na-modele" placeholder="Ex. : 3300"></div>
|
||||||
|
<div class="champ"><label for="na-mes">Mise en service</label><input id="na-mes" placeholder="JJ/MM/AAAA"></div>
|
||||||
|
<div class="champ"><label>Catégorie <em style="color:var(--danger)">*</em></label><div class="faux-select">Ascenseur électrique <span>▾</span></div></div>
|
||||||
|
<div class="champ"><label for="na-charge">Charge (kg)</label><input id="na-charge" placeholder="630"></div>
|
||||||
|
<div class="champ"><label for="na-niv">Niveaux desservis</label><input id="na-niv" placeholder="8"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte">
|
||||||
|
<h2>2 · Rattachement</h2>
|
||||||
|
<div class="form-grille">
|
||||||
|
<div class="champ"><label>Site <em style="color:var(--danger)">*</em></label><div class="faux-select">Tour Atlas <span>▾</span></div></div>
|
||||||
|
<div class="champ"><label>Emplacement dans le site</label><div class="faux-select">Tour bureaux (étages 1-24) <span>▾</span></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte">
|
||||||
|
<h2>3 · Organes <span style="font-weight:400;text-transform:none;letter-spacing:0">— suivis individuellement (pannes par organe, essais réglementaires)</span></h2>
|
||||||
|
<div class="ligne-organe"><div class="faux-select">Portes cabine <span>▾</span></div><div class="faux-select" style="flex:2">Fermator 40/10</div><button class="btn-suppr" aria-label="Retirer">✕</button></div>
|
||||||
|
<div class="ligne-organe"><div class="faux-select">Treuil <span>▾</span></div><div class="faux-select" style="flex:2">Schindler FMB130</div><button class="btn-suppr" aria-label="Retirer">✕</button></div>
|
||||||
|
<div class="ligne-organe"><div class="faux-select">Parachute <span>▾</span></div><div class="faux-select vide" style="flex:2">Désignation…</div><button class="btn-suppr" aria-label="Retirer">✕</button></div>
|
||||||
|
<button class="btn ghost" style="margin-top:8px"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Ajouter un organe</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;justify-content:flex-end;gap:8px">
|
||||||
|
<button class="btn">Annuler</button>
|
||||||
|
<button class="btn prim">Créer l'ascenseur (QR généré)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 5 · ÉTIQUETTE QR ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="etiquette" hidden><b>Intention :</b> l'étiquette <b>imprimable</b> (A6, encre noire sur blanc — lisible et économique) collée en cabine : la marque, le QR, la consigne pour le gardien. Fond toujours blanc, même en thème sombre : c'est un objet papier.</p>
|
||||||
|
<section class="ecran" id="etiquette">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Parc</div>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0z"/><circle cx="12" cy="10" r="3"/></svg>Sites</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar">SI</span>
|
||||||
|
<div class="qui"><b>Salma Idrissi</b><br><small>Dispatcher ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="filajout"><a href="#">Ascenseurs</a> / <a href="#">Ascenseur A1</a> / Étiquette QR</div>
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Étiquette cabine — Ascenseur A1</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn">Télécharger PDF</button>
|
||||||
|
<button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M6 9V3h12v6M6 18h12v3H6zM4 9h16a2 2 0 0 1 2 2v5h-4"/><path d="M4 16H2v-5a2 2 0 0 1 2-2"/></svg>Imprimer (A6)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="zone-impression">
|
||||||
|
<div class="etiquette">
|
||||||
|
<div class="tete"><span class="marque">▲<br>▼</span><b>SIOP</b><span style="margin-left:auto;font-size:11px;color:#55647a">Maintenance d'ascenseurs<br>SPELEV · 05 22 00 00 00</span></div>
|
||||||
|
<div class="corps">
|
||||||
|
<svg class="qr-grand" width="120" height="120" viewBox="0 0 86 86" role="img" aria-label="QR code de l'ascenseur A1">
|
||||||
|
<rect width="86" height="86" fill="#fff"/>
|
||||||
|
<g fill="#1b2534">
|
||||||
|
<rect x="8" y="8" width="20" height="20"/><rect x="58" y="8" width="20" height="20"/><rect x="8" y="58" width="20" height="20"/>
|
||||||
|
<rect x="12" y="12" width="12" height="12" fill="#fff"/><rect x="62" y="12" width="12" height="12" fill="#fff"/><rect x="12" y="62" width="12" height="12" fill="#fff"/>
|
||||||
|
<rect x="15" y="15" width="6" height="6"/><rect x="65" y="15" width="6" height="6"/><rect x="15" y="65" width="6" height="6"/>
|
||||||
|
<rect x="34" y="10" width="4" height="4"/><rect x="42" y="10" width="8" height="4"/><rect x="34" y="18" width="4" height="8"/><rect x="46" y="18" width="4" height="4"/>
|
||||||
|
<rect x="10" y="34" width="4" height="4"/><rect x="18" y="34" width="8" height="4"/><rect x="30" y="34" width="4" height="8"/><rect x="42" y="34" width="4" height="4"/>
|
||||||
|
<rect x="50" y="34" width="4" height="8"/><rect x="58" y="38" width="8" height="4"/><rect x="70" y="34" width="4" height="4"/>
|
||||||
|
<rect x="34" y="46" width="8" height="4"/><rect x="46" y="46" width="4" height="4"/><rect x="58" y="50" width="4" height="4"/><rect x="66" y="46" width="8" height="4"/>
|
||||||
|
<rect x="34" y="58" width="4" height="8"/><rect x="42" y="62" width="8" height="4"/><rect x="58" y="58" width="8" height="8"/><rect x="70" y="70" width="6" height="6"/>
|
||||||
|
<rect x="42" y="70" width="4" height="6"/><rect x="50" y="70" width="4" height="4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h3>Ascenseur A1</h3>
|
||||||
|
<div class="ou">Résidence Al Manar<br>Hall principal — Mohammedia</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="consigne">Un problème ? Une personne bloquée ?<br>📱 Scannez ce code pour signaler</div>
|
||||||
|
<div class="ref">SIOP · A1 · OT-2020-4521 · siop2.apps.enset.top/q/a1</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte" style="flex:1;min-width:260px">
|
||||||
|
<h2>Ce que fait le scan</h2>
|
||||||
|
<dl class="infos" style="grid-template-columns:96px 1fr">
|
||||||
|
<dt>Gardien</dt><dd>ouvre le <b>portail de signalement</b>, équipement prérempli (écran validé R0)</dd>
|
||||||
|
<dt>Technicien</dt><dd>connecté → ouvre la <b>fiche de l'appareil</b></dd>
|
||||||
|
<dt>Impression</dt><dd>A6 autocollant, noir sur blanc — testée à 300 dpi</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 6 · PERSONNES & ÉQUIPES ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="personnes" hidden><b>Intention :</b> l'admin <b>invite</b> (jamais de mot de passe créé pour autrui) : lien d'activation 7 jours, statut « Invitation envoyée » jusqu'à activation. Les équipes regroupent les techniciens par zone — l'assignation d'OT (R2) s'appuiera dessus. La modale est montrée ouverte.</p>
|
||||||
|
<section class="ecran" id="personnes">
|
||||||
|
<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="#"><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="#" class="actif"><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 une personne…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-1)">YA</span>
|
||||||
|
<div class="qui"><b>Yasmine Alaoui</b><br><small>Administratrice ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="entete-page">
|
||||||
|
<h1>Personnes & équipes</h1>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn">Gérer les rôles</button>
|
||||||
|
<button class="btn prim"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M12 5v14M5 12h14"/></svg>Inviter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte" style="padding:0">
|
||||||
|
<div class="table"><table>
|
||||||
|
<thead><tr><th>Personne</th><th>Rôle</th><th>Équipe</th><th>Statut</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><div style="display:flex;align-items:center;gap:9px"><span class="avatar" style="width:26px;height:26px;font-size:10.5px">SI</span><div><b>Salma Idrissi</b><span class="sous">salma@spelev.ma</span></div></div></td><td>Dispatcher</td><td>—</td><td><span class="st termine">Actif</span></td><td></td></tr>
|
||||||
|
<tr><td><div style="display:flex;align-items:center;gap:9px"><span class="avatar" style="width:26px;height:26px;font-size:10.5px;background:var(--graf-3)">AB</span><div><b>Ahmed Benali</b><span class="sous">ahmed@spelev.ma</span></div></div></td><td>Technicien</td><td>Casablanca Centre</td><td><span class="st termine">Actif</span></td><td></td></tr>
|
||||||
|
<tr><td><div style="display:flex;align-items:center;gap:9px"><span class="avatar" style="width:26px;height:26px;font-size:10.5px;background:var(--graf-2)">FZ</span><div><b>Fatima-Zahra Idrissi</b><span class="sous">fz.idrissi@spelev.ma</span></div></div></td><td>Technicienne</td><td>Mohammedia</td><td><span class="st attente">Invitation envoyée</span></td><td><button class="btn ghost" style="padding:4px 8px;font-size:12px">Renvoyer le lien</button></td></tr>
|
||||||
|
<tr><td><div style="display:flex;align-items:center;gap:9px"><span class="avatar" style="width:26px;height:26px;font-size:10.5px;background:var(--st-encours)">YT</span><div><b>Youssef Tazi</b><span class="sous">y.tazi@spelev.ma</span></div></div></td><td>Technicien limité</td><td>Casablanca Centre</td><td><span class="st termine">Actif</span></td><td></td></tr>
|
||||||
|
<tr><td><div style="display:flex;align-items:center;gap:9px"><span class="avatar" style="width:26px;height:26px;font-size:10.5px;background:var(--st-annule)">KD</span><div><b>Karim Doukkali</b><span class="sous">gardien · Tour Atlas</span></div></div></td><td>Demandeur</td><td>—</td><td><span class="st termine">Actif</span></td><td></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table></div>
|
||||||
|
</div>
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Équipes</h2>
|
||||||
|
<div class="equipes">
|
||||||
|
<div class="equipe"><b>Casablanca Centre</b><span class="zone">Tour Atlas · Anfa Place · Clinique Yasmine</span><div class="avatars"><span class="avatar" style="background:var(--graf-3)">AB</span><span class="avatar" style="background:var(--st-encours)">YT</span></div></div>
|
||||||
|
<div class="equipe"><b>Mohammedia</b><span class="zone">Résidence Al Manar · Marina Center</span><div class="avatars"><span class="avatar" style="background:var(--graf-2)">FZ</span></div></div>
|
||||||
|
<div class="equipe" style="border-style:dashed;align-items:center;justify-content:center;color:var(--encre-3)"><b style="font-weight:600">+ Nouvelle équipe</b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="voile">
|
||||||
|
<div class="modale">
|
||||||
|
<h3>Inviter une personne</h3>
|
||||||
|
<div class="champ"><label for="inv-email">Email professionnel</label><input id="inv-email" placeholder="prenom.nom@spelev.ma"></div>
|
||||||
|
<div class="form-grille">
|
||||||
|
<div class="champ"><label>Rôle</label><div class="faux-select">Technicien <span>▾</span></div></div>
|
||||||
|
<div class="champ"><label>Équipe (optionnel)</label><div class="faux-select">Casablanca Centre <span>▾</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:12px;color:var(--encre-2);background:var(--surface-2);border-radius:8px;padding:8px 10px">
|
||||||
|
La personne recevra un <b>lien d'activation valable 7 jours</b> pour choisir son mot de passe. Aucun compte n'est actif avant cela.
|
||||||
|
</div>
|
||||||
|
<div class="pied"><button class="btn">Annuler</button><button class="btn prim">Envoyer l'invitation</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════ 7 · CATÉGORIES ═══════════ -->
|
||||||
|
<p class="note-ecran" data-note="categories" hidden><b>Intention :</b> les référentiels administrables du parc — catégories d'équipement et types d'organes. Simples listes : renommer, ajouter, désactiver (jamais supprimer si utilisé). Les référentiels du bilan codé arriveront en R2 sur le même modèle.</p>
|
||||||
|
<section class="ecran" id="categories">
|
||||||
|
<div class="app"><aside class="nav">
|
||||||
|
<div class="logo"><span class="marque">▲<br>▼</span><div><b>SIOP</b></div></div>
|
||||||
|
<div class="groupe">Parc</div>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M10 7l2-2 2 2M10 17l2 2 2-2"/></svg>Ascenseurs</a>
|
||||||
|
<a href="#"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M20 10c0 6-8 12-8 12S4 16 4 10a8 8 0 1 1 16 0z"/><circle cx="12" cy="10" r="3"/></svg>Sites</a>
|
||||||
|
<a href="#" class="actif"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M4 7h16M4 12h16M4 17h10"/></svg>Catégories</a>
|
||||||
|
</aside>
|
||||||
|
<div class="contenu">
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="recherche"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="11" cy="11" r="7"/><path d="m21 21-4-4"/></svg>Rechercher…</div>
|
||||||
|
<div class="compte"><span class="chip-demo">DÉMO</span><span class="avatar" style="background:var(--graf-1)">YA</span>
|
||||||
|
<div class="qui"><b>Yasmine Alaoui</b><br><small>Administratrice ▾</small></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="page">
|
||||||
|
<div class="entete-page"><h1>Catégories</h1></div>
|
||||||
|
<div class="grille-2">
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Catégories d'équipement</h2>
|
||||||
|
<div class="item-liste">Ascenseur électrique<span class="fin"><span class="num">26 appareils</span> · <a href="#" style="color:var(--primaire)">Renommer</a></span></div>
|
||||||
|
<div class="item-liste">Ascenseur hydraulique<span class="fin"><span class="num">4</span> · <a href="#" style="color:var(--primaire)">Renommer</a></span></div>
|
||||||
|
<div class="item-liste">Monte-charge<span class="fin"><span class="num">3</span> · <a href="#" style="color:var(--primaire)">Renommer</a></span></div>
|
||||||
|
<div class="item-liste">EPMR (plateforme PMR)<span class="fin"><span class="num">1</span> · <a href="#" style="color:var(--primaire)">Renommer</a></span></div>
|
||||||
|
<div class="ligne-organe" style="border:0;margin-top:6px"><input placeholder="Nouvelle catégorie…" style="flex:1;padding:8px 11px;border:1px solid var(--bordure-forte);border-radius:8px;font:inherit;background:var(--surface);color:var(--encre)"><button class="btn prim">Ajouter</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="carte">
|
||||||
|
<h2>Types d'organes</h2>
|
||||||
|
<div class="item-liste">Portes cabine / palières<span class="fin"><span class="num">34</span></span></div>
|
||||||
|
<div class="item-liste">Treuil / machinerie<span class="fin"><span class="num">34</span></span></div>
|
||||||
|
<div class="item-liste">Parachute<span class="fin"><span class="num">31</span></span></div>
|
||||||
|
<div class="item-liste">Armoire de commande<span class="fin"><span class="num">34</span></span></div>
|
||||||
|
<div class="item-liste">Boutons & signalisation<span class="fin"><span class="num">29</span></span></div>
|
||||||
|
<div class="item-liste" style="color:var(--encre-3)">Limiteur de vitesse <span style="font-size:11px">(désactivé)</span><span class="fin"><a href="#" style="color:var(--primaire)">Réactiver</a></span></div>
|
||||||
|
<div class="ligne-organe" style="border:0;margin-top:6px"><input placeholder="Nouveau type…" style="flex:1;padding:8px 11px;border:1px solid var(--bordure-forte);border-radius:8px;font:inherit;background:var(--surface);color:var(--encre)"><button class="btn prim">Ajouter</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="carte" style="border-style:dashed;color:var(--encre-2);font-size:13px">
|
||||||
|
Une catégorie <b>utilisée</b> ne peut pas être supprimée — seulement renommée ou désactivée (elle disparaît des sélecteurs, l'historique reste lisible).
|
||||||
|
</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>
|
||||||
@@ -50,10 +50,74 @@ model User {
|
|||||||
| `demo-login` refuse tout compte `isDemo=false` | service auth (+ test e2e) |
|
| `demo-login` refuse tout compte `isDemo=false` | service auth (+ test e2e) |
|
||||||
| Matrice complète : chaque rôle a une ligne par catégorie d'objet | seed idempotent (+ test) |
|
| Matrice complète : chaque rôle a une ligne par catégorie d'objet | seed idempotent (+ test) |
|
||||||
|
|
||||||
|
## R1 — Référentiel
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
enum CategoryKind { EQUIPMENT COMPONENT_TYPE } // référentiels administrables
|
||||||
|
enum AssetStatus { IN_SERVICE OUT_OF_SERVICE UNDER_MAINTENANCE }
|
||||||
|
|
||||||
|
model Category { // renommable/désactivable, JAMAIS supprimée si utilisée
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
kind CategoryKind
|
||||||
|
name String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
@@unique([kind, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Location { // site (parentId null) → zone (1 niveau max, vérifié service)
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String
|
||||||
|
parentId String? @db.Uuid // auto-relation « LocationTree »
|
||||||
|
address String? city String?
|
||||||
|
guardianName String? guardianPhone String?
|
||||||
|
latitude Float? longitude Float? // saisies par la carte
|
||||||
|
// + colonne PostGIS générée (migration SQL) :
|
||||||
|
// position geography(Point,4326) GENERATED ALWAYS AS (ST_Point(longitude,latitude)::geography) STORED
|
||||||
|
}
|
||||||
|
|
||||||
|
model Asset { // l'appareil (ascenseur, monte-charge…)
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
reference String @unique // « A1 », « B2 » — imprimée sur le QR
|
||||||
|
brand String model String? serialNumber String?
|
||||||
|
commissionedAt DateTime? loadKg Int? floors Int?
|
||||||
|
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
||||||
|
categoryId String @db.Uuid // Category(kind=EQUIPMENT)
|
||||||
|
locationId String @db.Uuid // rattachement obligatoire
|
||||||
|
components AssetComponent[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model AssetComponent { // organe — PAS de colonne emplacement : la règle
|
||||||
|
id String @id @default(uuid()) @db.Uuid // « un organe n'a pas d'emplacement
|
||||||
|
assetId String @db.Uuid // propre » est garantie PAR
|
||||||
|
typeId String @db.Uuid // CONSTRUCTION (table dédiée),
|
||||||
|
designation String? // plus besoin du CHECK v1
|
||||||
|
}
|
||||||
|
|
||||||
|
model Team { // équipes par zone — l'assignation d'OT (R2) s'appuiera dessus
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @unique
|
||||||
|
description String?
|
||||||
|
members User[] // m2m implicite
|
||||||
|
}
|
||||||
|
|
||||||
|
// User (R0) reçoit : phone?, teams Team[], et l'invitation (ADR maquettes R1) :
|
||||||
|
// activationToken String? @unique + activationExpiresAt DateTime?
|
||||||
|
// statut dérivé : invité = passwordHash null && token présent ; actif = hash présent
|
||||||
|
```
|
||||||
|
|
||||||
|
**Invariants R1** :
|
||||||
|
|
||||||
|
| Invariant | Où il vit |
|
||||||
|
| --- | --- |
|
||||||
|
| Hiérarchie d'emplacements limitée à 2 niveaux (site → zone) | service locations (+ test) |
|
||||||
|
| Un organe n'a pas d'emplacement propre | par construction (AssetComponent sans locationId) |
|
||||||
|
| `Category(kind)` cohérente avec l'usage (EQUIPMENT sur Asset, COMPONENT_TYPE sur organe) | services (+ test) |
|
||||||
|
| Catégorie utilisée : jamais supprimée (désactivation seulement) | service categories (+ test) |
|
||||||
|
| Lien d'activation : 7 jours, usage unique, aucun compte actif avant | service users (+ test e2e) |
|
||||||
|
| Position : lat/lng saisis, colonne PostGIS **générée** pour les requêtes spatiales futures | migration SQL |
|
||||||
|
|
||||||
## À 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)
|
||||||
|
|
||||||
- **R1** : `Location` (hiérarchie + colonne PostGIS), `Asset` + organes (hiérarchie 2 niveaux,
|
|
||||||
CHECK « un organe n'a pas d'emplacement propre »), `Category`, `Team`.
|
|
||||||
- **R2** : `WorkOrder` (machine à états stricte, priorité « personne bloquée »), `Request`
|
- **R2** : `WorkOrder` (machine à états stricte, priorité « personne bloquée »), `Request`
|
||||||
(lien 1-1 vers OT), `InterventionReport` (bilan codé 6 champs → `ReferenceValue`),
|
(lien 1-1 vers OT), `InterventionReport` (bilan codé 6 champs → `ReferenceValue`),
|
||||||
`TaskTemplate`/`PreventivePlan`/`ChecklistItem` (périodicité calendrier), `Meter`.
|
`TaskTemplate`/`PreventivePlan`/`ChecklistItem` (périodicité calendrier), `Meter`.
|
||||||
|
|||||||
@@ -4,6 +4,75 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-16 — Pr. Daaif (+ Claude) — 🏁 R1 CLOSE : recettée, déployée, taguée
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **Recette R1 prononcée par le référent** (écrans ↔ maquette-r1, deux thèmes).
|
||||||
|
- **Déploiement vérifié en ligne** (<https://siop2.apps.enset.top>) : migration `r1_referentiel` + seed appliqués au boot du conteneur — 5 sites, 8 appareils, écrans /sites servis ; parcours API admin vérifié (locations, assets).
|
||||||
|
- DoD complète (recette, CI 6 jobs verts, revue pixel, production, journal) → **tag `release/r1`**.
|
||||||
|
|
||||||
|
**Prochaine étape** : R2 Exploitation. Maquettes R0 déjà validées pour tableau de bord, liste OT, fiche OT (bilan codé), portail demandeur ; il restera à maquetter le préventif (gabarits, grille du mois, checklist) avant tout code R2 — design d'abord.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-16 — Pr. Daaif (+ Claude) — R1.2 : écrans web du référentiel
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **7 écrans fidèles à maquette-r1.html** : Sites (liste + **carte Leaflet/OSM réelle**, pins maquette, création en modale avec position posée au clic), Fiche site (arbre site→zones, appareils, ajout de zone), Ascenseurs (table dense, filtres site/statut, strie rouge sur appareil à l'arrêt), Fiche appareil (écran validé R0 : identité, organes ± ajout/retrait, changement de statut, **QR réel** ; historique R2 et documents R3 annoncés sans simulation), Nouvel ascenseur (identité → rattachement → organes ligne à ligne), **Étiquette A6 imprimable** (`window.print` n'imprime qu'elle, objet papier même en sombre), Personnes & équipes (invitation → **lien d'activation affiché à copier** — pas d'email en R1, décision explicite ; renvoi de lien ; équipes), Catégories (ajout, renommage inline, désactivation).
|
||||||
|
- **Page /activation** (cible du lien d'invitation, moule visuel de la connexion) — la personne choisit son mot de passe et entre directement.
|
||||||
|
- **Navigation pilotée par la matrice** : les entrées livrées n'apparaissent que si `canView` (Catégories exige SETTINGS) ; boutons de création/édition conditionnés par `can()` — l'API re-vérifie de toute façon.
|
||||||
|
- **e2e Playwright** : la **recette R1 officielle rejouée intégralement** (site → zone → appareil+organe → étiquette → invitation → activation du compte → l'invité est connecté) + un parcours « la matrice pilote l'UI » (Technicien : lecture sans boutons) ; purge idempotente des données de test en globalSetup. 5 tests verts, 4 vitest, build prod, lint.
|
||||||
|
|
||||||
|
**Leçon**
|
||||||
|
|
||||||
|
- Paquet workspace CJS + Vite : les exports nommés runtime échouent (pas de pré-bundle des paquets liés) → alias Vite `@siop/shared` → **source TypeScript** ; l'API garde le dist CJS. Symptôme vicieux : app blanche, tests e2e tous rouges.
|
||||||
|
|
||||||
|
**Prochaine étape** : R1.3 — recette R1 avec le référent (revue pixel écrans ↔ maquette-r1), déploiement (CI → Dokploy), tag `release/r1`. Puis R2 Exploitation (maquettes déjà validées pour OT/demandes/portail).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-16 — Pr. Daaif (+ Claude) — R1.1 : socle backend du référentiel
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **Modèle R1** (doc + Prisma + migration `r1_referentiel`) : Category (kind EQUIPMENT/COMPONENT_TYPE), Location (site → zone, lat/lng + **colonne PostGIS générée** `geography(Point,4326)` + index GIST), Asset (statut d'équipement), AssetComponent (organe **sans emplacement par construction**), Team (m2m User), invitation sur User (token unique + expiration). Migration **autosuffisante** (`CREATE EXTENSION IF NOT EXISTS postgis`).
|
||||||
|
- **Contrat** : 21 nouvelles opérations (26 au total) — categories/locations/assets+organes/teams/users/roles/invitations/activate ; générateur OpenAPI étendu aux paramètres de chemin ; spec + client web régénérés dans le même commit.
|
||||||
|
- **API** : 4 nouveaux modules + gestion des personnes, tous sous `@RequirePermission` (la matrice décide) ; invariants en service : profondeur 2, kinds de catégories, catégorie jamais supprimée, lien d'activation 7 jours à usage unique qui connecte directement.
|
||||||
|
- **Seed** : parc de la maquette (5 sites + 8 zones, 8 appareils dont B2 à l'arrêt et M1 en maintenance, organes d'A1/B2, 9 catégories, 2 équipes).
|
||||||
|
- **36 tests verts** (couverture 96 % stmts / 85 % branches) : parcours de recette site→zone→appareil→organes, profondeur 3 refusée, matrice vivante (Technicien lit mais ne crée pas), invitation→activation complète (lien périmé/consommé/renvoyé). Smoke test sur build de prod : sites avec compteurs, fiche A1 et ses 4 organes.
|
||||||
|
- **CI** : bascule sur `postgis/postgis:18-3.6` (la migration R1 l'exige) — le moment anticipé dans le commentaire du workflow.
|
||||||
|
|
||||||
|
**Leçons**
|
||||||
|
|
||||||
|
- Migration modifiée après application locale ⇒ réaligner son checksum dans `_prisma_migrations` (ou reset) — d'où la règle : rendre la migration autosuffisante AVANT de l'appliquer.
|
||||||
|
- Un serveur `reuseExistingServer` de Playwright peut squatter :3000 et faire tester un dist périmé — tuer le port avant tout smoke test.
|
||||||
|
|
||||||
|
**Prochaine étape** : R1.2 `apps/web` — écrans Sites (+ carte Leaflet/OSM), Fiche site, Ascenseurs, Nouvel ascenseur, Étiquette QR, Personnes & équipes, Catégories, fidèles à maquette-r1.html.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-16 — Pr. Daaif (+ Claude) — R0 CLOSE (tag) · R1 ouverte : maquettes à valider
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **R0 close** : recette prononcée par le référent, tag `release/r0` poussé (DoD complète : CI verte, revue pixel, production en ligne, journal).
|
||||||
|
- **R1 « Référentiel » ouverte — design d'abord** (principe n°1) : `maquette-r1.html` produite dans le moule validé (CSS répliquée de `maquette-web.html`, mêmes tokens/typo/motifs) — **7 écrans** : Sites (liste + carte PostGIS), Fiche site (hiérarchie d'emplacements), Ascenseurs (liste, statuts d'équipement distincts des statuts OT), Nouvel ascenseur (identité → rattachement → organes), Étiquette QR imprimable (A6 papier, blanche même en thème sombre), Personnes & équipes (invitation par lien d'activation 7 jours, modale montrée), Catégories (référentiels administrables, jamais de suppression si utilisé).
|
||||||
|
- Rendu vérifié en Chrome headless : 7 écrans + 2 contrôles thème sombre, zéro erreur.
|
||||||
|
|
||||||
|
**Décisions (proposées à la validation)**
|
||||||
|
|
||||||
|
- Statuts d'équipement (En service / À l'arrêt / En maintenance) **distincts** des statuts OT ; l'appareil à l'arrêt porte la strie rouge.
|
||||||
|
- Un organe **n'a jamais d'emplacement propre** : il suit son appareil (contrainte en base, comme en v1).
|
||||||
|
- Invitation par **lien d'activation** (7 jours) — aucun mot de passe créé pour autrui ; compte inactif avant activation.
|
||||||
|
- Catégorie utilisée : renommage/désactivation seulement, jamais de suppression.
|
||||||
|
|
||||||
|
**⛔ Bloquant** : validation des maquettes R1 par le référent avant toute ligne de code applicatif R1.
|
||||||
|
**→ Levé le 16/07/2026 : maquettes R1 et les 4 décisions de conception VALIDÉES par le référent.** Lancement R1.1 (socle backend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-16 — Pr. Daaif (+ Claude) — 🚀 R0 EN PRODUCTION : <https://siop2.apps.enset.top>
|
## 2026-07-16 — Pr. Daaif (+ Claude) — 🚀 R0 EN PRODUCTION : <https://siop2.apps.enset.top>
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
2265
docs/openapi.json
2265
docs/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,16 @@ 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
|
||||||
|
? {
|
||||||
|
parameters: op.pathParams.map((name) => ({
|
||||||
|
name,
|
||||||
|
in: 'path',
|
||||||
|
required: true,
|
||||||
|
schema: { type: 'string', format: 'uuid' },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }),
|
...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }),
|
||||||
...(op.demoOnly
|
...(op.demoOnly
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -7,6 +7,36 @@ import {
|
|||||||
} from './schemas/auth';
|
} from './schemas/auth';
|
||||||
import { MeResponseSchema } from './schemas/users';
|
import { MeResponseSchema } from './schemas/users';
|
||||||
import { HealthResponseSchema } from './schemas/health';
|
import { HealthResponseSchema } from './schemas/health';
|
||||||
|
import {
|
||||||
|
AssetCreateSchema,
|
||||||
|
AssetComponentCreateSchema,
|
||||||
|
AssetComponentSchema,
|
||||||
|
AssetDetailSchema,
|
||||||
|
AssetsResponseSchema,
|
||||||
|
AssetUpdateSchema,
|
||||||
|
CategoriesResponseSchema,
|
||||||
|
CategoryCreateSchema,
|
||||||
|
CategorySchema,
|
||||||
|
CategoryUpdateSchema,
|
||||||
|
LocationCreateSchema,
|
||||||
|
LocationSchema,
|
||||||
|
LocationsResponseSchema,
|
||||||
|
LocationUpdateSchema,
|
||||||
|
TeamCreateSchema,
|
||||||
|
TeamSchema,
|
||||||
|
TeamsResponseSchema,
|
||||||
|
TeamUpdateSchema,
|
||||||
|
} from './schemas/referentiel';
|
||||||
|
import {
|
||||||
|
ActivateRequestSchema,
|
||||||
|
ActivateResponseSchema,
|
||||||
|
InvitationCreateSchema,
|
||||||
|
InvitationResponseSchema,
|
||||||
|
RolesResponseSchema,
|
||||||
|
UserAdminSchema,
|
||||||
|
UsersResponseSchema,
|
||||||
|
UserUpdateSchema,
|
||||||
|
} from './schemas/users-admin';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contrat d'API R0 — source unique de vérité (règle d'or ADR-001).
|
* Contrat d'API R0 — source unique de vérité (règle d'or ADR-001).
|
||||||
@@ -23,6 +53,8 @@ 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. */
|
||||||
|
pathParams?: string[];
|
||||||
request?: { name: string; schema: z.ZodType };
|
request?: { name: string; schema: z.ZodType };
|
||||||
responses: Record<
|
responses: Record<
|
||||||
number,
|
number,
|
||||||
@@ -86,6 +118,255 @@ export const API_CONTRACT: ApiOperation[] = [
|
|||||||
401: { description: 'Non authentifié' },
|
401: { description: 'Non authentifié' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
operationId: 'activateAccount',
|
||||||
|
method: 'post',
|
||||||
|
path: '/auth/activate',
|
||||||
|
summary: 'Activer un compte invité (lien 7 jours) — connecte directement',
|
||||||
|
tags: ['auth'],
|
||||||
|
isPublic: true,
|
||||||
|
request: { name: 'ActivateRequest', schema: ActivateRequestSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Compte activé et connecté', name: 'AuthResponse', schema: ActivateResponseSchema },
|
||||||
|
400: { description: 'Lien invalide ou expiré' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ————— R1 · Référentiel —————
|
||||||
|
{
|
||||||
|
operationId: 'listCategories',
|
||||||
|
method: 'get',
|
||||||
|
path: '/categories',
|
||||||
|
summary: 'Référentiels administrables (catégories d’équipement, types d’organes)',
|
||||||
|
tags: ['categories'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'CategoriesResponse', schema: CategoriesResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'createCategory',
|
||||||
|
method: 'post',
|
||||||
|
path: '/categories',
|
||||||
|
summary: 'Ajouter une catégorie',
|
||||||
|
tags: ['categories'],
|
||||||
|
request: { name: 'CategoryCreate', schema: CategoryCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Créée', name: 'Category', schema: CategorySchema },
|
||||||
|
409: { description: 'Nom déjà utilisé pour ce type' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateCategory',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/categories/{id}',
|
||||||
|
summary: 'Renommer ou (dés)activer — jamais de suppression si utilisée',
|
||||||
|
tags: ['categories'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'CategoryUpdate', schema: CategoryUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Mise à jour', name: 'Category', schema: CategorySchema },
|
||||||
|
404: { description: 'Inconnue' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listLocations',
|
||||||
|
method: 'get',
|
||||||
|
path: '/locations',
|
||||||
|
summary: 'Sites et zones (liste plate, le client construit l’arbre)',
|
||||||
|
tags: ['locations'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'LocationsResponse', schema: LocationsResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'createLocation',
|
||||||
|
method: 'post',
|
||||||
|
path: '/locations',
|
||||||
|
summary: 'Créer un site (sans parent) ou une zone (profondeur max 2)',
|
||||||
|
tags: ['locations'],
|
||||||
|
request: { name: 'LocationCreate', schema: LocationCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Créé', name: 'Location', schema: LocationSchema },
|
||||||
|
400: { description: 'Hiérarchie trop profonde' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateLocation',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/locations/{id}',
|
||||||
|
summary: 'Modifier un emplacement',
|
||||||
|
tags: ['locations'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'LocationUpdate', schema: LocationUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Mis à jour', name: 'Location', schema: LocationSchema },
|
||||||
|
404: { description: 'Inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listAssets',
|
||||||
|
method: 'get',
|
||||||
|
path: '/assets',
|
||||||
|
summary: 'Inventaire des appareils',
|
||||||
|
tags: ['assets'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'AssetsResponse', schema: AssetsResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'getAsset',
|
||||||
|
method: 'get',
|
||||||
|
path: '/assets/{id}',
|
||||||
|
summary: 'Fiche appareil (identité + organes)',
|
||||||
|
tags: ['assets'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Fiche', name: 'AssetDetail', schema: AssetDetailSchema },
|
||||||
|
404: { description: 'Inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'createAsset',
|
||||||
|
method: 'post',
|
||||||
|
path: '/assets',
|
||||||
|
summary: 'Créer un appareil (avec ses organes) — le QR découle de la référence',
|
||||||
|
tags: ['assets'],
|
||||||
|
request: { name: 'AssetCreate', schema: AssetCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Créé', name: 'AssetDetail', schema: AssetDetailSchema },
|
||||||
|
409: { description: 'Référence déjà utilisée' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateAsset',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/assets/{id}',
|
||||||
|
summary: 'Modifier un appareil (dont son statut d’équipement)',
|
||||||
|
tags: ['assets'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'AssetUpdate', schema: AssetUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Mis à jour', name: 'AssetDetail', schema: AssetDetailSchema },
|
||||||
|
404: { description: 'Inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'addAssetComponent',
|
||||||
|
method: 'post',
|
||||||
|
path: '/assets/{id}/components',
|
||||||
|
summary: 'Ajouter un organe (jamais d’emplacement propre)',
|
||||||
|
tags: ['assets'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'AssetComponentCreate', schema: AssetComponentCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Ajouté', name: 'AssetComponent', schema: AssetComponentSchema },
|
||||||
|
400: { description: 'Le type choisi n’est pas un type d’organe' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'removeAssetComponent',
|
||||||
|
method: 'delete',
|
||||||
|
path: '/assets/{id}/components/{componentId}',
|
||||||
|
summary: 'Retirer un organe',
|
||||||
|
tags: ['assets'],
|
||||||
|
pathParams: ['id', 'componentId'],
|
||||||
|
responses: {
|
||||||
|
204: { description: 'Retiré' },
|
||||||
|
404: { description: 'Inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listTeams',
|
||||||
|
method: 'get',
|
||||||
|
path: '/teams',
|
||||||
|
summary: 'Équipes et leurs membres',
|
||||||
|
tags: ['teams'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'TeamsResponse', schema: TeamsResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'createTeam',
|
||||||
|
method: 'post',
|
||||||
|
path: '/teams',
|
||||||
|
summary: 'Créer une équipe',
|
||||||
|
tags: ['teams'],
|
||||||
|
request: { name: 'TeamCreate', schema: TeamCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Créée', name: 'Team', schema: TeamSchema },
|
||||||
|
409: { description: 'Nom déjà utilisé' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateTeam',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/teams/{id}',
|
||||||
|
summary: 'Modifier une équipe (nom, description, membres)',
|
||||||
|
tags: ['teams'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'TeamUpdate', schema: TeamUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Mise à jour', name: 'Team', schema: TeamSchema },
|
||||||
|
404: { description: 'Inconnue' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listUsers',
|
||||||
|
method: 'get',
|
||||||
|
path: '/users',
|
||||||
|
summary: 'Personnes (statut dérivé : actif / invité / désactivé)',
|
||||||
|
tags: ['users'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'UsersResponse', schema: UsersResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listRoles',
|
||||||
|
method: 'get',
|
||||||
|
path: '/roles',
|
||||||
|
summary: 'Les 7 rôles (pour l’invitation)',
|
||||||
|
tags: ['users'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'RolesResponse', schema: RolesResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'inviteUser',
|
||||||
|
method: 'post',
|
||||||
|
path: '/users/invitations',
|
||||||
|
summary: 'Inviter — crée le compte inactif et émet le lien d’activation (7 j)',
|
||||||
|
tags: ['users'],
|
||||||
|
request: { name: 'InvitationCreate', schema: InvitationCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Invitation émise', name: 'InvitationResponse', schema: InvitationResponseSchema },
|
||||||
|
409: { description: 'Email déjà utilisé' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'resendInvitation',
|
||||||
|
method: 'post',
|
||||||
|
path: '/users/{id}/invitation',
|
||||||
|
summary: 'Régénérer le lien d’activation d’un compte non activé',
|
||||||
|
tags: ['users'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Nouveau lien', name: 'InvitationResponse', schema: InvitationResponseSchema },
|
||||||
|
409: { description: 'Compte déjà activé' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateUser',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/users/{id}',
|
||||||
|
summary: 'Modifier une personne (rôle, équipes, activation du compte)',
|
||||||
|
tags: ['users'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'UserUpdate', schema: UserUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Mise à jour', name: 'UserAdmin', schema: UserAdminSchema },
|
||||||
|
404: { description: 'Inconnue' },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
operationId: 'getHealth',
|
operationId: 'getHealth',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export * from './permissions';
|
export * from './permissions';
|
||||||
|
export * from './referentiel';
|
||||||
export * from './schemas/auth';
|
export * from './schemas/auth';
|
||||||
export * from './schemas/users';
|
export * from './schemas/users';
|
||||||
|
export * from './schemas/users-admin';
|
||||||
|
export * from './schemas/referentiel';
|
||||||
export * from './schemas/health';
|
export * from './schemas/health';
|
||||||
export * from './contract';
|
export * from './contract';
|
||||||
|
|||||||
29
packages/shared/src/referentiel.ts
Normal file
29
packages/shared/src/referentiel.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/** Vocabulaires du référentiel (R1) — partagés API / web / seed. */
|
||||||
|
|
||||||
|
export const ASSET_STATUSES = [
|
||||||
|
'IN_SERVICE',
|
||||||
|
'OUT_OF_SERVICE',
|
||||||
|
'UNDER_MAINTENANCE',
|
||||||
|
] as const;
|
||||||
|
export type AssetStatus = (typeof ASSET_STATUSES)[number];
|
||||||
|
|
||||||
|
/** Libellés métier (charte §7 : vocabulaire du carnet). */
|
||||||
|
export const ASSET_STATUS_LABELS: Record<AssetStatus, string> = {
|
||||||
|
IN_SERVICE: 'En service',
|
||||||
|
OUT_OF_SERVICE: 'À l’arrêt',
|
||||||
|
UNDER_MAINTENANCE: 'En maintenance',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CATEGORY_KINDS = ['EQUIPMENT', 'COMPONENT_TYPE'] as const;
|
||||||
|
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
|
||||||
|
|
||||||
|
export const CATEGORY_KIND_LABELS: Record<CategoryKind, string> = {
|
||||||
|
EQUIPMENT: 'Catégorie d’équipement',
|
||||||
|
COMPONENT_TYPE: 'Type d’organe',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hiérarchie d'emplacements : site → zone, jamais plus profond (invariant R1). */
|
||||||
|
export const LOCATION_MAX_DEPTH = 2;
|
||||||
|
|
||||||
|
/** Durée de validité d'un lien d'activation (invitation). */
|
||||||
|
export const INVITATION_TTL_DAYS = 7;
|
||||||
158
packages/shared/src/schemas/referentiel.ts
Normal file
158
packages/shared/src/schemas/referentiel.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { ASSET_STATUSES, CATEGORY_KINDS } from '../referentiel';
|
||||||
|
|
||||||
|
// ————— Catégories (référentiels administrables) —————
|
||||||
|
|
||||||
|
export const CategorySchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
kind: z.enum(CATEGORY_KINDS),
|
||||||
|
name: z.string(),
|
||||||
|
isActive: z.boolean(),
|
||||||
|
usageCount: z.number().int(), // appareils ou organes qui l'utilisent
|
||||||
|
});
|
||||||
|
export type Category = z.infer<typeof CategorySchema>;
|
||||||
|
|
||||||
|
export const CategoriesResponseSchema = z.object({
|
||||||
|
categories: z.array(CategorySchema),
|
||||||
|
});
|
||||||
|
export type CategoriesResponse = z.infer<typeof CategoriesResponseSchema>;
|
||||||
|
|
||||||
|
export const CategoryCreateSchema = z.object({
|
||||||
|
kind: z.enum(CATEGORY_KINDS),
|
||||||
|
name: z.string().min(1).max(80),
|
||||||
|
});
|
||||||
|
export type CategoryCreate = z.infer<typeof CategoryCreateSchema>;
|
||||||
|
|
||||||
|
export const CategoryUpdateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(80).optional(),
|
||||||
|
isActive: z.boolean().optional(), // désactivation — jamais de suppression si utilisé
|
||||||
|
});
|
||||||
|
export type CategoryUpdate = z.infer<typeof CategoryUpdateSchema>;
|
||||||
|
|
||||||
|
// ————— Emplacements (site → zone) —————
|
||||||
|
|
||||||
|
export const LocationSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
name: z.string(),
|
||||||
|
parentId: z.uuid().nullable(), // null = site
|
||||||
|
address: z.string().nullable(),
|
||||||
|
city: z.string().nullable(),
|
||||||
|
guardianName: z.string().nullable(),
|
||||||
|
guardianPhone: z.string().nullable(),
|
||||||
|
latitude: z.number().nullable(),
|
||||||
|
longitude: z.number().nullable(),
|
||||||
|
assetCount: z.number().int(), // appareils rattachés (zones incluses pour un site)
|
||||||
|
});
|
||||||
|
export type LocationDto = z.infer<typeof LocationSchema>;
|
||||||
|
|
||||||
|
export const LocationsResponseSchema = z.object({
|
||||||
|
locations: z.array(LocationSchema), // liste plate — le web construit l'arbre
|
||||||
|
});
|
||||||
|
export type LocationsResponse = z.infer<typeof LocationsResponseSchema>;
|
||||||
|
|
||||||
|
export const LocationCreateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(120),
|
||||||
|
parentId: z.uuid().optional(),
|
||||||
|
address: z.string().max(200).optional(),
|
||||||
|
city: z.string().max(80).optional(),
|
||||||
|
guardianName: z.string().max(120).optional(),
|
||||||
|
guardianPhone: z.string().max(40).optional(),
|
||||||
|
latitude: z.number().min(-90).max(90).optional(),
|
||||||
|
longitude: z.number().min(-180).max(180).optional(),
|
||||||
|
});
|
||||||
|
export type LocationCreate = z.infer<typeof LocationCreateSchema>;
|
||||||
|
|
||||||
|
export const LocationUpdateSchema = LocationCreateSchema.partial();
|
||||||
|
export type LocationUpdate = z.infer<typeof LocationUpdateSchema>;
|
||||||
|
|
||||||
|
// ————— Appareils & organes —————
|
||||||
|
|
||||||
|
export const AssetComponentSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
typeId: z.uuid(),
|
||||||
|
typeName: z.string(),
|
||||||
|
designation: z.string().nullable(),
|
||||||
|
});
|
||||||
|
export type AssetComponentDto = z.infer<typeof AssetComponentSchema>;
|
||||||
|
|
||||||
|
export const AssetSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
reference: z.string(),
|
||||||
|
brand: z.string(),
|
||||||
|
model: z.string().nullable(),
|
||||||
|
serialNumber: z.string().nullable(),
|
||||||
|
commissionedAt: z.iso.datetime().nullable(),
|
||||||
|
loadKg: z.number().int().nullable(),
|
||||||
|
floors: z.number().int().nullable(),
|
||||||
|
status: z.enum(ASSET_STATUSES),
|
||||||
|
categoryId: z.uuid(),
|
||||||
|
categoryName: z.string(),
|
||||||
|
locationId: z.uuid(),
|
||||||
|
locationName: z.string(),
|
||||||
|
siteName: z.string(), // le site racine (= locationName si rattaché au site)
|
||||||
|
componentCount: z.number().int(),
|
||||||
|
});
|
||||||
|
export type AssetDto = z.infer<typeof AssetSchema>;
|
||||||
|
|
||||||
|
export const AssetsResponseSchema = z.object({ assets: z.array(AssetSchema) });
|
||||||
|
export type AssetsResponse = z.infer<typeof AssetsResponseSchema>;
|
||||||
|
|
||||||
|
export const AssetDetailSchema = AssetSchema.extend({
|
||||||
|
components: z.array(AssetComponentSchema),
|
||||||
|
});
|
||||||
|
export type AssetDetail = z.infer<typeof AssetDetailSchema>;
|
||||||
|
|
||||||
|
export const AssetComponentCreateSchema = z.object({
|
||||||
|
typeId: z.uuid(), // Category(kind=COMPONENT_TYPE) — vérifié service
|
||||||
|
designation: z.string().max(120).optional(),
|
||||||
|
});
|
||||||
|
export type AssetComponentCreate = z.infer<typeof AssetComponentCreateSchema>;
|
||||||
|
|
||||||
|
export const AssetCreateSchema = z.object({
|
||||||
|
reference: z.string().min(1).max(30),
|
||||||
|
brand: z.string().min(1).max(80),
|
||||||
|
model: z.string().max(80).optional(),
|
||||||
|
serialNumber: z.string().max(80).optional(),
|
||||||
|
commissionedAt: z.iso.datetime().optional(),
|
||||||
|
loadKg: z.number().int().positive().optional(),
|
||||||
|
floors: z.number().int().positive().optional(),
|
||||||
|
categoryId: z.uuid(), // Category(kind=EQUIPMENT) — vérifié service
|
||||||
|
locationId: z.uuid(),
|
||||||
|
components: z.array(AssetComponentCreateSchema).optional(),
|
||||||
|
});
|
||||||
|
export type AssetCreate = z.infer<typeof AssetCreateSchema>;
|
||||||
|
|
||||||
|
export const AssetUpdateSchema = AssetCreateSchema.omit({ components: true })
|
||||||
|
.partial()
|
||||||
|
.extend({ status: z.enum(ASSET_STATUSES).optional() });
|
||||||
|
export type AssetUpdate = z.infer<typeof AssetUpdateSchema>;
|
||||||
|
|
||||||
|
// ————— Équipes —————
|
||||||
|
|
||||||
|
export const TeamMemberSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
displayName: z.string(),
|
||||||
|
roleName: z.string(),
|
||||||
|
initials: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TeamSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
members: z.array(TeamMemberSchema),
|
||||||
|
});
|
||||||
|
export type Team = z.infer<typeof TeamSchema>;
|
||||||
|
|
||||||
|
export const TeamsResponseSchema = z.object({ teams: z.array(TeamSchema) });
|
||||||
|
export type TeamsResponse = z.infer<typeof TeamsResponseSchema>;
|
||||||
|
|
||||||
|
export const TeamCreateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(80),
|
||||||
|
description: z.string().max(200).optional(),
|
||||||
|
memberIds: z.array(z.uuid()).optional(),
|
||||||
|
});
|
||||||
|
export type TeamCreate = z.infer<typeof TeamCreateSchema>;
|
||||||
|
|
||||||
|
export const TeamUpdateSchema = TeamCreateSchema.partial();
|
||||||
|
export type TeamUpdate = z.infer<typeof TeamUpdateSchema>;
|
||||||
65
packages/shared/src/schemas/users-admin.ts
Normal file
65
packages/shared/src/schemas/users-admin.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { ROLE_NAMES } from '../permissions';
|
||||||
|
import { AuthResponseSchema } from './auth';
|
||||||
|
|
||||||
|
/** Statut dérivé : invité = pas de mot de passe + lien émis ; désactivé prime. */
|
||||||
|
export const USER_STATUSES = ['active', 'invited', 'disabled'] as const;
|
||||||
|
|
||||||
|
export const UserAdminSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
email: z.email(),
|
||||||
|
displayName: z.string(),
|
||||||
|
phone: z.string().nullable(),
|
||||||
|
role: z.object({ id: z.uuid(), name: z.enum(ROLE_NAMES) }),
|
||||||
|
teams: z.array(z.object({ id: z.uuid(), name: z.string() })),
|
||||||
|
status: z.enum(USER_STATUSES),
|
||||||
|
isDemo: z.boolean(),
|
||||||
|
});
|
||||||
|
export type UserAdmin = z.infer<typeof UserAdminSchema>;
|
||||||
|
|
||||||
|
export const UsersResponseSchema = z.object({ users: z.array(UserAdminSchema) });
|
||||||
|
export type UsersResponse = z.infer<typeof UsersResponseSchema>;
|
||||||
|
|
||||||
|
export const RolesResponseSchema = z.object({
|
||||||
|
roles: z.array(z.object({ id: z.uuid(), name: z.enum(ROLE_NAMES) })),
|
||||||
|
});
|
||||||
|
export type RolesResponse = z.infer<typeof RolesResponseSchema>;
|
||||||
|
|
||||||
|
export const UserUpdateSchema = z.object({
|
||||||
|
displayName: z.string().min(1).max(120).optional(),
|
||||||
|
phone: z.string().max(40).optional(),
|
||||||
|
roleId: z.uuid().optional(),
|
||||||
|
teamIds: z.array(z.uuid()).optional(), // remplace l'affectation
|
||||||
|
isActive: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
export type UserUpdate = z.infer<typeof UserUpdateSchema>;
|
||||||
|
|
||||||
|
// ————— Invitation (maquettes R1 : jamais de mot de passe créé pour autrui) —————
|
||||||
|
|
||||||
|
export const InvitationCreateSchema = z.object({
|
||||||
|
email: z.email(),
|
||||||
|
displayName: z.string().min(1).max(120),
|
||||||
|
roleId: z.uuid(),
|
||||||
|
teamIds: z.array(z.uuid()).optional(),
|
||||||
|
phone: z.string().max(40).optional(),
|
||||||
|
});
|
||||||
|
export type InvitationCreate = z.infer<typeof InvitationCreateSchema>;
|
||||||
|
|
||||||
|
/** Le lien est construit par le web (`/activation?token=…`) — l'API ne
|
||||||
|
* connaît pas son origine publique. L'envoi d'email viendra plus tard ;
|
||||||
|
* en R1 l'admin copie le lien depuis l'interface. */
|
||||||
|
export const InvitationResponseSchema = z.object({
|
||||||
|
userId: z.uuid(),
|
||||||
|
activationToken: z.string(),
|
||||||
|
expiresAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
export type InvitationResponse = z.infer<typeof InvitationResponseSchema>;
|
||||||
|
|
||||||
|
export const ActivateRequestSchema = z.object({
|
||||||
|
token: z.string().min(10),
|
||||||
|
password: z.string().min(8, '8 caractères minimum'),
|
||||||
|
});
|
||||||
|
export type ActivateRequest = z.infer<typeof ActivateRequestSchema>;
|
||||||
|
|
||||||
|
/** L'activation connecte directement la personne (AuthResponse). */
|
||||||
|
export const ActivateResponseSchema = AuthResponseSchema;
|
||||||
103
pnpm-lock.yaml
generated
103
pnpm-lock.yaml
generated
@@ -108,6 +108,9 @@ importers:
|
|||||||
'@fontsource/manrope':
|
'@fontsource/manrope':
|
||||||
specifier: ^5.1.0
|
specifier: ^5.1.0
|
||||||
version: 5.2.8
|
version: 5.2.8
|
||||||
|
'@radix-ui/react-dialog':
|
||||||
|
specifier: ^1.1.19
|
||||||
|
version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
'@radix-ui/react-dropdown-menu':
|
'@radix-ui/react-dropdown-menu':
|
||||||
specifier: ^2.1.4
|
specifier: ^2.1.4
|
||||||
version: 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
@@ -123,18 +126,27 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
leaflet:
|
||||||
|
specifier: ^1.9.4
|
||||||
|
version: 1.9.4
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.469.0
|
specifier: ^0.469.0
|
||||||
version: 0.469.0(react@19.2.7)
|
version: 0.469.0(react@19.2.7)
|
||||||
openapi-fetch:
|
openapi-fetch:
|
||||||
specifier: ^0.13.4
|
specifier: ^0.13.4
|
||||||
version: 0.13.8
|
version: 0.13.8
|
||||||
|
qrcode.react:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0(react@19.2.7)
|
||||||
react:
|
react:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.7(react@19.2.7)
|
version: 19.2.7(react@19.2.7)
|
||||||
|
react-leaflet:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^7.1.0
|
specifier: ^7.1.0
|
||||||
version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
@@ -148,6 +160,9 @@ importers:
|
|||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.3.2(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)(tsx@4.23.1))
|
version: 4.3.2(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)(tsx@4.23.1))
|
||||||
|
'@types/leaflet':
|
||||||
|
specifier: ^1.9.21
|
||||||
|
version: 1.9.21
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
@@ -1242,6 +1257,19 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-dialog@1.1.19':
|
||||||
|
resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@radix-ui/react-direction@1.1.2':
|
'@radix-ui/react-direction@1.1.2':
|
||||||
resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==}
|
resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1461,6 +1489,13 @@ packages:
|
|||||||
'@radix-ui/rect@1.1.2':
|
'@radix-ui/rect@1.1.2':
|
||||||
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
|
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
|
||||||
|
|
||||||
|
'@react-leaflet/core@3.0.0':
|
||||||
|
resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
|
||||||
|
peerDependencies:
|
||||||
|
leaflet: ^1.9.0
|
||||||
|
react: ^19.0.0
|
||||||
|
react-dom: ^19.0.0
|
||||||
|
|
||||||
'@redocly/ajv@8.11.2':
|
'@redocly/ajv@8.11.2':
|
||||||
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
|
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
|
||||||
|
|
||||||
@@ -1808,6 +1843,9 @@ packages:
|
|||||||
'@types/express@5.0.6':
|
'@types/express@5.0.6':
|
||||||
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16':
|
||||||
|
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
|
||||||
|
|
||||||
'@types/graceful-fs@4.1.9':
|
'@types/graceful-fs@4.1.9':
|
||||||
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
|
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
|
||||||
|
|
||||||
@@ -1832,6 +1870,9 @@ packages:
|
|||||||
'@types/jsonwebtoken@9.0.10':
|
'@types/jsonwebtoken@9.0.10':
|
||||||
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
|
||||||
|
|
||||||
|
'@types/leaflet@1.9.21':
|
||||||
|
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
|
||||||
|
|
||||||
'@types/methods@1.1.4':
|
'@types/methods@1.1.4':
|
||||||
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
|
||||||
|
|
||||||
@@ -3315,6 +3356,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
leaflet@1.9.4:
|
||||||
|
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||||
|
|
||||||
leven@3.1.0:
|
leven@3.1.0:
|
||||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3819,6 +3863,11 @@ packages:
|
|||||||
pure-rand@6.1.0:
|
pure-rand@6.1.0:
|
||||||
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
|
||||||
|
|
||||||
|
qrcode.react@4.2.0:
|
||||||
|
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
qs@6.15.3:
|
qs@6.15.3:
|
||||||
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
|
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@@ -3846,6 +3895,13 @@ packages:
|
|||||||
react-is@18.3.1:
|
react-is@18.3.1:
|
||||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
||||||
|
|
||||||
|
react-leaflet@5.0.0:
|
||||||
|
resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
|
||||||
|
peerDependencies:
|
||||||
|
leaflet: ^1.9.0
|
||||||
|
react: ^19.0.0
|
||||||
|
react-dom: ^19.0.0
|
||||||
|
|
||||||
react-refresh@0.17.0:
|
react-refresh@0.17.0:
|
||||||
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -5723,6 +5779,28 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@types/react': 19.2.17
|
||||||
|
|
||||||
|
'@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.5
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||||
|
'@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
|
||||||
|
'@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
|
||||||
|
'@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
|
||||||
|
'@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
|
||||||
|
'@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||||
|
aria-hidden: 1.2.6
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.17
|
||||||
|
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||||
|
|
||||||
'@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)':
|
'@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
@@ -5928,6 +6006,12 @@ snapshots:
|
|||||||
|
|
||||||
'@radix-ui/rect@1.1.2': {}
|
'@radix-ui/rect@1.1.2': {}
|
||||||
|
|
||||||
|
'@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||||
|
dependencies:
|
||||||
|
leaflet: 1.9.4
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
'@redocly/ajv@8.11.2':
|
'@redocly/ajv@8.11.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
@@ -6208,6 +6292,8 @@ snapshots:
|
|||||||
'@types/express-serve-static-core': 5.1.2
|
'@types/express-serve-static-core': 5.1.2
|
||||||
'@types/serve-static': 2.2.0
|
'@types/serve-static': 2.2.0
|
||||||
|
|
||||||
|
'@types/geojson@7946.0.16': {}
|
||||||
|
|
||||||
'@types/graceful-fs@4.1.9':
|
'@types/graceful-fs@4.1.9':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
@@ -6236,6 +6322,10 @@ snapshots:
|
|||||||
'@types/ms': 2.1.0
|
'@types/ms': 2.1.0
|
||||||
'@types/node': 24.13.3
|
'@types/node': 24.13.3
|
||||||
|
|
||||||
|
'@types/leaflet@1.9.21':
|
||||||
|
dependencies:
|
||||||
|
'@types/geojson': 7946.0.16
|
||||||
|
|
||||||
'@types/methods@1.1.4': {}
|
'@types/methods@1.1.4': {}
|
||||||
|
|
||||||
'@types/ms@2.1.0': {}
|
'@types/ms@2.1.0': {}
|
||||||
@@ -8087,6 +8177,8 @@ snapshots:
|
|||||||
|
|
||||||
kleur@3.0.3: {}
|
kleur@3.0.3: {}
|
||||||
|
|
||||||
|
leaflet@1.9.4: {}
|
||||||
|
|
||||||
leven@3.1.0: {}
|
leven@3.1.0: {}
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
@@ -8516,6 +8608,10 @@ snapshots:
|
|||||||
|
|
||||||
pure-rand@6.1.0: {}
|
pure-rand@6.1.0: {}
|
||||||
|
|
||||||
|
qrcode.react@4.2.0(react@19.2.7):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.7
|
||||||
|
|
||||||
qs@6.15.3:
|
qs@6.15.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-define-property: 1.0.1
|
es-define-property: 1.0.1
|
||||||
@@ -8549,6 +8645,13 @@ snapshots:
|
|||||||
|
|
||||||
react-is@18.3.1: {}
|
react-is@18.3.1: {}
|
||||||
|
|
||||||
|
react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||||
|
dependencies:
|
||||||
|
'@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
|
leaflet: 1.9.4
|
||||||
|
react: 19.2.7
|
||||||
|
react-dom: 19.2.7(react@19.2.7)
|
||||||
|
|
||||||
react-refresh@0.17.0: {}
|
react-refresh@0.17.0: {}
|
||||||
|
|
||||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
|
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
|
||||||
|
|||||||
Reference in New Issue
Block a user