mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r3.4): corrections de recette (8 écarts) + recherche globale ⌘K
Arbitrage du référent sur la revue pixel : tout corriger, activer la recherche. - Stock : filtre fournisseur, sous-seuil en tête, « Entrée de stock » depuis la liste ; fiche pièce : fournisseur → lien Tiers. - Statistiques : période 3/6/12 mois (paramètre months au contrat). - Tiers : rattachements syndic→site (migration r3_recette_fixes, Location.partnerId gardé CLIENT), éditable sur la fiche site, seedé. - Bibliothèque : filtre « Rattaché à » + glisser-déposer (modale préremplie, rattachement toujours requis). - Recherche globale : GET /search (73 opérations) — familles OT/ ascenseurs/sites filtrées par la matrice, « voir autre » respecté ; topbar ⌘K, debounce, résultats groupés, navigation clavier. 74 tests API (8 nouveaux sur le scoping de la recherche), 14/14 Playwright dont un parcours « recette corrigée », 18/18 contrôles en navigateur réel, zéro erreur console. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -616,6 +616,17 @@ async function seedGestion(prisma: PrismaClient): Promise<void> {
|
||||
partnerIds.set(p.name, partner.id);
|
||||
}
|
||||
|
||||
// Rattachements de la maquette Tiers : les syndics gèrent leur site.
|
||||
for (const [site, partner] of [
|
||||
['Tour Atlas', 'Atlas Property Management'],
|
||||
['Résidence Al Manar', 'Syndic Al Manar'],
|
||||
] as const) {
|
||||
await prisma.location.updateMany({
|
||||
where: { name: site, parentId: null, partnerId: null },
|
||||
data: { partnerId: partnerIds.get(partner) },
|
||||
});
|
||||
}
|
||||
|
||||
const partIds = new Map<string, string>();
|
||||
for (const p of PARTS) {
|
||||
const part = await prisma.part.upsert({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AnalyticsSummary> {
|
||||
const depuis12m = debutMois(11);
|
||||
const depuis6m = debutMois(5);
|
||||
async summary(monthsRaw?: string): Promise<AnalyticsSummary> {
|
||||
// Période demandée par l'écran (3, 6 ou 12 mois) — 12 par défaut.
|
||||
const months: AnalyticsPeriod = ANALYTICS_PERIODS.find(
|
||||
(p) => p === Number(monthsRaw),
|
||||
) ?? 12;
|
||||
const depuisPeriode = debutMois(months - 1);
|
||||
|
||||
const [consommations, mainOeuvre, clos12m, grilles12m, correctifsClos, pannes, topDonnees] =
|
||||
await Promise.all([
|
||||
this.prisma.stockMovement.findMany({
|
||||
where: { kind: 'CONSUMPTION', createdAt: { gte: 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<string, number>();
|
||||
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,
|
||||
|
||||
@@ -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] : []),
|
||||
],
|
||||
|
||||
@@ -21,9 +21,16 @@ type LocationRow = {
|
||||
guardianPhone: string | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
partnerId: string | null;
|
||||
partner: { name: string } | null;
|
||||
_count: { assets: number };
|
||||
};
|
||||
|
||||
const locationInclude = {
|
||||
partner: { select: { name: true } },
|
||||
_count: { select: { assets: true } },
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class LocationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -31,7 +38,7 @@ export class LocationsService {
|
||||
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
|
||||
async list(): Promise<LocationsResponse> {
|
||||
const rows: LocationRow[] = await this.prisma.location.findMany({
|
||||
include: { _count: { select: { assets: true } } },
|
||||
include: locationInclude,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
const childAssets = new Map<string, number>();
|
||||
@@ -52,9 +59,10 @@ export class LocationsService {
|
||||
|
||||
async create(dto: LocationCreate): Promise<LocationDto> {
|
||||
await this.assertDepth(dto.parentId);
|
||||
await this.assertClientPartner(dto.partnerId);
|
||||
const created = await this.prisma.location.create({
|
||||
data: dto,
|
||||
include: { _count: { select: { assets: true } } },
|
||||
include: locationInclude,
|
||||
});
|
||||
return this.toDto(created, 0);
|
||||
}
|
||||
@@ -76,10 +84,11 @@ export class LocationsService {
|
||||
}
|
||||
await this.assertDepth(dto.parentId);
|
||||
}
|
||||
await this.assertClientPartner(dto.partnerId);
|
||||
const updated = await this.prisma.location.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
include: { _count: { select: { assets: true } } },
|
||||
include: locationInclude,
|
||||
});
|
||||
return this.toDto(updated, updated._count.assets);
|
||||
}
|
||||
@@ -96,6 +105,16 @@ export class LocationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** R3 : seul un tiers Client/syndic peut gérer un site. */
|
||||
private async assertClientPartner(partnerId?: string | null): Promise<void> {
|
||||
if (!partnerId) return;
|
||||
const partner = await this.prisma.partner.findUnique({ where: { id: partnerId } });
|
||||
if (!partner) throw new BadRequestException('Tiers inconnu');
|
||||
if (partner.kind !== 'CLIENT') {
|
||||
throw new BadRequestException('Seul un tiers « Client / syndic » peut être rattaché à un site');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -107,6 +126,8 @@ export class LocationsService {
|
||||
guardianPhone: row.guardianPhone,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
partnerId: row.partnerId,
|
||||
partnerName: row.partner?.name ?? null,
|
||||
assetCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
15
apps/api/src/search/search.controller.ts
Normal file
15
apps/api/src/search/search.controller.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
/** Authentification seule (pas de @RequirePermission) : chaque famille de
|
||||
* résultats est filtrée par la matrice DANS le service. */
|
||||
@Controller('search')
|
||||
export class SearchController {
|
||||
constructor(private readonly search: SearchService) {}
|
||||
|
||||
@Get()
|
||||
global(@CurrentUser() user: AuthenticatedUser, @Query('q') q = '') {
|
||||
return this.search.search(user, q);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/search/search.module.ts
Normal file
9
apps/api/src/search/search.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
105
apps/api/src/search/search.service.ts
Normal file
105
apps/api/src/search/search.service.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import { SEARCH_MAX_PER_KIND, SEARCH_MIN_CHARS, type SearchResponse } from '@siop/shared';
|
||||
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/** Recherche globale de la topbar (⌘K). Chaque famille n'est interrogée que
|
||||
* si le rôle a la permission `view` du domaine ; les OT respectent en plus
|
||||
* l'invariant « voir autre » (même règle que la liste des OT). */
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
) {}
|
||||
|
||||
async search(user: AuthenticatedUser, q: string): Promise<SearchResponse> {
|
||||
const terme = q.trim();
|
||||
const vide: SearchResponse = { workOrders: [], assets: [], sites: [] };
|
||||
if (terme.length < SEARCH_MIN_CHARS) return vide;
|
||||
|
||||
const [voitOT, voitParc, voitSites] = await Promise.all([
|
||||
this.permissions.can(user.roleId, 'WORK_ORDERS', 'view'),
|
||||
this.permissions.can(user.roleId, 'ASSETS', 'view'),
|
||||
this.permissions.can(user.roleId, 'LOCATIONS', 'view'),
|
||||
]);
|
||||
const contient = (champ: string): Prisma.StringFilter => ({
|
||||
contains: champ,
|
||||
mode: 'insensitive',
|
||||
});
|
||||
|
||||
const [workOrders, assets, sites] = await Promise.all([
|
||||
voitOT
|
||||
? this.prisma.workOrder.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ OR: [{ reference: contient(terme) }, { title: contient(terme) }] },
|
||||
await this.scopeOT(user),
|
||||
],
|
||||
},
|
||||
select: { id: true, reference: true, title: true, status: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
voitParc
|
||||
? this.prisma.asset.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ reference: contient(terme) },
|
||||
{ brand: contient(terme) },
|
||||
{ model: contient(terme) },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
reference: true,
|
||||
brand: true,
|
||||
model: true,
|
||||
location: { select: { name: true, parent: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: { reference: 'asc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
voitSites
|
||||
? this.prisma.location.findMany({
|
||||
where: {
|
||||
parentId: null,
|
||||
OR: [{ name: contient(terme) }, { city: contient(terme) }],
|
||||
},
|
||||
select: { id: true, name: true, city: true },
|
||||
orderBy: { name: 'asc' },
|
||||
take: SEARCH_MAX_PER_KIND,
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
return {
|
||||
workOrders,
|
||||
assets: assets.map((a) => ({
|
||||
id: a.id,
|
||||
reference: a.reference,
|
||||
brand: a.brand,
|
||||
model: a.model,
|
||||
siteName: a.location.parent?.name ?? a.location.name,
|
||||
})),
|
||||
sites,
|
||||
};
|
||||
}
|
||||
|
||||
/** Même invariant que WorkOrdersService.scope — sans le droit « voir
|
||||
* autre », on ne trouve que SES OT. */
|
||||
private async scopeOT(user: AuthenticatedUser): Promise<Prisma.WorkOrderWhereInput> {
|
||||
const viewOther = await this.permissions.can(user.roleId, 'WORK_ORDERS', 'viewOther');
|
||||
if (viewOther) return {};
|
||||
return {
|
||||
OR: [
|
||||
{ assignees: { some: { id: user.userId } } },
|
||||
{ createdById: user.userId },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
141
apps/api/test/recherche.e2e-spec.ts
Normal file
141
apps/api/test/recherche.e2e-spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* E2E R3 (recette) — recherche globale : chaque famille de résultats est
|
||||
* filtrée par la matrice, les OT respectent « voir autre » ; au passage,
|
||||
* rattachements Tiers (siteNames) et période analytics paramétrable.
|
||||
*/
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { seed } from '../prisma/seed';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
describe('Recherche globale & corrections de recette R3 (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let yasmine: string; // Administrateur
|
||||
let salma: string; // Dispatcher (voir autre)
|
||||
let youssef: string; // Technicien limité : SES OT, ASSETS view, pas de LOCATIONS
|
||||
let karim: string; // Demandeur : aucune des trois familles
|
||||
let otId: string;
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
||||
const suffix = `rech-${Date.now().toString(36)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
await seed(prisma);
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot()],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
const { body } = await http().get('/auth/demo-accounts');
|
||||
const login = async (roleName: string) => {
|
||||
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||
return (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||
.accessToken as string;
|
||||
};
|
||||
yasmine = await login('Administrateur');
|
||||
salma = await login('Dispatcher');
|
||||
youssef = await login('Technicien limité');
|
||||
karim = await login('Demandeur');
|
||||
|
||||
// Un OT au titre unique, assigné à Ahmed (PAS à Youssef).
|
||||
const { body: assets } = await http().get('/assets').set(auth(salma));
|
||||
const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
const ahmed = body.accounts.find((a: { roleName: string }) => a.roleName === 'Technicien');
|
||||
const cree = await http()
|
||||
.post('/work-orders')
|
||||
.set(auth(salma))
|
||||
.send({
|
||||
title: `Recherche E2E ${suffix}`,
|
||||
type: 'CORRECTIVE',
|
||||
priority: 'LOW',
|
||||
assetId: a1.id,
|
||||
assigneeIds: [ahmed.id],
|
||||
})
|
||||
.expect(201);
|
||||
otId = cree.body.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.workOrder.deleteMany({ where: { title: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('dispatcher (voir autre) : OT par titre, appareil par référence, site insensible à la casse', async () => {
|
||||
const parTitre = await http().get(`/search?q=${suffix}`).set(auth(salma)).expect(200);
|
||||
expect(parTitre.body.workOrders.map((w: { id: string }) => w.id)).toContain(otId);
|
||||
|
||||
const parRef = await http().get('/search?q=A1').set(auth(salma)).expect(200);
|
||||
expect(parRef.body.assets.some((a: { reference: string }) => a.reference === 'A1')).toBe(true);
|
||||
expect(parRef.body.assets[0].siteName).toBeTruthy();
|
||||
|
||||
const parSite = await http().get('/search?q=atlas').set(auth(salma)).expect(200);
|
||||
expect(parSite.body.sites.some((s: { name: string }) => s.name === 'Tour Atlas')).toBe(true);
|
||||
});
|
||||
|
||||
it('technicien limité : ne trouve pas l’OT d’un autre, ni les sites ; le parc oui', async () => {
|
||||
const res = await http().get(`/search?q=${suffix}`).set(auth(youssef)).expect(200);
|
||||
expect(res.body.workOrders).toHaveLength(0); // pas « voir autre »
|
||||
|
||||
const parc = await http().get('/search?q=A1').set(auth(youssef)).expect(200);
|
||||
expect(parc.body.assets.length).toBeGreaterThan(0); // ASSETS view
|
||||
const sites = await http().get('/search?q=atlas').set(auth(youssef)).expect(200);
|
||||
expect(sites.body.sites).toHaveLength(0); // pas de LOCATIONS view
|
||||
});
|
||||
|
||||
it('demandeur : 200 mais aucune famille (aucune permission view)', async () => {
|
||||
const res = await http().get('/search?q=atlas').set(auth(karim)).expect(200);
|
||||
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
|
||||
});
|
||||
|
||||
it('moins de 2 caractères : réponse vide, pas de requête inutile', async () => {
|
||||
const res = await http().get('/search?q=a').set(auth(salma)).expect(200);
|
||||
expect(res.body).toEqual({ workOrders: [], assets: [], sites: [] });
|
||||
});
|
||||
|
||||
it('sans jeton : 401 (API fermée par défaut)', async () => {
|
||||
await http().get('/search?q=atlas').expect(401);
|
||||
});
|
||||
|
||||
it('tiers : les syndics portent leurs sites (« Rattachements » de la maquette)', async () => {
|
||||
const { body } = await http().get('/partners').set(auth(yasmine)).expect(200);
|
||||
const atlas = body.partners.find(
|
||||
(p: { name: string }) => p.name === 'Atlas Property Management',
|
||||
);
|
||||
expect(atlas.siteNames).toEqual(['Tour Atlas']);
|
||||
const fournisseur = body.partners.find((p: { name: string }) => p.name === 'Lubmaroc');
|
||||
expect(fournisseur.siteNames).toEqual([]);
|
||||
});
|
||||
|
||||
it('analytics : période 3/6/12 mois, 12 par défaut, valeur inconnue repliée sur 12', async () => {
|
||||
const trois = await http().get('/analytics/summary?months=3').set(auth(yasmine)).expect(200);
|
||||
expect(trois.body.months).toBe(3);
|
||||
expect(trois.body.costsByMonth).toHaveLength(3);
|
||||
|
||||
const defaut = await http().get('/analytics/summary').set(auth(yasmine)).expect(200);
|
||||
expect(defaut.body.months).toBe(12);
|
||||
expect(defaut.body.costsByMonth).toHaveLength(12);
|
||||
|
||||
const inconnu = await http().get('/analytics/summary?months=7').set(auth(yasmine)).expect(200);
|
||||
expect(inconnu.body.months).toBe(12);
|
||||
});
|
||||
|
||||
it('sites : le rattachement n’accepte qu’un tiers Client / syndic', async () => {
|
||||
const { body: partners } = await http().get('/partners').set(auth(yasmine));
|
||||
const fournisseur = partners.partners.find((p: { kind: string }) => p.kind === 'SUPPLIER');
|
||||
const { body: locations } = await http().get('/locations').set(auth(yasmine));
|
||||
const site = locations.locations.find(
|
||||
(l: { parentId: string | null; name: string }) => !l.parentId && l.name === 'Anfa Place',
|
||||
);
|
||||
await http()
|
||||
.patch(`/locations/${site.id}`)
|
||||
.set(auth(yasmine))
|
||||
.send({ partnerId: fournisseur.id })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -254,10 +254,15 @@ export async function ouvrirDocument(id: string): Promise<void> {
|
||||
|
||||
// ————— 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) } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
22
apps/web/src/api/recherche.ts
Normal file
22
apps/web/src/api/recherche.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { SEARCH_MIN_CHARS } from '@siop/shared';
|
||||
import { api } from './client';
|
||||
|
||||
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||
if (res.data !== undefined) return res.data;
|
||||
throw new Error(`Recherche indisponible (${res.response.status})`);
|
||||
}
|
||||
|
||||
/** Recherche globale de la topbar (⌘K). L'API filtre chaque famille par la
|
||||
* matrice — le client n'a aucune logique de permission à porter. */
|
||||
export function useGlobalSearch(q: string) {
|
||||
const terme = q.trim();
|
||||
return useQuery({
|
||||
queryKey: ['search', terme],
|
||||
enabled: terme.length >= SEARCH_MIN_CHARS,
|
||||
staleTime: 30_000,
|
||||
placeholderData: (precedent) => precedent, // pas de clignotement en tapant
|
||||
queryFn: async () =>
|
||||
unwrap(await api.GET('/search', { params: { query: { q: terme } } })),
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CategoryUpdate,
|
||||
InvitationCreate,
|
||||
LocationCreate,
|
||||
LocationUpdate,
|
||||
TeamCreate,
|
||||
UserUpdate,
|
||||
} from '@siop/shared';
|
||||
@@ -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({
|
||||
|
||||
78
apps/web/src/api/schema.d.ts
vendored
78
apps/web/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
179
apps/web/src/components/recherche-globale.tsx
Normal file
179
apps/web/src/components/recherche-globale.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SEARCH_MIN_CHARS } from '@siop/shared';
|
||||
import { useGlobalSearch } from '@/api/recherche';
|
||||
import { StatutOT } from '@/components/chips-ot';
|
||||
import { IcoRecherche } from '@/components/nav-icons';
|
||||
|
||||
interface Resultat {
|
||||
cle: string;
|
||||
route: string;
|
||||
contenu: React.ReactNode;
|
||||
}
|
||||
|
||||
/** La recherche de la topbar, enfin active (recette R3) : ⌘K pour saisir,
|
||||
* résultats groupés (OT / ascenseurs / sites), déjà filtrés par la matrice
|
||||
* côté API — flèches + Entrée pour naviguer. */
|
||||
export function RechercheGlobale() {
|
||||
const navigate = useNavigate();
|
||||
const [saisie, setSaisie] = useState('');
|
||||
const [q, setQ] = useState('');
|
||||
const [ouverte, setOuverte] = useState(false);
|
||||
const [actif, setActif] = useState(0);
|
||||
const champ = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Débounce : on n'interroge l'API que 200 ms après la dernière frappe.
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQ(saisie), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [saisie]);
|
||||
|
||||
// ⌘K / Ctrl+K : focus depuis n'importe où.
|
||||
useEffect(() => {
|
||||
const surTouche = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
champ.current?.focus();
|
||||
champ.current?.select();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', surTouche);
|
||||
return () => window.removeEventListener('keydown', surTouche);
|
||||
}, []);
|
||||
|
||||
const { data } = useGlobalSearch(q);
|
||||
|
||||
const resultats = useMemo<Resultat[]>(() => {
|
||||
if (!data) return [];
|
||||
return [
|
||||
...data.workOrders.map((w) => ({
|
||||
cle: `ot-${w.id}`,
|
||||
route: `/ot/${w.id}`,
|
||||
contenu: (
|
||||
<>
|
||||
<span className="ref num">{w.reference}</span>
|
||||
<span className="titre">{w.title}</span>
|
||||
<span className="fin"><StatutOT statut={w.status} /></span>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
...data.assets.map((a) => ({
|
||||
cle: `asc-${a.id}`,
|
||||
route: `/ascenseurs/${a.id}`,
|
||||
contenu: (
|
||||
<>
|
||||
<span className="ref num">{a.reference}</span>
|
||||
<span className="titre">
|
||||
{a.brand}
|
||||
{a.model ? ` ${a.model}` : ''}
|
||||
</span>
|
||||
<span className="fin">{a.siteName}</span>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
...data.sites.map((s) => ({
|
||||
cle: `site-${s.id}`,
|
||||
route: `/sites/${s.id}`,
|
||||
contenu: (
|
||||
<>
|
||||
<span className="titre"><b>{s.name}</b></span>
|
||||
<span className="fin">{s.city ?? ''}</span>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
const groupes = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const g: { libelle: string; debut: number; nombre: number }[] = [];
|
||||
if (data.workOrders.length) g.push({ libelle: 'Ordres de travail', debut: 0, nombre: data.workOrders.length });
|
||||
if (data.assets.length) g.push({ libelle: 'Ascenseurs', debut: data.workOrders.length, nombre: data.assets.length });
|
||||
if (data.sites.length) {
|
||||
g.push({
|
||||
libelle: 'Sites',
|
||||
debut: data.workOrders.length + data.assets.length,
|
||||
nombre: data.sites.length,
|
||||
});
|
||||
}
|
||||
return g;
|
||||
}, [data]);
|
||||
|
||||
const visible = ouverte && q.trim().length >= SEARCH_MIN_CHARS;
|
||||
|
||||
const aller = (r: Resultat | undefined) => {
|
||||
if (!r) return;
|
||||
setOuverte(false);
|
||||
setSaisie('');
|
||||
navigate(r.route);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="recherche" data-active>
|
||||
<IcoRecherche />
|
||||
<input
|
||||
ref={champ}
|
||||
type="search"
|
||||
placeholder="Rechercher un OT, un ascenseur, un site…"
|
||||
aria-label="Recherche globale"
|
||||
value={saisie}
|
||||
onChange={(e) => {
|
||||
setSaisie(e.target.value);
|
||||
setOuverte(true);
|
||||
setActif(0);
|
||||
}}
|
||||
onFocus={() => setOuverte(true)}
|
||||
onBlur={() => setOuverte(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setOuverte(false);
|
||||
champ.current?.blur();
|
||||
}
|
||||
if (!visible || !resultats.length) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActif((a) => Math.min(a + 1, resultats.length - 1));
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActif((a) => Math.max(a - 1, 0));
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
aller(resultats[actif]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<kbd>⌘K</kbd>
|
||||
{visible ? (
|
||||
<div className="resultats" role="listbox" aria-label="Résultats">
|
||||
{groupes.map((g) => (
|
||||
<div key={g.libelle}>
|
||||
<div className="groupe-resultats">{g.libelle}</div>
|
||||
{resultats.slice(g.debut, g.debut + g.nombre).map((r, i) => (
|
||||
<button
|
||||
key={r.cle}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={g.debut + i === actif}
|
||||
className="resultat"
|
||||
data-actif={g.debut + i === actif || undefined}
|
||||
onMouseEnter={() => setActif(g.debut + i)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // avant le blur du champ
|
||||
aller(r);
|
||||
}}
|
||||
>
|
||||
{r.contenu}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{!resultats.length ? (
|
||||
<div className="groupe-resultats">Aucun résultat pour « {q.trim()} »</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { getToken } from '@/api/client';
|
||||
import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth';
|
||||
import { 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 }) {
|
||||
|
||||
<div className="contenu">
|
||||
<header className="topbar">
|
||||
<div className="recherche" aria-hidden="true">
|
||||
<IcoRecherche />
|
||||
Rechercher un OT, un ascenseur, un site… ⌘K
|
||||
</div>
|
||||
<RechercheGlobale />
|
||||
{urgences.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -10,6 +10,8 @@ const loc = (over: Partial<LocationDto>): LocationDto => ({
|
||||
city: null,
|
||||
guardianName: null,
|
||||
guardianPhone: null,
|
||||
partnerId: null,
|
||||
partnerName: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
assetCount: 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
import {
|
||||
DOCUMENT_KIND_LABELS,
|
||||
DOCUMENT_KINDS,
|
||||
@@ -17,13 +17,29 @@ import { Modale } from '@/components/ui/modale';
|
||||
export default function PageBibliotheque() {
|
||||
const { can } = usePermissions();
|
||||
const [kind, setKind] = useState<DocumentKind | ''>('');
|
||||
const { data: documents } = useDocuments(kind ? { kind } : {});
|
||||
const [assetId, setAssetId] = useState('');
|
||||
const { data: options } = useAssetOptions();
|
||||
const { data: documents } = useDocuments({
|
||||
...(kind ? { kind } : {}),
|
||||
...(assetId ? { assetId } : {}),
|
||||
});
|
||||
const suppression = useDeleteDocument();
|
||||
const [modale, setModale] = useState(false);
|
||||
const [fichierDepose, setFichierDepose] = useState<File | null>(null);
|
||||
const [survol, setSurvol] = useState(false);
|
||||
|
||||
const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0);
|
||||
const peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit');
|
||||
|
||||
const surDepot = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setSurvol(false);
|
||||
const fichier = e.dataTransfer.files?.[0];
|
||||
if (!fichier) return;
|
||||
setFichierDepose(fichier);
|
||||
setModale(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
@@ -48,7 +64,35 @@ export default function PageBibliotheque() {
|
||||
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filtrer par appareil"
|
||||
value={assetId}
|
||||
onChange={(e) => setAssetId(e.target.value)}
|
||||
>
|
||||
<option value="">Rattaché à : tout le parc</option>
|
||||
{(options ?? []).map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.reference} — {a.siteName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{peutEditer ? (
|
||||
<button
|
||||
type="button"
|
||||
className="zone-depot"
|
||||
data-survol={survol || undefined}
|
||||
onClick={() => setModale(true)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setSurvol(true);
|
||||
}}
|
||||
onDragLeave={() => setSurvol(false)}
|
||||
onDrop={surDepot}
|
||||
>
|
||||
📂 Glissez un fichier ici — PDF, JPG, PNG · 20 Mo max · rattachez-le à un appareil ou un OT
|
||||
</button>
|
||||
) : null}
|
||||
{documents?.length ? (
|
||||
<div className="docs">
|
||||
{documents.map((d) => (
|
||||
@@ -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.
|
||||
</div>
|
||||
{peutEditer ? <ModaleTeleversement ouverte={modale} surFermeture={setModale} /> : null}
|
||||
{peutEditer ? (
|
||||
<ModaleTeleversement
|
||||
ouverte={modale}
|
||||
fichierInitial={fichierDepose}
|
||||
surFermeture={(o) => {
|
||||
setModale(o);
|
||||
if (!o) setFichierDepose(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleTeleversement({
|
||||
ouverte,
|
||||
fichierInitial,
|
||||
surFermeture,
|
||||
}: {
|
||||
ouverte: boolean;
|
||||
fichierInitial?: File | null;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const { data: options } = useAssetOptions();
|
||||
@@ -85,6 +140,11 @@ function ModaleTeleversement({
|
||||
const [assetId, setAssetId] = useState('');
|
||||
const [fichier, setFichier] = useState<File | null>(null);
|
||||
|
||||
// Fichier arrivé par glisser-déposer : la modale s'ouvre déjà remplie.
|
||||
useEffect(() => {
|
||||
if (fichierInitial) setFichier(fichierInitial);
|
||||
}, [fichierInitial]);
|
||||
|
||||
const envoyer = () => {
|
||||
if (!fichier || !assetId) return;
|
||||
if (fichier.size > DOCUMENT_MAX_BYTES) {
|
||||
|
||||
@@ -52,7 +52,15 @@ export default function PageFichePiece() {
|
||||
{piece.lastUnitPrice != null ? `${fmt.format(piece.lastUnitPrice)} MAD` : 'aucun prix connu'}
|
||||
</dd>
|
||||
<dt>Fournisseur</dt>
|
||||
<dd>{piece.supplierName ?? '—'}</dd>
|
||||
<dd>
|
||||
{piece.supplierName ? (
|
||||
<Link to="/tiers" style={{ color: 'var(--primaire)', fontWeight: 600 }}>
|
||||
{piece.supplierName}
|
||||
</Link>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</dd>
|
||||
<dt>Compatible</dt>
|
||||
<dd>{piece.compatible ?? '—'}</dd>
|
||||
</dl>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAssets, useCreateLocation, useLocations } from '@/api/referentiel';
|
||||
import { usePartners } from '@/api/gestion';
|
||||
import { useAssets, useCreateLocation, useLocations, useUpdateLocation } from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { CarteGeo } from '@/components/carte-geo';
|
||||
import { StatutEquipement } from '@/components/statut-equipement';
|
||||
@@ -56,6 +57,14 @@ export default function PageFicheSite() {
|
||||
<span className="num"> · {site.guardianPhone}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
<dt>Client / syndic</dt>
|
||||
<dd>
|
||||
<ChampSyndic
|
||||
siteId={site.id}
|
||||
partnerId={site.partnerId}
|
||||
partnerName={site.partnerName}
|
||||
/>
|
||||
</dd>
|
||||
<dt>Appareils</dt>
|
||||
<dd className="num">{site.assetCount}</dd>
|
||||
</dl>
|
||||
@@ -121,6 +130,40 @@ export default function PageFicheSite() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Rattachement du site à son client/syndic (colonne « Rattachements » des
|
||||
* Tiers). Éditable si LOCATIONS.edit ET si la matrice donne accès aux tiers
|
||||
* (la liste des tiers est sous PURCHASE_ORDERS.view) — sinon, lecture seule. */
|
||||
function ChampSyndic({
|
||||
siteId,
|
||||
partnerId,
|
||||
partnerName,
|
||||
}: {
|
||||
siteId: string;
|
||||
partnerId: string | null;
|
||||
partnerName: string | null;
|
||||
}) {
|
||||
const { can } = usePermissions();
|
||||
const editable = can('LOCATIONS', 'edit') && can('PURCHASE_ORDERS', 'view');
|
||||
const { data: partners } = usePartners(editable);
|
||||
const maj = useUpdateLocation(siteId);
|
||||
|
||||
if (!editable) return <>{partnerName ?? '—'}</>;
|
||||
const clients = (partners ?? []).filter((p) => p.kind === 'CLIENT' && p.isActive);
|
||||
return (
|
||||
<select
|
||||
aria-label="Client / syndic du site"
|
||||
value={partnerId ?? ''}
|
||||
onChange={(e) => maj.mutate({ partnerId: e.target.value || null })}
|
||||
disabled={maj.isPending}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function NouvelleZone({
|
||||
siteId,
|
||||
ouverte,
|
||||
|
||||
@@ -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<AnalyticsPeriod>(12);
|
||||
const { data } = useAnalyticsSummary(periode);
|
||||
if (!data) return null;
|
||||
|
||||
const delta =
|
||||
@@ -30,7 +33,17 @@ export default function PageStatistiques() {
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Statistiques</h1>
|
||||
<span className="filajout">12 derniers mois</span>
|
||||
<div className="actions">
|
||||
<select
|
||||
aria-label="Période"
|
||||
value={periode}
|
||||
onChange={(e) => setPeriode(Number(e.target.value) as AnalyticsPeriod)}
|
||||
>
|
||||
{ANALYTICS_PERIODS.map((m) => (
|
||||
<option key={m} value={m}>Période : {m} derniers mois</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||
<div className="kpi">
|
||||
@@ -50,9 +63,9 @@ export default function PageStatistiques() {
|
||||
)}
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="lib">OT clôturés · 12 mois</div>
|
||||
<div className="val num">{fmt.format(data.closed12m.total)}</div>
|
||||
<div className="delta neutre">dont {fmt.format(data.closed12m.preventive)} préventifs</div>
|
||||
<div className="lib">OT clôturés · {data.months} mois</div>
|
||||
<div className="val num">{fmt.format(data.closed.total)}</div>
|
||||
<div className="delta neutre">dont {fmt.format(data.closed.preventive)} préventifs</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="lib">Taux de préventif</div>
|
||||
@@ -66,13 +79,13 @@ export default function PageStatistiques() {
|
||||
<div className="val num">
|
||||
{data.avgResolutionDays != null ? `${fmtCompact.format(data.avgResolutionDays)} j` : '—'}
|
||||
</div>
|
||||
<div className="delta neutre">dépannages, 12 mois</div>
|
||||
<div className="delta neutre">dépannages, {data.months} mois</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grille-2">
|
||||
<div className="carte graf">
|
||||
<h2>
|
||||
Pannes par organe · 12 mois{' '}
|
||||
Pannes par organe · {data.months} mois{' '}
|
||||
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||
(bilans codés)
|
||||
</span>
|
||||
@@ -119,7 +132,7 @@ export default function PageStatistiques() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="carte">
|
||||
<h2>Top équipements en coût · 12 mois</h2>
|
||||
<h2>Top équipements en coût · {data.months} mois</h2>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
@@ -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') ? (
|
||||
<Link to="/achats"><Button>Bons de commande</Button></Link>
|
||||
) : null}
|
||||
{can('PARTS', 'edit') ? (
|
||||
<Button onClick={() => setModale('entree')}>Entrée de stock</Button>
|
||||
) : null}
|
||||
{can('PARTS', 'create') ? (
|
||||
<Button variant="prim" onClick={() => setModale(true)}>
|
||||
<Button variant="prim" onClick={() => setModale('piece')}>
|
||||
+ Nouvelle pièce
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -53,6 +65,16 @@ export default function PageStock() {
|
||||
<option value="">Alerte : toutes</option>
|
||||
<option value="seuil">Sous le seuil</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filtrer par fournisseur"
|
||||
value={fournisseur}
|
||||
onChange={(e) => setFournisseur(e.target.value)}
|
||||
>
|
||||
<option value="">Fournisseur : tous</option>
|
||||
{fournisseurs.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="carte" style={{ padding: 0 }}>
|
||||
<div className="table">
|
||||
@@ -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). <b>Aucune saisie directe de quantité.</b>
|
||||
</div>
|
||||
{can('PARTS', 'create') ? <NouvellePiece ouverte={modale} surFermeture={setModale} /> : null}
|
||||
{can('PARTS', 'create') ? (
|
||||
<NouvellePiece ouverte={modale === 'piece'} surFermeture={() => setModale(null)} />
|
||||
) : null}
|
||||
{can('PARTS', 'edit') && modale === 'entree' ? (
|
||||
<EntreeDeStock surFermeture={() => setModale(null)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Entrée de stock depuis la liste (maquette) : on choisit la pièce ici —
|
||||
* même mouvement tracé que depuis la fiche. */
|
||||
function EntreeDeStock({ surFermeture }: { surFermeture: () => void }) {
|
||||
const { data: parts } = useParts();
|
||||
const [pieceId, setPieceId] = useState('');
|
||||
const mouvement = useAddStockMovement(pieceId);
|
||||
const actives = (parts ?? []).filter((p) => p.isActive);
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!pieceId) return;
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
mouvement.mutate(
|
||||
{
|
||||
kind: 'ENTRY',
|
||||
quantity: Number(donnees.get('quantity')),
|
||||
reason: String(donnees.get('reason') ?? '').trim() || undefined,
|
||||
},
|
||||
{ onSuccess: surFermeture },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre="Entrée de stock" ouverte surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="es-piece">Pièce *</label>
|
||||
<select id="es-piece" required value={pieceId} onChange={(e) => setPieceId(e.target.value)}>
|
||||
<option value="">Sélectionner…</option>
|
||||
{actives.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.reference} — {p.designation} (stock : {p.stock})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="es-qte">Quantité *</label>
|
||||
<input id="es-qte" name="quantity" type="number" required min={1} placeholder="5" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="es-motif">Motif (optionnel)</label>
|
||||
<input id="es-motif" name="reason" placeholder="Ex. : reprise de stock atelier" />
|
||||
</div>
|
||||
{mouvement.isError ? <p className="erreur-form">{mouvement.error.message}</p> : null}
|
||||
<div className="pied">
|
||||
<Button onClick={surFermeture}>Annuler</Button>
|
||||
<Button type="submit" variant="prim" disabled={!pieceId || mouvement.isPending}>
|
||||
Tracer le mouvement
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
|
||||
function NouvellePiece({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
|
||||
@@ -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 <AccueilSansExploitation />;
|
||||
|
||||
@@ -49,6 +49,8 @@ export default function PageTiers() {
|
||||
? p.openOrders
|
||||
? `${p.openOrders} BC en cours`
|
||||
: '—'
|
||||
: p.siteNames.length
|
||||
? p.siteNames.join(' · ')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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<string, 'file' | 'string'>;
|
||||
/** 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) —————
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -38,16 +38,20 @@ export type DocumentsResponse = z.infer<typeof DocumentsResponseSchema>;
|
||||
|
||||
// ————— Analytics (écran Statistiques, maquette R3) —————
|
||||
|
||||
export const ANALYTICS_PERIODS = [3, 6, 12] as const;
|
||||
export type AnalyticsPeriod = (typeof ANALYTICS_PERIODS)[number];
|
||||
|
||||
export const AnalyticsSummarySchema = z.object({
|
||||
months: z.number().int(), // période appliquée (3, 6 ou 12 mois)
|
||||
monthCost: z.number(), // MAD, mois courant (pièces + main-d'œuvre)
|
||||
previousMonthCost: z.number(),
|
||||
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(),
|
||||
|
||||
@@ -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<typeof PartnerSchema>;
|
||||
|
||||
|
||||
@@ -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<typeof LocationSchema>;
|
||||
@@ -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<typeof LocationCreateSchema>;
|
||||
|
||||
|
||||
37
packages/shared/src/schemas/search.ts
Normal file
37
packages/shared/src/schemas/search.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
import { WORK_ORDER_STATUSES } from '../exploitation';
|
||||
|
||||
/** Recherche globale (topbar ⌘K) — chaque famille de résultats n'apparaît
|
||||
* que si l'appelant a la permission `view` du domaine ; les OT respectent
|
||||
* en plus l'invariant « voir autre ». */
|
||||
|
||||
export const SEARCH_MIN_CHARS = 2;
|
||||
export const SEARCH_MAX_PER_KIND = 5;
|
||||
|
||||
export const SearchResponseSchema = z.object({
|
||||
workOrders: z.array(
|
||||
z.object({
|
||||
id: z.uuid(),
|
||||
reference: z.string(),
|
||||
title: z.string(),
|
||||
status: z.enum(WORK_ORDER_STATUSES),
|
||||
}),
|
||||
),
|
||||
assets: z.array(
|
||||
z.object({
|
||||
id: z.uuid(),
|
||||
reference: z.string(),
|
||||
brand: z.string(),
|
||||
model: z.string().nullable(),
|
||||
siteName: z.string(),
|
||||
}),
|
||||
),
|
||||
sites: z.array(
|
||||
z.object({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
city: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
export type SearchResponse = z.infer<typeof SearchResponseSchema>;
|
||||
Reference in New Issue
Block a user