From 460ef4a80e1f3219fda54acb150d3007d5e7aa8c Mon Sep 17 00:00:00 2001 From: pr-daaif Date: Fri, 17 Jul 2026 02:14:43 +0100 Subject: [PATCH] =?UTF-8?q?feat(r3.4):=20corrections=20de=20recette=20(8?= =?UTF-8?q?=20=C3=A9carts)=20+=20recherche=20globale=20=E2=8C=98K?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arbitrage du référent sur la revue pixel : tout corriger, activer la recherche. - Stock : filtre fournisseur, sous-seuil en tête, « Entrée de stock » depuis la liste ; fiche pièce : fournisseur → lien Tiers. - Statistiques : période 3/6/12 mois (paramètre months au contrat). - Tiers : rattachements syndic→site (migration r3_recette_fixes, Location.partnerId gardé CLIENT), éditable sur la fiche site, seedé. - Bibliothèque : filtre « Rattaché à » + glisser-déposer (modale préremplie, rattachement toujours requis). - Recherche globale : GET /search (73 opérations) — familles OT/ ascenseurs/sites filtrées par la matrice, « voir autre » respecté ; topbar ⌘K, debounce, résultats groupés, navigation clavier. 74 tests API (8 nouveaux sur le scoping de la recherche), 14/14 Playwright dont un parcours « recette corrigée », 18/18 contrôles en navigateur réel, zéro erreur console. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 3 +- .../migration.sql | 8 + apps/api/prisma/schema.prisma | 5 + apps/api/prisma/seed.ts | 11 + .../api/src/analytics/analytics.controller.ts | 6 +- apps/api/src/analytics/analytics.service.ts | 34 ++- apps/api/src/app.module.ts | 2 + apps/api/src/locations/locations.service.ts | 27 +- apps/api/src/partners/partners.service.ts | 7 + apps/api/src/search/search.controller.ts | 15 + apps/api/src/search/search.module.ts | 9 + apps/api/src/search/search.service.ts | 105 +++++++ apps/api/test/documents-analytics.e2e-spec.ts | 4 +- apps/api/test/recherche.e2e-spec.ts | 141 ++++++++++ apps/web/e2e/parcours-r3.spec.ts | 52 +++- apps/web/src/api/gestion.ts | 11 +- apps/web/src/api/recherche.ts | 22 ++ apps/web/src/api/referentiel.ts | 10 + apps/web/src/api/schema.d.ts | 78 ++++- apps/web/src/components/recherche-globale.tsx | 179 ++++++++++++ apps/web/src/layout/coquille.tsx | 7 +- apps/web/src/lib/lieux.test.ts | 2 + apps/web/src/pages/bibliotheque.tsx | 66 ++++- apps/web/src/pages/fiche-piece.tsx | 10 +- apps/web/src/pages/fiche-site.tsx | 45 ++- apps/web/src/pages/statistiques.tsx | 29 +- apps/web/src/pages/stock.tsx | 95 ++++++- apps/web/src/pages/tableau-de-bord.tsx | 2 +- apps/web/src/pages/tiers.tsx | 4 +- apps/web/src/styles/app.css | 40 +++ docs/journal/journal.md | 19 ++ docs/openapi.json | 266 +++++++++++++++++- packages/shared/src/contract.ts | 15 +- packages/shared/src/index.ts | 1 + packages/shared/src/schemas/documents.ts | 12 +- packages/shared/src/schemas/gestion.ts | 1 + packages/shared/src/schemas/referentiel.ts | 3 + packages/shared/src/schemas/search.ts | 37 +++ 38 files changed, 1318 insertions(+), 65 deletions(-) create mode 100644 apps/api/prisma/migrations/20260717005354_r3_recette_fixes/migration.sql create mode 100644 apps/api/src/search/search.controller.ts create mode 100644 apps/api/src/search/search.module.ts create mode 100644 apps/api/src/search/search.service.ts create mode 100644 apps/api/test/recherche.e2e-spec.ts create mode 100644 apps/web/src/api/recherche.ts create mode 100644 apps/web/src/components/recherche-globale.tsx create mode 100644 packages/shared/src/schemas/search.ts diff --git a/CLAUDE.md b/CLAUDE.md index 02a6f48..565f999 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,5 +52,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **R3 — maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC → RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1). - ✅ **R3.2 — bibliothèque + analytics** : `FileStorage` complet (put/stream/remove, bucket auto), upload multipart typé (20 Mo, rattachement requis, permission sur la cible), download **streamé par l'API**, octets vérifiés à l'identique en e2e ; `GET /analytics/summary` dérivé du réel (pannes par organe via bilans codés, coûts figés, taux préventif, top équipements) ; MinIO en CI ; 71 opérations, 58 tests (6 runs consécutifs verts). - ✅ **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright). -- 🔄 **Reprise ici** : recette R3 avec le référent (revue pixel écrans ↔ maquette-r3) → vérification sur le déploiement Dokploy → tag `release/r3` → ouverture R4 Mobile (Expo, design d'abord). +- ✅ **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) → 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndic→site via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright. +- 🔄 **Reprise ici** : validation finale du référent (artefact revue pixel) → vérification sur le déploiement Dokploy → tag `release/r3` → ouverture R4 Mobile (Expo, design d'abord). - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5. diff --git a/apps/api/prisma/migrations/20260717005354_r3_recette_fixes/migration.sql b/apps/api/prisma/migrations/20260717005354_r3_recette_fixes/migration.sql new file mode 100644 index 0000000..50fd321 --- /dev/null +++ b/apps/api/prisma/migrations/20260717005354_r3_recette_fixes/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "Location" ADD COLUMN "partnerId" UUID; + +-- CreateIndex +CREATE INDEX "Location_partnerId_idx" ON "Location"("partnerId"); + +-- AddForeignKey +ALTER TABLE "Location" ADD CONSTRAINT "Location_partnerId_fkey" FOREIGN KEY ("partnerId") REFERENCES "Partner"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2637b5f..b4e8885 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -107,11 +107,15 @@ model Location { longitude Float? // + colonne PostGIS générée (voir migration r1_referentiel) : // position geography(Point,4326) GENERATED ALWAYS AS (…) STORED + // Client/syndic gérant le site (R3 — colonne « Rattachements » des Tiers) + partnerId String? @db.Uuid + partner Partner? @relation(fields: [partnerId], references: [id]) assets Asset[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([parentId]) + @@index([partnerId]) } model Asset { @@ -408,6 +412,7 @@ model Partner { isActive Boolean @default(true) parts Part[] purchaseOrders PurchaseOrder[] + sites Location[] // sites gérés (kind CLIENT) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 45d93f8..38a60cc 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -616,6 +616,17 @@ async function seedGestion(prisma: PrismaClient): Promise { partnerIds.set(p.name, partner.id); } + // Rattachements de la maquette Tiers : les syndics gèrent leur site. + for (const [site, partner] of [ + ['Tour Atlas', 'Atlas Property Management'], + ['Résidence Al Manar', 'Syndic Al Manar'], + ] as const) { + await prisma.location.updateMany({ + where: { name: site, parentId: null, partnerId: null }, + data: { partnerId: partnerIds.get(partner) }, + }); + } + const partIds = new Map(); for (const p of PARTS) { const part = await prisma.part.upsert({ diff --git a/apps/api/src/analytics/analytics.controller.ts b/apps/api/src/analytics/analytics.controller.ts index f9b3339..da95388 100644 --- a/apps/api/src/analytics/analytics.controller.ts +++ b/apps/api/src/analytics/analytics.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { RequirePermission } from '../permissions/require-permission.decorator'; import { AnalyticsService } from './analytics.service'; @@ -8,7 +8,7 @@ export class AnalyticsController { @Get('summary') @RequirePermission('ANALYTICS', 'view') - summary() { - return this.analytics.summary(); + summary(@Query('months') months?: string) { + return this.analytics.summary(months); } } diff --git a/apps/api/src/analytics/analytics.service.ts b/apps/api/src/analytics/analytics.service.ts index cf9faab..601b1ae 100644 --- a/apps/api/src/analytics/analytics.service.ts +++ b/apps/api/src/analytics/analytics.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import type { AnalyticsSummary } from '@siop/shared'; +import { ANALYTICS_PERIODS, type AnalyticsPeriod, type AnalyticsSummary } from '@siop/shared'; import { PrismaService } from '../prisma/prisma.service'; const debutMois = (decalage: number): Date => { @@ -13,43 +13,46 @@ const debutMois = (decalage: number): Date => { export class AnalyticsService { constructor(private readonly prisma: PrismaService) {} - async summary(): Promise { - const depuis12m = debutMois(11); - const depuis6m = debutMois(5); + async summary(monthsRaw?: string): Promise { + // Période demandée par l'écran (3, 6 ou 12 mois) — 12 par défaut. + const months: AnalyticsPeriod = ANALYTICS_PERIODS.find( + (p) => p === Number(monthsRaw), + ) ?? 12; + const depuisPeriode = debutMois(months - 1); const [consommations, mainOeuvre, clos12m, grilles12m, correctifsClos, pannes, topDonnees] = await Promise.all([ this.prisma.stockMovement.findMany({ - where: { kind: 'CONSUMPTION', createdAt: { gte: depuis6m } }, + where: { kind: 'CONSUMPTION', createdAt: { gte: depuisPeriode } }, select: { quantity: true, unitPrice: true, createdAt: true }, }), this.prisma.laborTime.findMany({ - where: { createdAt: { gte: depuis6m } }, + where: { createdAt: { gte: depuisPeriode } }, select: { minutes: true, hourlyRate: true, createdAt: true }, }), this.prisma.workOrder.groupBy({ by: ['type'], - where: { status: 'DONE', completedAt: { gte: depuis12m } }, + where: { status: 'DONE', completedAt: { gte: depuisPeriode } }, _count: { _all: true }, }), this.prisma.workOrder.findMany({ - where: { periodKey: { not: null }, createdAt: { gte: depuis12m } }, + where: { periodKey: { not: null }, createdAt: { gte: depuisPeriode } }, select: { status: true }, }), this.prisma.workOrder.findMany({ - where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuis12m } }, + where: { type: 'CORRECTIVE', status: 'DONE', completedAt: { gte: depuisPeriode } }, select: { createdAt: true, completedAt: true }, }), this.prisma.interventionReport.groupBy({ by: ['componentConcernedId'], where: { componentConcernedId: { not: null }, - workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuis12m } }, + workOrder: { status: 'DONE', type: 'CORRECTIVE', completedAt: { gte: depuisPeriode } }, }, _count: { _all: true }, }), this.prisma.stockMovement.findMany({ - where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuis12m } }, + where: { kind: 'CONSUMPTION', workOrderId: { not: null }, createdAt: { gte: depuisPeriode } }, select: { quantity: true, unitPrice: true, @@ -71,7 +74,7 @@ export class AnalyticsService { // Coûts par mois (6 derniers) — pièces + main-d'œuvre, prix/taux figés const cleMois = (d: Date) => d.toISOString().slice(0, 7); const parMois = new Map(); - for (let i = 5; i >= 0; i--) parMois.set(cleMois(debutMois(i)), 0); + for (let i = months - 1; i >= 0; i--) parMois.set(cleMois(debutMois(i)), 0); for (const c of consommations) { const cle = cleMois(c.createdAt); if (parMois.has(cle)) { @@ -106,7 +109,7 @@ export class AnalyticsService { // Top équipements en coût (pièces via OT + main-d'œuvre) const laborParOT = await this.prisma.laborTime.findMany({ - where: { createdAt: { gte: depuis12m } }, + where: { createdAt: { gte: depuisPeriode } }, select: { minutes: true, hourlyRate: true, @@ -143,7 +146,7 @@ export class AnalyticsService { } const correctivesParAppareil = await this.prisma.workOrder.groupBy({ by: ['assetId'], - where: { type: 'CORRECTIVE', createdAt: { gte: depuis12m } }, + where: { type: 'CORRECTIVE', createdAt: { gte: depuisPeriode } }, _count: { _all: true }, }); const assetsRefs = await this.prisma.asset.findMany({ @@ -172,9 +175,10 @@ export class AnalyticsService { const grillesTerminees = grilles12m.filter((g) => g.status === 'DONE').length; return { + months, monthCost: costsByMonth.find((c) => c.month === moisCourant)?.total ?? 0, previousMonthCost: costsByMonth.find((c) => c.month === moisPrecedent)?.total ?? 0, - closed12m: { total: totalClos, preventive: preventifsClos }, + closed: { total: totalClos, preventive: preventifsClos }, preventiveRate: grilles12m.length ? Math.round((grillesTerminees / grilles12m.length) * 100) / 100 : null, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index a202d98..75d2109 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,6 +1,7 @@ import { DynamicModule, Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { AnalyticsModule } from './analytics/analytics.module'; +import { SearchModule } from './search/search.module'; import { AssetsModule } from './assets/assets.module'; import { DocumentsModule } from './documents/documents.module'; import { AuthModule } from './auth/auth.module'; @@ -61,6 +62,7 @@ export class AppModule { PurchaseOrdersModule, DocumentsModule, AnalyticsModule, + SearchModule, // ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404 ...(demoModeEnabled() ? [DemoAuthModule] : []), ], diff --git a/apps/api/src/locations/locations.service.ts b/apps/api/src/locations/locations.service.ts index 29862e4..e405dd6 100644 --- a/apps/api/src/locations/locations.service.ts +++ b/apps/api/src/locations/locations.service.ts @@ -21,9 +21,16 @@ type LocationRow = { guardianPhone: string | null; latitude: number | null; longitude: number | null; + partnerId: string | null; + partner: { name: string } | null; _count: { assets: number }; }; +const locationInclude = { + partner: { select: { name: true } }, + _count: { select: { assets: true } }, +} as const; + @Injectable() export class LocationsService { constructor(private readonly prisma: PrismaService) {} @@ -31,7 +38,7 @@ export class LocationsService { /** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */ async list(): Promise { const rows: LocationRow[] = await this.prisma.location.findMany({ - include: { _count: { select: { assets: true } } }, + include: locationInclude, orderBy: { name: 'asc' }, }); const childAssets = new Map(); @@ -52,9 +59,10 @@ export class LocationsService { async create(dto: LocationCreate): Promise { await this.assertDepth(dto.parentId); + await this.assertClientPartner(dto.partnerId); const created = await this.prisma.location.create({ data: dto, - include: { _count: { select: { assets: true } } }, + include: locationInclude, }); return this.toDto(created, 0); } @@ -76,10 +84,11 @@ export class LocationsService { } await this.assertDepth(dto.parentId); } + await this.assertClientPartner(dto.partnerId); const updated = await this.prisma.location.update({ where: { id }, data: dto, - include: { _count: { select: { assets: true } } }, + include: locationInclude, }); return this.toDto(updated, updated._count.assets); } @@ -96,6 +105,16 @@ export class LocationsService { } } + /** R3 : seul un tiers Client/syndic peut gérer un site. */ + private async assertClientPartner(partnerId?: string | null): Promise { + if (!partnerId) return; + const partner = await this.prisma.partner.findUnique({ where: { id: partnerId } }); + if (!partner) throw new BadRequestException('Tiers inconnu'); + if (partner.kind !== 'CLIENT') { + throw new BadRequestException('Seul un tiers « Client / syndic » peut être rattaché à un site'); + } + } + private toDto(row: Omit, assetCount: number): LocationDto { return { id: row.id, @@ -107,6 +126,8 @@ export class LocationsService { guardianPhone: row.guardianPhone, latitude: row.latitude, longitude: row.longitude, + partnerId: row.partnerId, + partnerName: row.partner?.name ?? null, assetCount, }; } diff --git a/apps/api/src/partners/partners.service.ts b/apps/api/src/partners/partners.service.ts index e8b2767..b1d0991 100644 --- a/apps/api/src/partners/partners.service.ts +++ b/apps/api/src/partners/partners.service.ts @@ -18,6 +18,12 @@ const partnerInclude = { purchaseOrders: { where: { status: { in: ['DRAFT', 'SENT'] } } }, }, }, + // Rattachements de la maquette : sites (jamais les zones) gérés par un client/syndic. + sites: { + where: { parentId: null }, + select: { name: true }, + orderBy: { name: 'asc' as const }, + }, } satisfies Prisma.PartnerInclude; type Row = Prisma.PartnerGetPayload<{ include: typeof partnerInclude }>; @@ -79,6 +85,7 @@ export class PartnersService { city: row.city, isActive: row.isActive, openOrders: row._count.purchaseOrders, + siteNames: row.sites.map((s) => s.name), }; } } diff --git a/apps/api/src/search/search.controller.ts b/apps/api/src/search/search.controller.ts new file mode 100644 index 0000000..48918e1 --- /dev/null +++ b/apps/api/src/search/search.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator'; +import { SearchService } from './search.service'; + +/** Authentification seule (pas de @RequirePermission) : chaque famille de + * résultats est filtrée par la matrice DANS le service. */ +@Controller('search') +export class SearchController { + constructor(private readonly search: SearchService) {} + + @Get() + global(@CurrentUser() user: AuthenticatedUser, @Query('q') q = '') { + return this.search.search(user, q); + } +} diff --git a/apps/api/src/search/search.module.ts b/apps/api/src/search/search.module.ts new file mode 100644 index 0000000..238d0e8 --- /dev/null +++ b/apps/api/src/search/search.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { SearchController } from './search.controller'; +import { SearchService } from './search.service'; + +@Module({ + controllers: [SearchController], + providers: [SearchService], +}) +export class SearchModule {} diff --git a/apps/api/src/search/search.service.ts b/apps/api/src/search/search.service.ts new file mode 100644 index 0000000..e62a9b4 --- /dev/null +++ b/apps/api/src/search/search.service.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@nestjs/common'; +import type { Prisma } from '@prisma/client'; +import { SEARCH_MAX_PER_KIND, SEARCH_MIN_CHARS, type SearchResponse } from '@siop/shared'; +import type { AuthenticatedUser } from '../auth/current-user.decorator'; +import { PermissionsService } from '../permissions/permissions.service'; +import { PrismaService } from '../prisma/prisma.service'; + +/** Recherche globale de la topbar (⌘K). Chaque famille n'est interrogée que + * si le rôle a la permission `view` du domaine ; les OT respectent en plus + * l'invariant « voir autre » (même règle que la liste des OT). */ +@Injectable() +export class SearchService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + ) {} + + async search(user: AuthenticatedUser, q: string): Promise { + const terme = q.trim(); + const vide: SearchResponse = { workOrders: [], assets: [], sites: [] }; + if (terme.length < SEARCH_MIN_CHARS) return vide; + + const [voitOT, voitParc, voitSites] = await Promise.all([ + this.permissions.can(user.roleId, 'WORK_ORDERS', 'view'), + this.permissions.can(user.roleId, 'ASSETS', 'view'), + this.permissions.can(user.roleId, 'LOCATIONS', 'view'), + ]); + const contient = (champ: string): Prisma.StringFilter => ({ + contains: champ, + mode: 'insensitive', + }); + + const [workOrders, assets, sites] = await Promise.all([ + voitOT + ? this.prisma.workOrder.findMany({ + where: { + AND: [ + { OR: [{ reference: contient(terme) }, { title: contient(terme) }] }, + await this.scopeOT(user), + ], + }, + select: { id: true, reference: true, title: true, status: true }, + orderBy: { createdAt: 'desc' }, + take: SEARCH_MAX_PER_KIND, + }) + : [], + voitParc + ? this.prisma.asset.findMany({ + where: { + OR: [ + { reference: contient(terme) }, + { brand: contient(terme) }, + { model: contient(terme) }, + ], + }, + select: { + id: true, + reference: true, + brand: true, + model: true, + location: { select: { name: true, parent: { select: { name: true } } } }, + }, + orderBy: { reference: 'asc' }, + take: SEARCH_MAX_PER_KIND, + }) + : [], + voitSites + ? this.prisma.location.findMany({ + where: { + parentId: null, + OR: [{ name: contient(terme) }, { city: contient(terme) }], + }, + select: { id: true, name: true, city: true }, + orderBy: { name: 'asc' }, + take: SEARCH_MAX_PER_KIND, + }) + : [], + ]); + + return { + workOrders, + assets: assets.map((a) => ({ + id: a.id, + reference: a.reference, + brand: a.brand, + model: a.model, + siteName: a.location.parent?.name ?? a.location.name, + })), + sites, + }; + } + + /** Même invariant que WorkOrdersService.scope — sans le droit « voir + * autre », on ne trouve que SES OT. */ + private async scopeOT(user: AuthenticatedUser): Promise { + const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther'); + if (viewOther) return {}; + return { + OR: [ + { assignees: { some: { id: user.userId } } }, + { createdById: user.userId }, + ], + }; + } +} diff --git a/apps/api/test/documents-analytics.e2e-spec.ts b/apps/api/test/documents-analytics.e2e-spec.ts index 6e5d097..328abbc 100644 --- a/apps/api/test/documents-analytics.e2e-spec.ts +++ b/apps/api/test/documents-analytics.e2e-spec.ts @@ -125,7 +125,7 @@ describe('Bibliothèque & analytics (e2e)', () => { it('analytics : le tableau de la direction est dérivé du réel (bilans, coûts figés)', async () => { const res = await http().get('/analytics/summary').set(auth(nadia)).expect(200); const s = res.body; - expect(s.costsByMonth).toHaveLength(6); + expect(s.costsByMonth).toHaveLength(12); // période par défaut // Le seed a consommé 325 MAD de pièces + 180 de MO sur OT-0341 ce mois-ci expect(s.monthCost).toBeGreaterThanOrEqual(505); // Pannes par organe : OT-0332 (seed) a un bilan « Guides » @@ -134,7 +134,7 @@ describe('Bibliothèque & analytics (e2e)', () => { ).toBe(true); // Top équipements : A1 porte les coûts du seed expect(s.topAssets.some((t: { reference: string }) => t.reference === 'A1')).toBe(true); - expect(s.closed12m.total).toBeGreaterThanOrEqual(1); + expect(s.closed.total).toBeGreaterThanOrEqual(1); // Karim (Demandeur, sans ANALYTICS) → 403 await http().get('/analytics/summary').set(auth(karim)).expect(403); }); diff --git a/apps/api/test/recherche.e2e-spec.ts b/apps/api/test/recherche.e2e-spec.ts new file mode 100644 index 0000000..3786708 --- /dev/null +++ b/apps/api/test/recherche.e2e-spec.ts @@ -0,0 +1,141 @@ +/** + * E2E R3 (recette) — recherche globale : chaque famille de résultats est + * filtrée par la matrice, les OT respectent « voir autre » ; au passage, + * rattachements Tiers (siteNames) et période analytics paramétrable. + */ +process.env.DEMO_MODE = 'true'; + +import { INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { seed } from '../prisma/seed'; +import { AppModule } from '../src/app.module'; + +describe('Recherche globale & corrections de recette R3 (e2e)', () => { + let app: INestApplication; + let yasmine: string; // Administrateur + let salma: string; // Dispatcher (voir autre) + let youssef: string; // Technicien limité : SES OT, ASSETS view, pas de LOCATIONS + let karim: string; // Demandeur : aucune des trois familles + let otId: string; + const prisma = new PrismaClient(); + const http = () => request(app.getHttpServer()); + const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); + const suffix = `rech-${Date.now().toString(36)}`; + + beforeAll(async () => { + await seed(prisma); + const moduleRef = await Test.createTestingModule({ + imports: [AppModule.forRoot()], + }).compile(); + app = moduleRef.createNestApplication(); + await app.init(); + const { body } = await http().get('/auth/demo-accounts'); + const login = async (roleName: string) => { + const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName); + return (await http().post('/auth/demo-login').send({ userId: compte.id })).body + .accessToken as string; + }; + yasmine = await login('Administrateur'); + salma = await login('Dispatcher'); + youssef = await login('Technicien limité'); + karim = await login('Demandeur'); + + // Un OT au titre unique, assigné à Ahmed (PAS à Youssef). + const { body: assets } = await http().get('/assets').set(auth(salma)); + const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1'); + const ahmed = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien'); + const cree = await http() + .post('/work-orders') + .set(auth(salma)) + .send({ + title: `Recherche E2E ${suffix}`, + type: 'CORRECTIVE', + priority: 'LOW', + assetId: a1.id, + assigneeIds: [ahmed.id], + }) + .expect(201); + otId = cree.body.id; + }); + + afterAll(async () => { + await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } }); + await app?.close(); + await prisma.$disconnect(); + }); + + it('dispatcher (voir autre) : OT par titre, appareil par référence, site insensible à la casse', async () => { + const parTitre = await http().get(`/search?q=${suffix}`).set(auth(salma)).expect(200); + expect(parTitre.body.workOrders.map((w: { id: string }) => w.id)).toContain(otId); + + const parRef = await http().get('/search?q=A1').set(auth(salma)).expect(200); + expect(parRef.body.assets.some((a: { reference: string }) => a.reference === 'A1')).toBe(true); + expect(parRef.body.assets[0].siteName).toBeTruthy(); + + const parSite = await http().get('/search?q=atlas').set(auth(salma)).expect(200); + expect(parSite.body.sites.some((s: { name: string }) => s.name === 'Tour Atlas')).toBe(true); + }); + + it('technicien limité : ne trouve pas l’OT d’un autre, ni les sites ; le parc oui', async () => { + const res = await http().get(`/search?q=${suffix}`).set(auth(youssef)).expect(200); + expect(res.body.workOrders).toHaveLength(0); // pas « voir autre » + + const parc = await http().get('/search?q=A1').set(auth(youssef)).expect(200); + expect(parc.body.assets.length).toBeGreaterThan(0); // ASSETS view + const sites = await http().get('/search?q=atlas').set(auth(youssef)).expect(200); + expect(sites.body.sites).toHaveLength(0); // pas de LOCATIONS view + }); + + it('demandeur : 200 mais aucune famille (aucune permission view)', async () => { + const res = await http().get('/search?q=atlas').set(auth(karim)).expect(200); + expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] }); + }); + + it('moins de 2 caractères : réponse vide, pas de requête inutile', async () => { + const res = await http().get('/search?q=a').set(auth(salma)).expect(200); + expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] }); + }); + + it('sans jeton : 401 (API fermée par défaut)', async () => { + await http().get('/search?q=atlas').expect(401); + }); + + it('tiers : les syndics portent leurs sites (« Rattachements » de la maquette)', async () => { + const { body } = await http().get('/partners').set(auth(yasmine)).expect(200); + const atlas = body.partners.find( + (p: { name: string }) => p.name === 'Atlas Property Management', + ); + expect(atlas.siteNames).toEqual(['Tour Atlas']); + const fournisseur = body.partners.find((p: { name: string }) => p.name === 'Lubmaroc'); + expect(fournisseur.siteNames).toEqual([]); + }); + + it('analytics : période 3/6/12 mois, 12 par défaut, valeur inconnue repliée sur 12', async () => { + const trois = await http().get('/analytics/summary?months=3').set(auth(yasmine)).expect(200); + expect(trois.body.months).toBe(3); + expect(trois.body.costsByMonth).toHaveLength(3); + + const defaut = await http().get('/analytics/summary').set(auth(yasmine)).expect(200); + expect(defaut.body.months).toBe(12); + expect(defaut.body.costsByMonth).toHaveLength(12); + + const inconnu = await http().get('/analytics/summary?months=7').set(auth(yasmine)).expect(200); + expect(inconnu.body.months).toBe(12); + }); + + it('sites : le rattachement n’accepte qu’un tiers Client / syndic', async () => { + const { body: partners } = await http().get('/partners').set(auth(yasmine)); + const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER'); + const { body: locations } = await http().get('/locations').set(auth(yasmine)); + const site = locations.locations.find( + (l: { parentId: string | null; name: string }) => !l.parentId && l.name === 'Anfa Place', + ); + await http() + .patch(`/locations/${site.id}`) + .set(auth(yasmine)) + .send({ partnerId: fournisseur.id }) + .expect(400); + }); +}); diff --git a/apps/web/e2e/parcours-r3.spec.ts b/apps/web/e2e/parcours-r3.spec.ts index 019f272..5bc28ec 100644 --- a/apps/web/e2e/parcours-r3.spec.ts +++ b/apps/web/e2e/parcours-r3.spec.ts @@ -48,7 +48,7 @@ test('recette R3 : tiers → pièce sous seuil → BC prérempli → réception await page.getByLabel('Désignation *').fill(piece); await page.getByLabel("Seuil d'alerte").fill('5'); await page.getByLabel('Prix initial (MAD)').fill('100'); - await page.getByLabel('Fournisseur').selectOption({ label: fournisseur }); + await page.getByLabel('Fournisseur', { exact: true }).selectOption({ label: fournisseur }); await page.getByRole('button', { name: 'Créer la pièce' }).click(); await expect(page.getByRole('heading', { name: piece })).toBeVisible(); @@ -157,3 +157,53 @@ test('recette R3 : coûts réels sur OT (prix + taux figés) + document + statis await expect(page.getByText('Pannes par organe · 12 mois')).toBeVisible(); await expect(page.getByText('Top équipements en coût · 12 mois')).toBeVisible(); }); + +test('recette R3 corrigée : recherche ⌘K, tri/filtre stock, période stats, rattachements, dépôt', async ({ + page, +}) => { + await connexionDemo(page, /Yasmine Alaoui/); + + // Recherche globale : ⌘K → site (insensible à la casse), clic → fiche site + await page.keyboard.press('ControlOrMeta+k'); + await page.keyboard.type('atlas'); + await page.locator('.resultat', { hasText: 'Tour Atlas' }).first().click(); + await expect(page).toHaveURL(/\/sites\//); + // le site porte son syndic (rattachement seedé, éditable par l'admin) + await expect(page.getByLabel('Client / syndic du site')).toHaveValue(/.+/); + + // Recherche OT au clavier seul (Entrée sur le premier résultat) + await page.keyboard.press('ControlOrMeta+k'); + await page.keyboard.type('bruit'); + await page.locator('.resultat', { hasText: 'Bruit anormal' }).first().waitFor(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/ot\//); + + // Stock : les pièces sous le seuil remontent en tête, filtre fournisseur, + // « Entrée de stock » accessible depuis la liste (choix de la pièce) + await page.getByRole('link', { name: 'Stock & achats' }).click(); + await expect(page.locator('tbody tr').first().locator('.stock-bas')).toHaveText(/Sous le seuil/); + await page.getByLabel('Filtrer par fournisseur').selectOption({ label: 'Lubmaroc' }); + await expect(page.locator('tbody tr', { hasText: 'P-0042' })).toHaveCount(1); + await page.getByLabel('Filtrer par fournisseur').selectOption({ index: 0 }); + await page.getByRole('button', { name: 'Entrée de stock' }).click(); + await expect(page.getByLabel('Pièce *')).toBeVisible(); + await page.getByRole('button', { name: 'Annuler' }).click(); + + // Fiche pièce : le fournisseur est un lien vers les tiers + await page.getByRole('cell', { name: 'P-0113' }).click(); + await page.getByRole('link', { name: 'Ascentech Maroc' }).click(); + await expect(page).toHaveURL(/\/tiers/); + // Tiers : les syndics affichent leurs sites + await expect(page.getByRole('cell', { name: 'Tour Atlas' })).toBeVisible(); + await expect(page.getByRole('cell', { name: 'Résidence Al Manar' })).toBeVisible(); + + // Statistiques : la période est un vrai sélecteur + await page.getByRole('link', { name: 'Statistiques', exact: true }).click(); + await page.getByLabel('Période').selectOption('3'); + await expect(page.getByText('OT clôturés · 3 mois')).toBeVisible(); + + // Bibliothèque : filtre « Rattaché à » + zone de glisser-déposer + await page.getByRole('link', { name: 'Fichiers' }).click(); + await expect(page.getByLabel('Filtrer par appareil')).toBeVisible(); + await expect(page.locator('.zone-depot')).toBeVisible(); +}); diff --git a/apps/web/src/api/gestion.ts b/apps/web/src/api/gestion.ts index 9d31465..a5c3fce 100644 --- a/apps/web/src/api/gestion.ts +++ b/apps/web/src/api/gestion.ts @@ -254,10 +254,15 @@ export async function ouvrirDocument(id: string): Promise { // ————— Analytics ————— -export function useAnalyticsSummary(enabled = true) { +export function useAnalyticsSummary(months: 3 | 6 | 12 = 12, enabled = true) { return useQuery({ - queryKey: ['analytics'], + queryKey: ['analytics', months], enabled, - queryFn: async () => unwrap(await api.GET('/analytics/summary')), + queryFn: async () => + unwrap( + await api.GET('/analytics/summary', { + params: { query: { months: String(months) } }, + }), + ), }); } diff --git a/apps/web/src/api/recherche.ts b/apps/web/src/api/recherche.ts new file mode 100644 index 0000000..f859144 --- /dev/null +++ b/apps/web/src/api/recherche.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import { SEARCH_MIN_CHARS } from '@siop/shared'; +import { api } from './client'; + +async function unwrap(res: { data?: T; error?: unknown; response: Response }): Promise { + if (res.data !== undefined) return res.data; + throw new Error(`Recherche indisponible (${res.response.status})`); +} + +/** Recherche globale de la topbar (⌘K). L'API filtre chaque famille par la + * matrice — le client n'a aucune logique de permission à porter. */ +export function useGlobalSearch(q: string) { + const terme = q.trim(); + return useQuery({ + queryKey: ['search', terme], + enabled: terme.length >= SEARCH_MIN_CHARS, + staleTime: 30_000, + placeholderData: (precedent) => precedent, // pas de clignotement en tapant + queryFn: async () => + unwrap(await api.GET('/search', { params: { query: { q: terme } } })), + }); +} diff --git a/apps/web/src/api/referentiel.ts b/apps/web/src/api/referentiel.ts index edee536..88752df 100644 --- a/apps/web/src/api/referentiel.ts +++ b/apps/web/src/api/referentiel.ts @@ -7,6 +7,7 @@ import type { CategoryUpdate, InvitationCreate, LocationCreate, + LocationUpdate, TeamCreate, UserUpdate, } from '@siop/shared'; @@ -98,6 +99,15 @@ export function useCreateLocation() { }); } +export function useUpdateLocation(id: string) { + const invalidate = useInvalidate('locations', 'partners'); + return useMutation({ + mutationFn: async (body: LocationUpdate) => + unwrap(await api.PATCH('/locations/{id}', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} + export function useCreateAsset() { const invalidate = useInvalidate('assets', 'locations'); return useMutation({ diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index 1c2a4db..1758421 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -441,6 +441,23 @@ export interface paths { patch?: never; trace?: never; }; + "/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Recherche globale (OT, ascenseurs, sites) — chaque famille filtrée par la matrice */ + get: operations["globalSearch"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/partners": { parameters: { query?: never; @@ -1101,6 +1118,8 @@ export interface components { guardianPhone: string | null; latitude: number | null; longitude: number | null; + partnerId: string | null; + partnerName: string | null; assetCount: number; }[]; }; @@ -1115,6 +1134,8 @@ export interface components { guardianPhone: string | null; latitude: number | null; longitude: number | null; + partnerId: string | null; + partnerName: string | null; assetCount: number; }; LocationCreate: { @@ -1127,6 +1148,7 @@ export interface components { guardianPhone?: string; latitude?: number; longitude?: number; + partnerId?: string | null; }; LocationUpdate: { name?: string; @@ -1138,6 +1160,7 @@ export interface components { guardianPhone?: string; latitude?: number; longitude?: number; + partnerId?: string | null; }; AssetsResponse: { assets: { @@ -1397,9 +1420,10 @@ export interface components { createdAt: string; }; AnalyticsSummary: { + months: number; monthCost: number; previousMonthCost: number; - closed12m: { + closed: { total: number; preventive: number; }; @@ -1422,6 +1446,30 @@ export interface components { total: number; }[]; }; + SearchResponse: { + workOrders: { + /** Format: uuid */ + id: string; + reference: string; + title: string; + /** @enum {string} */ + status: "OPEN" | "IN_PROGRESS" | "ON_HOLD" | "DONE" | "CANCELLED"; + }[]; + assets: { + /** Format: uuid */ + id: string; + reference: string; + brand: string; + model: string | null; + siteName: string; + }[]; + sites: { + /** Format: uuid */ + id: string; + name: string; + city: string | null; + }[]; + }; PartnersResponse: { partners: { /** Format: uuid */ @@ -1435,6 +1483,7 @@ export interface components { city: string | null; isActive: boolean; openOrders: number; + siteNames: string[]; }[]; }; Partner: { @@ -1449,6 +1498,7 @@ export interface components { city: string | null; isActive: boolean; openOrders: number; + siteNames: string[]; }; PartnerCreate: { name: string; @@ -2889,7 +2939,9 @@ export interface operations { }; getAnalyticsSummary: { parameters: { - query?: never; + query?: { + months?: string; + }; header?: never; path?: never; cookie?: never; @@ -2907,6 +2959,28 @@ export interface operations { }; }; }; + globalSearch: { + parameters: { + query: { + q: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Résultats (5 max par famille) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SearchResponse"]; + }; + }; + }; + }; listPartners: { parameters: { query?: never; diff --git a/apps/web/src/components/recherche-globale.tsx b/apps/web/src/components/recherche-globale.tsx new file mode 100644 index 0000000..399331d --- /dev/null +++ b/apps/web/src/components/recherche-globale.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { SEARCH_MIN_CHARS } from '@siop/shared'; +import { useGlobalSearch } from '@/api/recherche'; +import { StatutOT } from '@/components/chips-ot'; +import { IcoRecherche } from '@/components/nav-icons'; + +interface Resultat { + cle: string; + route: string; + contenu: React.ReactNode; +} + +/** La recherche de la topbar, enfin active (recette R3) : ⌘K pour saisir, + * résultats groupés (OT / ascenseurs / sites), déjà filtrés par la matrice + * côté API — flèches + Entrée pour naviguer. */ +export function RechercheGlobale() { + const navigate = useNavigate(); + const [saisie, setSaisie] = useState(''); + const [q, setQ] = useState(''); + const [ouverte, setOuverte] = useState(false); + const [actif, setActif] = useState(0); + const champ = useRef(null); + + // Débounce : on n'interroge l'API que 200 ms après la dernière frappe. + useEffect(() => { + const t = setTimeout(() => setQ(saisie), 200); + return () => clearTimeout(t); + }, [saisie]); + + // ⌘K / Ctrl+K : focus depuis n'importe où. + useEffect(() => { + const surTouche = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + champ.current?.focus(); + champ.current?.select(); + } + }; + window.addEventListener('keydown', surTouche); + return () => window.removeEventListener('keydown', surTouche); + }, []); + + const { data } = useGlobalSearch(q); + + const resultats = useMemo(() => { + if (!data) return []; + return [ + ...data.workOrders.map((w) => ({ + cle: `ot-${w.id}`, + route: `/ot/${w.id}`, + contenu: ( + <> + {w.reference} + {w.title} + + + ), + })), + ...data.assets.map((a) => ({ + cle: `asc-${a.id}`, + route: `/ascenseurs/${a.id}`, + contenu: ( + <> + {a.reference} + + {a.brand} + {a.model ? ` ${a.model}` : ''} + + {a.siteName} + + ), + })), + ...data.sites.map((s) => ({ + cle: `site-${s.id}`, + route: `/sites/${s.id}`, + contenu: ( + <> + {s.name} + {s.city ?? ''} + + ), + })), + ]; + }, [data]); + + const groupes = useMemo(() => { + if (!data) return []; + const g: { libelle: string; debut: number; nombre: number }[] = []; + if (data.workOrders.length) g.push({ libelle: 'Ordres de travail', debut: 0, nombre: data.workOrders.length }); + if (data.assets.length) g.push({ libelle: 'Ascenseurs', debut: data.workOrders.length, nombre: data.assets.length }); + if (data.sites.length) { + g.push({ + libelle: 'Sites', + debut: data.workOrders.length + data.assets.length, + nombre: data.sites.length, + }); + } + return g; + }, [data]); + + const visible = ouverte && q.trim().length >= SEARCH_MIN_CHARS; + + const aller = (r: Resultat | undefined) => { + if (!r) return; + setOuverte(false); + setSaisie(''); + navigate(r.route); + }; + + return ( +
+ + { + setSaisie(e.target.value); + setOuverte(true); + setActif(0); + }} + onFocus={() => setOuverte(true)} + onBlur={() => setOuverte(false)} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setOuverte(false); + champ.current?.blur(); + } + if (!visible || !resultats.length) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActif((a) => Math.min(a + 1, resultats.length - 1)); + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setActif((a) => Math.max(a - 1, 0)); + } + if (e.key === 'Enter') { + e.preventDefault(); + aller(resultats[actif]); + } + }} + /> + ⌘K + {visible ? ( +
+ {groupes.map((g) => ( +
+
{g.libelle}
+ {resultats.slice(g.debut, g.debut + g.nombre).map((r, i) => ( + + ))} +
+ ))} + {!resultats.length ? ( +
Aucun résultat pour « {q.trim()} »
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/layout/coquille.tsx b/apps/web/src/layout/coquille.tsx index eb51396..f8d98e3 100644 --- a/apps/web/src/layout/coquille.tsx +++ b/apps/web/src/layout/coquille.tsx @@ -6,6 +6,7 @@ import { getToken } from '@/api/client'; import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth'; import { Avatar, initialsOf } from '@/components/avatar'; import { Logo } from '@/components/logo'; +import { RechercheGlobale } from '@/components/recherche-globale'; import { useQuery } from '@tanstack/react-query'; import type { ObjectCategory, PermissionRight } from '@siop/shared'; import { api } from '@/api/client'; @@ -18,7 +19,6 @@ import { IcoOrdresDeTravail, IcoPersonnes, IcoPreventif, - IcoRecherche, IcoSites, IcoStatistiques, IcoStock, @@ -185,10 +185,7 @@ export function Coquille({ children }: { children: ReactNode }) {
- + {urgences.length > 0 ? ( + ) : null} {documents?.length ? (
{documents.map((d) => ( @@ -66,16 +110,27 @@ export default function PageBibliotheque() { Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) — le rattachement propre commence ici.
- {peutEditer ? : null} + {peutEditer ? ( + { + setModale(o); + if (!o) setFichierDepose(null); + }} + /> + ) : null} ); } function ModaleTeleversement({ ouverte, + fichierInitial, surFermeture, }: { ouverte: boolean; + fichierInitial?: File | null; surFermeture: (o: boolean) => void; }) { const { data: options } = useAssetOptions(); @@ -85,6 +140,11 @@ function ModaleTeleversement({ const [assetId, setAssetId] = useState(''); const [fichier, setFichier] = useState(null); + // Fichier arrivé par glisser-déposer : la modale s'ouvre déjà remplie. + useEffect(() => { + if (fichierInitial) setFichier(fichierInitial); + }, [fichierInitial]); + const envoyer = () => { if (!fichier || !assetId) return; if (fichier.size > DOCUMENT_MAX_BYTES) { diff --git a/apps/web/src/pages/fiche-piece.tsx b/apps/web/src/pages/fiche-piece.tsx index 46fb147..f30025c 100644 --- a/apps/web/src/pages/fiche-piece.tsx +++ b/apps/web/src/pages/fiche-piece.tsx @@ -52,7 +52,15 @@ export default function PageFichePiece() { {piece.lastUnitPrice != null ? `${fmt.format(piece.lastUnitPrice)} MAD` : 'aucun prix connu'}
Fournisseur
-
{piece.supplierName ?? '—'}
+
+ {piece.supplierName ? ( + + {piece.supplierName} + + ) : ( + '—' + )} +
Compatible
{piece.compatible ?? '—'}
diff --git a/apps/web/src/pages/fiche-site.tsx b/apps/web/src/pages/fiche-site.tsx index cea8212..7c1221d 100644 --- a/apps/web/src/pages/fiche-site.tsx +++ b/apps/web/src/pages/fiche-site.tsx @@ -1,6 +1,7 @@ import { useState, type FormEvent } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; -import { useAssets, useCreateLocation, useLocations } from '@/api/referentiel'; +import { usePartners } from '@/api/gestion'; +import { useAssets, useCreateLocation, useLocations, useUpdateLocation } from '@/api/referentiel'; import { usePermissions } from '@/auth/use-permissions'; import { CarteGeo } from '@/components/carte-geo'; import { StatutEquipement } from '@/components/statut-equipement'; @@ -56,6 +57,14 @@ export default function PageFicheSite() { · {site.guardianPhone} ) : null} +
Client / syndic
+
+ +
Appareils
{site.assetCount}
@@ -121,6 +130,40 @@ export default function PageFicheSite() { ); } +/** Rattachement du site à son client/syndic (colonne « Rattachements » des + * Tiers). Éditable si LOCATIONS.edit ET si la matrice donne accès aux tiers + * (la liste des tiers est sous PURCHASE_ORDERS.view) — sinon, lecture seule. */ +function ChampSyndic({ + siteId, + partnerId, + partnerName, +}: { + siteId: string; + partnerId: string | null; + partnerName: string | null; +}) { + const { can } = usePermissions(); + const editable = can('LOCATIONS', 'edit') && can('PURCHASE_ORDERS', 'view'); + const { data: partners } = usePartners(editable); + const maj = useUpdateLocation(siteId); + + if (!editable) return <>{partnerName ?? '—'}; + const clients = (partners ?? []).filter((p) => p.kind === 'CLIENT' && p.isActive); + return ( + + ); +} + function NouvelleZone({ siteId, ouverte, diff --git a/apps/web/src/pages/statistiques.tsx b/apps/web/src/pages/statistiques.tsx index e9da204..e1599fb 100644 --- a/apps/web/src/pages/statistiques.tsx +++ b/apps/web/src/pages/statistiques.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; +import { ANALYTICS_PERIODS, type AnalyticsPeriod } from '@siop/shared'; import { useAnalyticsSummary } from '@/api/gestion'; const fmt = new Intl.NumberFormat('fr-FR'); @@ -16,7 +18,8 @@ function moisLisible(period: string): string { /** Écran 5 des maquettes R3 : M. Bennis lit en 30 s — barres horizontales * à étiquettes directes (palette CVD validée), chiffres tabulaires. */ export default function PageStatistiques() { - const { data } = useAnalyticsSummary(); + const [periode, setPeriode] = useState(12); + const { data } = useAnalyticsSummary(periode); if (!data) return null; const delta = @@ -30,7 +33,17 @@ export default function PageStatistiques() { <>

Statistiques

- 12 derniers mois +
+ +
@@ -50,9 +63,9 @@ export default function PageStatistiques() { )}
-
OT clôturés · 12 mois
-
{fmt.format(data.closed12m.total)}
-
dont {fmt.format(data.closed12m.preventive)} préventifs
+
OT clôturés · {data.months} mois
+
{fmt.format(data.closed.total)}
+
dont {fmt.format(data.closed.preventive)} préventifs
Taux de préventif
@@ -66,13 +79,13 @@ export default function PageStatistiques() {
{data.avgResolutionDays != null ? `${fmtCompact.format(data.avgResolutionDays)} j` : '—'}
-
dépannages, 12 mois
+
dépannages, {data.months} mois

- Pannes par organe · 12 mois{' '} + Pannes par organe · {data.months} mois{' '} (bilans codés) @@ -119,7 +132,7 @@ export default function PageStatistiques() {

-

Top équipements en coût · 12 mois

+

Top équipements en coût · {data.months} mois

diff --git a/apps/web/src/pages/stock.tsx b/apps/web/src/pages/stock.tsx index 0e2eb33..945fb30 100644 --- a/apps/web/src/pages/stock.tsx +++ b/apps/web/src/pages/stock.tsx @@ -1,6 +1,6 @@ import { useState, type FormEvent } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { useCreatePart, usePartners, useParts } from '@/api/gestion'; +import { useAddStockMovement, useCreatePart, usePartners, useParts } from '@/api/gestion'; import { usePermissions } from '@/auth/use-permissions'; import { Button } from '@/components/ui/button'; import { Modale } from '@/components/ui/modale'; @@ -8,17 +8,26 @@ import { Modale } from '@/components/ui/modale'; const fmt = new Intl.NumberFormat('fr-FR'); /** Écran 1 des maquettes R3 : le stock DÉRIVÉ des mouvements — jamais de - * quantité saisie. Sous le seuil : strie ambre + « Préparer le BC ». */ + * quantité saisie. Sous le seuil : strie ambre + « Préparer le BC », + * et les pièces en alerte remontent en tête de liste (maquette). */ export default function PageStock() { const navigate = useNavigate(); const { data: parts } = useParts(); const { can } = usePermissions(); - const [modale, setModale] = useState(false); + const [modale, setModale] = useState<'piece' | 'entree' | null>(null); const [alerte, setAlerte] = useState(false); + const [fournisseur, setFournisseur] = useState(''); const toutes = parts ?? []; const sousSeuil = toutes.filter((p) => p.belowThreshold && p.isActive); - const visibles = alerte ? sousSeuil : toutes; + const fournisseurs = [...new Set(toutes.map((p) => p.supplierName).filter(Boolean))] as string[]; + const visibles = (alerte ? sousSeuil : toutes) + .filter((p) => !fournisseur || p.supplierName === fournisseur) + .sort( + (a, b) => + Number(b.belowThreshold && b.isActive) - Number(a.belowThreshold && a.isActive) || + a.reference.localeCompare(b.reference), + ); return ( <> @@ -37,8 +46,11 @@ export default function PageStock() { {can('PURCHASE_ORDERS', 'view') ? ( ) : null} + {can('PARTS', 'edit') ? ( + + ) : null} {can('PARTS', 'create') ? ( - ) : null} @@ -53,6 +65,16 @@ export default function PageStock() { +
@@ -105,11 +127,72 @@ export default function PageStock() { Le stock affiché = somme des mouvements (réceptions de BC, entrées manuelles, consommations d'OT, ajustements motivés). Aucune saisie directe de quantité.
- {can('PARTS', 'create') ? : null} + {can('PARTS', 'create') ? ( + setModale(null)} /> + ) : null} + {can('PARTS', 'edit') && modale === 'entree' ? ( + setModale(null)} /> + ) : null} ); } +/** Entrée de stock depuis la liste (maquette) : on choisit la pièce ici — + * même mouvement tracé que depuis la fiche. */ +function EntreeDeStock({ surFermeture }: { surFermeture: () => void }) { + const { data: parts } = useParts(); + const [pieceId, setPieceId] = useState(''); + const mouvement = useAddStockMovement(pieceId); + const actives = (parts ?? []).filter((p) => p.isActive); + + const surEnvoi = (e: FormEvent) => { + e.preventDefault(); + if (!pieceId) return; + const donnees = new FormData(e.currentTarget); + mouvement.mutate( + { + kind: 'ENTRY', + quantity: Number(donnees.get('quantity')), + reason: String(donnees.get('reason') ?? '').trim() || undefined, + }, + { onSuccess: surFermeture }, + ); + }; + + return ( + +
+
+ + +
+
+ + +
+
+ + +
+ {mouvement.isError ?

{mouvement.error.message}

: null} +
+ + +
+ +
+ ); +} + function NouvellePiece({ ouverte, surFermeture, diff --git a/apps/web/src/pages/tableau-de-bord.tsx b/apps/web/src/pages/tableau-de-bord.tsx index fa3a423..53e5397 100644 --- a/apps/web/src/pages/tableau-de-bord.tsx +++ b/apps/web/src/pages/tableau-de-bord.tsx @@ -28,7 +28,7 @@ export default function PageTableauDeBord() { const { data: workOrders } = useWorkOrders(peutVoirOT); const { data: requests } = useRequests(); const { data: preventif } = usePreventiveStatus(peutVoirOT); - const { data: stats } = useAnalyticsSummary(peutVoirStats); + const { data: stats } = useAnalyticsSummary(12, peutVoirStats); if (!chargé) return null; if (!peutVoirOT) return ; diff --git a/apps/web/src/pages/tiers.tsx b/apps/web/src/pages/tiers.tsx index 7fbd4b2..1d547ba 100644 --- a/apps/web/src/pages/tiers.tsx +++ b/apps/web/src/pages/tiers.tsx @@ -49,7 +49,9 @@ export default function PageTiers() { ? p.openOrders ? `${p.openOrders} BC en cours` : '—' - : '—'} + : p.siteNames.length + ? p.siteNames.join(' · ') + : '—'} ))} diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 1cb15c4..bdd7865 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -187,7 +187,38 @@ table { .recherche svg { width: 15px; height: 15px; + flex: none; } +.recherche[data-active] { position: relative; color: var(--encre-3); } +.recherche input { + flex: 1; min-width: 0; border: 0; background: transparent; outline: none; + color: var(--encre); font-size: 13px; font-family: inherit; +} +.recherche input::placeholder { color: var(--encre-3); } +.recherche kbd { + font-family: inherit; font-size: 11px; color: var(--encre-3); + border: 1px solid var(--bordure); border-radius: 5px; padding: 1px 5px; + background: var(--surface); +} +.recherche .resultats { + position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 50; + background: var(--surface); border: 1px solid var(--bordure); + border-radius: var(--rayon); box-shadow: 0 12px 32px rgb(15 23 42 / .14); + padding: 6px; max-height: 420px; overflow-y: auto; +} +.groupe-resultats { + font-size: 10.5px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; + color: var(--encre-3); padding: 8px 10px 4px; +} +.resultat { + display: flex; align-items: center; gap: 10px; width: 100%; + padding: 8px 10px; border-radius: 8px; text-align: left; + color: var(--encre); font-size: 13px; cursor: pointer; +} +.resultat[data-actif] { background: var(--primaire-doux); } +.resultat .ref { color: var(--primaire); font-weight: 700; flex: none; } +.resultat .titre { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.resultat .fin { flex: none; color: var(--encre-2); font-size: 12px; display: inline-flex; } .compte { margin-left: auto; display: flex; @@ -896,6 +927,15 @@ table { .total { display: flex; justify-content: space-between; font-weight: 700; padding-top: 10px; font-size: 14px; } /* Bibliothèque */ +.zone-depot { + width: 100%; border: 1.5px dashed var(--bordure-forte, var(--bordure)); + border-radius: var(--rayon); background: var(--surface); + padding: 16px; color: var(--encre-2); font-size: 13px; text-align: center; + cursor: pointer; transition: border-color .15s, background-color .15s; +} +.zone-depot:hover, .zone-depot[data-survol] { + border-color: var(--primaire); background: var(--primaire-doux); color: var(--encre); +} .docs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } @media (max-width: 1100px) { .docs { grid-template-columns: repeat(2, 1fr); } } .doc { diff --git a/docs/journal/journal.md b/docs/journal/journal.md index 65c16c4..df66ba0 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,25 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-07-17 — Pr. Daaif (+ Claude) — R3.4 : revue pixel, corrections de recette, recherche globale + +**Actions** + +- **Revue pixel R3** (artefact partagé, captures maquette ↔ appli côte à côte, 1440 px, thème clair) : 7/7 écrans structurellement fidèles, 8 écarts relevés. **Arbitrage du référent : tout corriger + activer la recherche.** +- **Stock** : filtre Fournisseur, « Entrée de stock » depuis la liste (choix de la pièce dans la modale), pièces sous le seuil en tête. **Fiche pièce** : fournisseur → lien vers Tiers. **Statistiques** : « Période » devient un vrai sélecteur 3/6/12 mois (`months` au contrat, libellés et barres suivent). **Tiers** : les syndics affichent leurs sites — nouveau lien site→client/syndic (migration `r3_recette_fixes`, `Location.partnerId`, garde « CLIENT seulement »), éditable sur la fiche site, seedé (Tour Atlas, Résidence Al Manar). **Bibliothèque** : filtre « Rattaché à » + zone de glisser-déposer (la modale s'ouvre préremplie, le rattachement reste obligatoire). +- **Recherche globale ⌘K active** : `GET /search` (73e opération au contrat) — chaque famille (OT/ascenseurs/sites) n'est interrogée que si le rôle a la permission `view` du domaine, les OT respectent « voir autre » ; topbar avec debounce 200 ms, résultats groupés, navigation flèches + Entrée. 8 tests API dédiés (scoping vérifié rôle par rôle). +- Écart « regroupement des tiers » retiré : le tri type-puis-nom existait déjà (constat d'écran, pas de code). +- Vérifié : 18/18 contrôles en navigateur réel (zéro erreur console), **14/14 Playwright** (dont nouveau parcours « recette corrigée »), **74 tests API** (13 suites). Artefact de revue mis à jour (statut « corrigé », captures fraîches). + +**Décisions** + +- « Bons de commande » reste dans l'entête du Stock **en plus** de la maquette : c'est le seul accès à l'écran BC — à entériner en recette (ou déplacer si le référent préfère). +- La liste des tiers restant sous `PURCHASE_ORDERS.view`, le champ syndic de la fiche site n'est éditable que si le rôle voit les tiers (sinon lecture seule). + +**Prochaine étape** : validation finale du référent sur l'artefact → déploiement Dokploy → tag `release/r3` → R4 Mobile (design d'abord). + +--- + ## 2026-07-16 — Pr. Daaif (+ Claude) — R3.3+ : aperçu et ouverture des documents (retour de pré-recette) **Actions** diff --git a/docs/openapi.json b/docs/openapi.json index fa33870..3ac7719 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1136,6 +1136,16 @@ "tags": [ "analytics" ], + "parameters": [ + { + "name": "months", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], "security": [ { "bearerAuth": [] @@ -1155,6 +1165,42 @@ } } }, + "/search": { + "get": { + "operationId": "globalSearch", + "summary": "Recherche globale (OT, ascenseurs, sites) — chaque famille filtrée par la matrice", + "tags": [ + "search" + ], + "parameters": [ + { + "name": "q", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Résultats (5 max par famille)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponse" + } + } + } + } + } + } + }, "/partners": { "get": { "operationId": "listPartners", @@ -3239,6 +3285,28 @@ } ] }, + "partnerId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "partnerName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "assetCount": { "type": "integer", "minimum": -9007199254740991, @@ -3255,6 +3323,8 @@ "guardianPhone", "latitude", "longitude", + "partnerId", + "partnerName", "assetCount" ], "additionalProperties": false @@ -3350,6 +3420,28 @@ } ] }, + "partnerId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "partnerName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "assetCount": { "type": "integer", "minimum": -9007199254740991, @@ -3366,6 +3458,8 @@ "guardianPhone", "latitude", "longitude", + "partnerId", + "partnerName", "assetCount" ], "additionalProperties": false @@ -3409,6 +3503,18 @@ "type": "number", "minimum": -180, "maximum": 180 + }, + "partnerId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -3455,6 +3561,18 @@ "type": "number", "minimum": -180, "maximum": 180 + }, + "partnerId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] } }, "additionalProperties": false @@ -4715,13 +4833,18 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { + "months": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, "monthCost": { "type": "number" }, "previousMonthCost": { "type": "number" }, - "closed12m": { + "closed": { "type": "object", "properties": { "total": { @@ -4840,9 +4963,10 @@ } }, "required": [ + "months", "monthCost", "previousMonthCost", - "closed12m", + "closed", "preventiveRate", "avgResolutionDays", "failuresByComponent", @@ -4851,6 +4975,126 @@ ], "additionalProperties": false }, + "SearchResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "workOrders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "title": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "OPEN", + "IN_PROGRESS", + "ON_HOLD", + "DONE", + "CANCELLED" + ] + } + }, + "required": [ + "id", + "reference", + "title", + "status" + ], + "additionalProperties": false + } + }, + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "brand": { + "type": "string" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "siteName": { + "type": "string" + } + }, + "required": [ + "id", + "reference", + "brand", + "model", + "siteName" + ], + "additionalProperties": false + } + }, + "sites": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string" + }, + "city": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "city" + ], + "additionalProperties": false + } + } + }, + "required": [ + "workOrders", + "assets", + "sites" + ], + "additionalProperties": false + }, "PartnersResponse": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", @@ -4922,6 +5166,12 @@ "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 + }, + "siteNames": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -4933,7 +5183,8 @@ "email", "city", "isActive", - "openOrders" + "openOrders", + "siteNames" ], "additionalProperties": false } @@ -5010,6 +5261,12 @@ "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 + }, + "siteNames": { + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -5021,7 +5278,8 @@ "email", "city", "isActive", - "openOrders" + "openOrders", + "siteNames" ], "additionalProperties": false }, diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 546d766..9c2e5f6 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -49,6 +49,7 @@ import { DocumentSchema, DocumentsResponseSchema, } from './schemas/documents'; +import { SearchResponseSchema } from './schemas/search'; import { ConsumePartSchema, LaborTimeCreateSchema, @@ -116,7 +117,7 @@ export interface ApiOperation { /** Paramètres de chemin (`{id}` dans path) — `id`/`…Id` = UUID. */ pathParams?: string[]; /** Paramètres de requête (?a=…&b=…). */ - queryParams?: { name: string; required?: boolean }[]; + queryParams?: { name: string; required?: boolean; description?: string }[]; /** Upload multipart/form-data : champs déclarés ('file' = binaire). */ multipartFields?: Record; /** Réponse 200 binaire (téléchargement streamé). */ @@ -502,10 +503,22 @@ export const API_CONTRACT: ApiOperation[] = [ path: '/analytics/summary', summary: 'Le tableau de la direction : coûts, pannes par organe (bilans), préventif, top équipements', tags: ['analytics'], + queryParams: [{ name: 'months', description: 'Période en mois : 3, 6 ou 12 (défaut)' }], responses: { 200: { description: 'Synthèse', name: 'AnalyticsSummary', schema: AnalyticsSummarySchema }, }, }, + { + operationId: 'globalSearch', + method: 'get', + path: '/search', + summary: 'Recherche globale (OT, ascenseurs, sites) — chaque famille filtrée par la matrice', + tags: ['search'], + queryParams: [{ name: 'q', required: true, description: '2 caractères minimum' }], + responses: { + 200: { description: 'Résultats (5 max par famille)', name: 'SearchResponse', schema: SearchResponseSchema }, + }, + }, // ————— R3 · Gestion (stock, achats, tiers, coûts) ————— { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 540186f..12f1bcc 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -11,4 +11,5 @@ export * from './schemas/users'; export * from './schemas/users-admin'; export * from './schemas/referentiel'; export * from './schemas/health'; +export * from './schemas/search'; export * from './contract'; diff --git a/packages/shared/src/schemas/documents.ts b/packages/shared/src/schemas/documents.ts index 00dca80..84e2ca3 100644 --- a/packages/shared/src/schemas/documents.ts +++ b/packages/shared/src/schemas/documents.ts @@ -38,16 +38,20 @@ export type DocumentsResponse = z.infer; // ————— Analytics (écran Statistiques, maquette R3) ————— +export const ANALYTICS_PERIODS = [3, 6, 12] as const; +export type AnalyticsPeriod = (typeof ANALYTICS_PERIODS)[number]; + export const AnalyticsSummarySchema = z.object({ + months: z.number().int(), // période appliquée (3, 6 ou 12 mois) monthCost: z.number(), // MAD, mois courant (pièces + main-d'œuvre) previousMonthCost: z.number(), - closed12m: z.object({ total: z.number().int(), preventive: z.number().int() }), - preventiveRate: z.number().nullable(), // grilles terminées / générées (12 mois) - avgResolutionDays: z.number().nullable(), // dépannages terminés (12 mois) + closed: z.object({ total: z.number().int(), preventive: z.number().int() }), + preventiveRate: z.number().nullable(), // grilles terminées / générées (période) + avgResolutionDays: z.number().nullable(), // dépannages terminés (période) failuresByComponent: z.array( z.object({ label: z.string(), count: z.number().int() }), ), - costsByMonth: z.array(z.object({ month: z.string(), total: z.number() })), // 6 derniers + costsByMonth: z.array(z.object({ month: z.string(), total: z.number() })), // un point par mois de la période topAssets: z.array( z.object({ reference: z.string(), diff --git a/packages/shared/src/schemas/gestion.ts b/packages/shared/src/schemas/gestion.ts index 752849d..5bfcb33 100644 --- a/packages/shared/src/schemas/gestion.ts +++ b/packages/shared/src/schemas/gestion.ts @@ -39,6 +39,7 @@ export const PartnerSchema = z.object({ city: z.string().nullable(), isActive: z.boolean(), openOrders: z.number().int(), // BC non reçus/annulés (fournisseur) + siteNames: z.array(z.string()), // sites gérés (client/syndic) — maquette « Rattachements » }); export type PartnerDto = z.infer; diff --git a/packages/shared/src/schemas/referentiel.ts b/packages/shared/src/schemas/referentiel.ts index 82cf50b..0a34feb 100644 --- a/packages/shared/src/schemas/referentiel.ts +++ b/packages/shared/src/schemas/referentiel.ts @@ -41,6 +41,8 @@ export const LocationSchema = z.object({ guardianPhone: z.string().nullable(), latitude: z.number().nullable(), longitude: z.number().nullable(), + partnerId: z.uuid().nullable(), // client/syndic du site (R3 — rattachements Tiers) + partnerName: z.string().nullable(), assetCount: z.number().int(), // appareils rattachés (zones incluses pour un site) }); export type LocationDto = z.infer; @@ -59,6 +61,7 @@ export const LocationCreateSchema = z.object({ guardianPhone: z.string().max(40).optional(), latitude: z.number().min(-90).max(90).optional(), longitude: z.number().min(-180).max(180).optional(), + partnerId: z.uuid().nullable().optional(), // null = détacher le client/syndic }); export type LocationCreate = z.infer; diff --git a/packages/shared/src/schemas/search.ts b/packages/shared/src/schemas/search.ts new file mode 100644 index 0000000..63f163d --- /dev/null +++ b/packages/shared/src/schemas/search.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { WORK_ORDER_STATUSES } from '../exploitation'; + +/** Recherche globale (topbar ⌘K) — chaque famille de résultats n'apparaît + * que si l'appelant a la permission `view` du domaine ; les OT respectent + * en plus l'invariant « voir autre ». */ + +export const SEARCH_MIN_CHARS = 2; +export const SEARCH_MAX_PER_KIND = 5; + +export const SearchResponseSchema = z.object({ + workOrders: z.array( + z.object({ + id: z.uuid(), + reference: z.string(), + title: z.string(), + status: z.enum(WORK_ORDER_STATUSES), + }), + ), + assets: z.array( + z.object({ + id: z.uuid(), + reference: z.string(), + brand: z.string(), + model: z.string().nullable(), + siteName: z.string(), + }), + ), + sites: z.array( + z.object({ + id: z.uuid(), + name: z.string(), + city: z.string().nullable(), + }), + ), +}); +export type SearchResponse = z.infer;