From 6a2dae1710e7109874c3ae9ada7d568b8e589bfb Mon Sep 17 00:00:00 2001 From: pr-daaif Date: Thu, 16 Jul 2026 17:13:20 +0100 Subject: [PATCH] =?UTF-8?q?feat(r2.3):=20=C3=A9crans=20web=20exploitation?= =?UTF-8?q?=20=E2=80=94=20OT,=20demandes,=20pr=C3=A9ventif,=20compteurs,?= =?UTF-8?q?=20urgence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - liste OT (filtres, strie rouge, « immédiat ») ; fiche OT : transitions pilotées par allowedTransitions, clôture grisée avec la garde expliquée, bilan codé (6 selects sur référentiels), checklist Fait→N-A→à faire, activité + commentaires, assignation, annulation motivée - nouvel OT : interrupteur « personne bloquée » qui force la priorité - demandes : table + panneau d'approbation (priorité, assignation, approuver → fiche OT), rejet en modale à motif obligatoire, signalement interne ; statut « Résolue » dérivé de l'OT lié - préventif : tuiles réelles, générer + résumé (« regénérer ne double rien »), gabarits administrables ; compteurs : saisie + historique - tableau de bord réel : bandeau urgence cliquable, KPIs, OT par statut, interventions récentes ; accueil dédié aux rôles sans exploitation - urgence traversante : chip topbar pulsante (60 s), badges de nav - GET /assets/options (auth seule) : le Demandeur peut désigner l'appareil qu'il signale — trou débusqué par l'e2e (49 opérations au contrat) - génération préventive durcie : collision de référence RETENTÉE (plus de saut silencieux), P2003 toléré ; 3 runs Jest complets consécutifs verts - 9 tests Playwright (recette R2 officielle rejouée intégralement), 50 tests API (94,6 % / 78,9 %) Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 3 +- apps/api/prisma/cleanup-e2e.ts | 7 + apps/api/src/assets/assets.controller.ts | 7 + apps/api/src/assets/assets.service.ts | 16 + apps/api/src/preventive/preventive.service.ts | 81 ++-- apps/api/test/preventif.e2e-spec.ts | 39 +- apps/web/e2e/parcours-r2.spec.ts | 117 ++++++ apps/web/src/App.tsx | 12 + apps/web/src/api/exploitation.ts | 235 ++++++++++++ apps/web/src/api/referentiel.ts | 8 + apps/web/src/api/schema.d.ts | 45 +++ apps/web/src/components/chips-ot.tsx | 66 ++++ apps/web/src/layout/coquille.tsx | 59 ++- apps/web/src/pages/compteurs.tsx | 89 +++++ apps/web/src/pages/demandes.tsx | 301 +++++++++++++++ apps/web/src/pages/fiche-ascenseur.tsx | 43 ++- apps/web/src/pages/fiche-ot.tsx | 354 ++++++++++++++++++ apps/web/src/pages/nouvel-ot.tsx | 135 +++++++ apps/web/src/pages/ordres-travail.tsx | 126 +++++++ apps/web/src/pages/preventif.tsx | 166 ++++++++ apps/web/src/pages/tableau-de-bord.tsx | 233 ++++++++++-- apps/web/src/styles/app.css | 123 ++++++ docs/journal/journal.md | 14 + docs/openapi.json | 61 +++ packages/shared/src/contract.ts | 15 + packages/shared/src/schemas/referentiel.ts | 12 + 26 files changed, 2274 insertions(+), 93 deletions(-) create mode 100644 apps/web/e2e/parcours-r2.spec.ts create mode 100644 apps/web/src/api/exploitation.ts create mode 100644 apps/web/src/components/chips-ot.tsx create mode 100644 apps/web/src/pages/compteurs.tsx create mode 100644 apps/web/src/pages/demandes.tsx create mode 100644 apps/web/src/pages/fiche-ot.tsx create mode 100644 apps/web/src/pages/nouvel-ot.tsx create mode 100644 apps/web/src/pages/ordres-travail.tsx create mode 100644 apps/web/src/pages/preventif.tsx diff --git a/CLAUDE.md b/CLAUDE.md index f2c46a1..23164bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,5 +47,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **R2 — maquettes validées** (16/07) : `maquette-r2.html` (demandes/approbation, nouvel OT, préventif, checklist, compteurs) + 4 décisions actées. - ✅ **R2.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demande→OT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %). - ✅ **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production. -- 🔄 **R2.3 — reprise ici** : écrans web exploitation fidèles aux maquettes (liste OT + filtres + strie urgence, fiche OT : transitions/activité/bilan codé/checklist, demandes + panneau d'approbation, nouvel OT + interrupteur personne bloquée, préventif + bouton générer, compteurs en fiche appareil, chip urgence topbar + bandeau tableau de bord) → e2e Playwright recette R2 → R2.4 portail public QR → recette + déploiement + tag. +- ✅ **R2.3 — écrans web exploitation** : liste/fiche OT (transitions via `allowedTransitions`, garde visible, bilan codé, checklist cliquable, activité), demandes+approbation/rejet motivé, nouvel OT (interrupteur urgence), préventif (tuiles+générer+gabarits), compteurs, tableau de bord réel, urgence traversante (chip topbar, badges, bandeau) ; `GET /assets/options` (trou Demandeur corrigé) ; retry sur collision de référence dans la génération ; 9 Playwright verts (recette R2 complète), 50 tests API. +- 🔄 **R2.4 — reprise ici** : portail public QR — route publique `/q/{ref}` → formulaire de signalement prérempli SANS compte (écran validé R0 : gros toggle personne bloquée, description, photo facultative → R3 pour l'upload), suivi des signalements du gardien sans jargon (Reçu → Intervention → Résolu), rate-limiting/abus à considérer → puis recette R2, déploiement, tag `release/r2`. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5. diff --git a/apps/api/prisma/cleanup-e2e.ts b/apps/api/prisma/cleanup-e2e.ts index a495d21..12e6770 100644 --- a/apps/api/prisma/cleanup-e2e.ts +++ b/apps/api/prisma/cleanup-e2e.ts @@ -6,6 +6,13 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); async function main() { + await prisma.request.deleteMany({ where: { description: { contains: 'E2E' } } }); + await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } }); + await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } }); + // Un appareil de test peut avoir reçu grilles/demandes (génération du mois) : + // on purge ses dépendances avant lui (FK RESTRICT). + await prisma.request.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } }); + await prisma.workOrder.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } }); await prisma.asset.deleteMany({ where: { reference: { startsWith: 'E2E-' } } }); await prisma.location.deleteMany({ where: { name: { contains: 'E2E' }, parentId: { not: null } } }); await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } }); diff --git a/apps/api/src/assets/assets.controller.ts b/apps/api/src/assets/assets.controller.ts index 99c9aa2..8b05f0a 100644 --- a/apps/api/src/assets/assets.controller.ts +++ b/apps/api/src/assets/assets.controller.ts @@ -31,6 +31,13 @@ export class AssetsController { return this.assetsService.list(); } + /** Avant ':id' (ordre des routes) — authentification seule : le demandeur + * doit pouvoir désigner l'appareil qu'il signale. */ + @Get('options') + options() { + return this.assetsService.options(); + } + @Get(':id') @RequirePermission('ASSETS', 'view') get(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/api/src/assets/assets.service.ts b/apps/api/src/assets/assets.service.ts index 813c93f..33cdec0 100644 --- a/apps/api/src/assets/assets.service.ts +++ b/apps/api/src/assets/assets.service.ts @@ -11,6 +11,7 @@ import type { AssetCreate, AssetDetail, AssetDto, + AssetOptionsResponse, AssetsResponse, AssetUpdate, } from '@siop/shared'; @@ -28,6 +29,21 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>; export class AssetsService { constructor(private readonly prisma: PrismaService) {} + /** Options minimales pour le signalement — ouvert à tout rôle authentifié. */ + async options(): Promise { + const rows = await this.prisma.asset.findMany({ + include: { location: { include: { parent: true } } }, + orderBy: { reference: 'asc' }, + }); + return { + options: rows.map((r) => ({ + id: r.id, + reference: r.reference, + siteName: r.location.parent?.name ?? r.location.name, + })), + }; + } + async list(): Promise { const rows = await this.prisma.asset.findMany({ include: assetInclude, diff --git a/apps/api/src/preventive/preventive.service.ts b/apps/api/src/preventive/preventive.service.ts index 03d9f26..e3c5bf6 100644 --- a/apps/api/src/preventive/preventive.service.ts +++ b/apps/api/src/preventive/preventive.service.ts @@ -129,44 +129,57 @@ export class PreventiveService { skipped++; continue; } - try { - const ot = await this.prisma.workOrder.create({ - data: { - reference: await this.nextReference(), - title: premierControle - ? `Premier contrôle — ${libelleMois}` - : `Grille du mois — ${libelleMois}`, - type: 'PREVENTIVE', - priority: 'LOW', - assetId: asset.id, - periodKey: period, - dueDate: dernierJour, - createdById: user.userId, - checklist: { - create: dues.map((t) => ({ label: t.label, templateId: t.id })), - }, - events: { - create: { - kind: 'GENERATED', - message: `Généré (${premierControle ? 'premier contrôle' : 'grille mensuelle'} · ${dues.length} tâches)`, - byId: user.userId, + let issue: 'créé' | 'sauté' | null = null; + for (let essai = 0; essai < 4 && issue === null; essai++) { + try { + const ot = await this.prisma.workOrder.create({ + data: { + reference: await this.nextReference(), + title: premierControle + ? `Premier contrôle — ${libelleMois}` + : `Grille du mois — ${libelleMois}`, + type: 'PREVENTIVE', + priority: 'LOW', + assetId: asset.id, + periodKey: period, + dueDate: dernierJour, + createdById: user.userId, + checklist: { + create: dues.map((t) => ({ label: t.label, templateId: t.id })), + }, + events: { + create: { + kind: 'GENERATED', + message: `Généré (${premierControle ? 'premier contrôle' : 'grille mensuelle'} · ${dues.length} tâches)`, + byId: user.userId, + }, }, }, - }, - select: { reference: true }, - }); - created++; - if (premierControle) firstControls++; - references.push(ot.reference); - } catch (e) { - // [assetId, periodKey] déjà pris → grille déjà en place : idempotence. - // (Collision de référence, rarissime : le prochain appel rattrape.) - if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { - skipped++; - continue; + select: { reference: true }, + }); + created++; + if (premierControle) firstControls++; + references.push(ot.reference); + issue = 'créé'; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + const cible = (e.meta?.target as string[] | string | undefined) ?? ''; + if (String(cible).includes('periodKey')) { + issue = 'sauté'; // grille déjà en place : idempotence + } + // sinon : collision de référence (course avec une création d'OT) + // → on recalcule et on retente + } else if ( + e instanceof Prisma.PrismaClientKnownRequestError && + e.code === 'P2003' + ) { + issue = 'sauté'; // appareil supprimé entre lecture et écriture + } else { + throw e; + } } - throw e; } + if (issue !== 'créé') skipped++; } this.logger.log( `Préventif ${period} : ${created} générés, ${skipped} déjà en place, ${firstControls} premiers contrôles.`, diff --git a/apps/api/test/preventif.e2e-spec.ts b/apps/api/test/preventif.e2e-spec.ts index d61478e..92c6c29 100644 --- a/apps/api/test/preventif.e2e-spec.ts +++ b/apps/api/test/preventif.e2e-spec.ts @@ -80,18 +80,37 @@ describe('Préventif & compteurs (e2e)', () => { where: { isActive: true, periodMonths: 1 }, }); - // B2 n'avait aucun OT préventif avant 2031-01 → premier contrôle - const b2 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'B2' } }); - const grilleB2 = await prisma.workOrder.findUniqueOrThrow({ - where: { assetId_periodKey: { assetId: b2.id, periodKey: '2031-01' } }, - include: { _count: { select: { checklist: true } } }, + // Un appareil VIERGE créé par ce test (aucune génération n'a pu le toucher) + const categorie = await prisma.category.findFirstOrThrow({ where: { kind: 'EQUIPMENT' } }); + const site = await prisma.location.findFirstOrThrow({ where: { parentId: null } }); + const vierge = await prisma.asset.create({ + data: { + reference: `PVT-${Date.now().toString(36)}`, + brand: 'Test', + categoryId: categorie.id, + locationId: site.id, + }, }); - expect(grilleB2.title).toContain('Premier contrôle'); - expect(grilleB2._count.checklist).toBe(templatesActifs); + try { + await http() + .post('/preventive/generate') + .set(auth(admin)) + .send({ month: '2031-09' }) + .expect(200); + const grille = await prisma.workOrder.findUniqueOrThrow({ + where: { assetId_periodKey: { assetId: vierge.id, periodKey: '2031-09' } }, + include: { _count: { select: { checklist: true } } }, + }); + expect(grille.title).toContain('Premier contrôle'); + expect(grille._count.checklist).toBe(templatesActifs); + } finally { + await prisma.workOrder.deleteMany({ where: { assetId: vierge.id } }); + await prisma.asset.delete({ where: { id: vierge.id } }); + } - // A1 a un historique préventif (seed juillet) → grille normale. - // Mise en service mars 2020 : janvier 2031 n'est le mois ni du 12, ni du 3, - // ni du 6 mois (écart 130) → seules les tâches MENSUELLES sont dues. + // A1 a toujours un historique préventif (seed) → grille normale. + // Mise en service mars 2020 : janvier 2031 (écart 130 mois) n'est le mois + // ni du 3, ni du 6, ni du 12 → seules les tâches MENSUELLES sont dues. const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } }); const grilleA1 = await prisma.workOrder.findUniqueOrThrow({ where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-01' } }, diff --git a/apps/web/e2e/parcours-r2.spec.ts b/apps/web/e2e/parcours-r2.spec.ts new file mode 100644 index 0000000..8d4f72b --- /dev/null +++ b/apps/web/e2e/parcours-r2.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from '@playwright/test'; + +/** + * Recette R2 (plan de releases) : demande gardien → approbation → OT → + * intervention → bilan codé → clôture → suivi demandeur ; génération du + * préventif du mois (idempotente) ; compteur croissant. + */ + +const suffix = Date.now().toString(36); + +test('recette R2 : demande → OT → bilan codé → clôture → suivi demandeur', async ({ + page, +}) => { + // 1 · Karim (demandeur) signale depuis l'app + await page.goto('/connexion'); + await page.getByRole('button', { name: /Karim Doukkali/ }).click(); + await expect(page.getByRole('heading', { name: 'Mes signalements' })).toBeVisible(); + await page.getByRole('link', { name: /Demandes/ }).click(); + await page.getByRole('button', { name: '+ Signaler' }).click(); + await page.getByLabel('Équipement *').selectOption({ index: 1 }); + await page.getByLabel('Décrivez le problème *').fill(`E2E ${suffix} — porte qui claque très fort`); + await page.getByRole('button', { name: 'Envoyer le signalement' }).click(); + await expect(page.getByRole('cell', { name: new RegExp(suffix) })).toBeVisible(); + + // 2 · Salma (dispatcher) approuve → OT créé, assigné à Ahmed + await page.locator('.compte').click(); + await page.getByRole('menuitem', { name: /Salma Idrissi/ }).click(); + await page.getByRole('link', { name: /Demandes/ }).click(); + await page.getByRole('cell', { name: new RegExp(suffix) }).click(); + await page.getByLabel('Assigner à').selectOption({ index: 1 }); // Ahmed Benali + await page.getByRole('button', { name: "Approuver → créer l'OT" }).click(); + + // → fiche OT + await expect(page.getByRole('heading', { name: new RegExp(suffix) })).toBeVisible(); + await expect(page.locator('.entete-page .st')).toHaveText('Ouvert'); + + // 3 · Démarrer, tenter de clôturer (bloqué : bilan), remplir le bilan, clôturer + await page.getByRole('button', { name: 'Démarrer' }).click(); + await expect(page.locator('.entete-page .st')).toHaveText('En cours'); + const boutonCloture = page.getByRole('button', { name: "Clôturer l'intervention" }); + await expect(boutonCloture).toBeDisabled(); // garde visible dans l'UI + await expect(page.locator('.avertissement')).toContainText('bilan incomplet'); + + await page.getByLabel(/État des portes/).selectOption({ label: 'Fonctionnement normal' }); + await page.getByLabel(/Action réalisée/).selectOption({ label: 'Réglage' }); + await page.getByLabel(/Élément concerné/).selectOption({ label: 'Portes' }); + await expect(boutonCloture).toBeEnabled(); + await boutonCloture.click(); + await expect(page.locator('.entete-page .st')).toHaveText('Terminé'); + + // 4 · Karim suit : sa demande est Résolue + await page.locator('.compte').click(); + await page.getByRole('menuitem', { name: /Karim Doukkali/ }).click(); + await page.getByRole('link', { name: /Demandes/ }).click(); + const ligne = page.locator('tr', { hasText: suffix }); + await expect(ligne.locator('.st')).toHaveText('Résolue'); +}); + +test('préventif : générer le mois — puis regénérer ne double rien', async ({ page }) => { + await page.goto('/connexion'); + await page.getByRole('button', { name: /Yasmine Alaoui/ }).click(); + await page.getByRole('link', { name: 'Préventif' }).click(); + await expect(page.getByRole('heading', { name: 'Préventif' })).toBeVisible(); + await expect(page.getByText('Essai du parachute')).toBeVisible(); + + await page.getByRole('button', { name: /Générer la grille de/ }).click(); + await expect(page.locator('.gen-info')).toContainText('regénérer ne double rien'); + // Second appel : 0 créé + await page.getByRole('button', { name: /Générer la grille de/ }).click(); + await expect(page.locator('.gen-info')).toContainText('0 OT créés'); +}); + +test('urgence « personne bloquée » : interrupteur → chip topbar + bandeau + tête de liste', async ({ + page, +}) => { + await page.goto('/connexion'); + await page.getByRole('button', { name: /Salma Idrissi/ }).click(); + await page.getByRole('link', { name: /Ordres de travail/ }).click(); + await page.getByRole('button', { name: '+ Nouvel OT' }).click(); + await page.getByLabel('Objet *').fill(`E2E ${suffix} — personne bloquée cage B`); + await page.getByLabel('Équipement *').selectOption({ index: 1 }); + await page.getByRole('switch', { name: 'Personne bloquée' }).click(); + await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click(); + await expect(page.getByRole('heading', { name: new RegExp(suffix) })).toBeVisible(); + + // chip topbar (pulse) présent et bandeau sur le tableau de bord + await expect(page.locator('.topbar .chip-urgence')).toContainText('personne'); + await page.getByRole('link', { name: 'Tableau de bord' }).click(); + await expect(page.locator('.bandeau-urgence').first()).toBeVisible(); + // tête de liste OT + await page.getByRole('link', { name: /Ordres de travail/ }).click(); + await expect(page.locator('tbody tr').first()).toHaveClass(/ligne-bloque/); + + // nettoyage : annuler l'OT d'urgence créé (motif via prompt) + await page.locator('tbody tr').first().click(); + page.once('dialog', (d) => d.accept('Test e2e — nettoyage')); + await page.getByRole('button', { name: 'Annuler', exact: true }).click(); + await expect(page.locator('.entete-page .st')).toHaveText('Annulé'); +}); + +test('compteurs : relevé croissant accepté, régression refusée', async ({ page }) => { + await page.goto('/connexion'); + await page.getByRole('button', { name: /Ahmed Benali/ }).click(); + await page.getByRole('link', { name: 'Ascenseurs' }).click(); + await page.getByRole('cell', { name: 'A1', exact: true }).click(); + await page.getByRole('button', { name: 'Compteurs' }).click(); + await expect(page.getByRole('heading', { name: /Compteurs — Ascenseur A1/ })).toBeVisible(); + + const saisie = page.getByLabel('Nouveau relevé — Heures de marche'); + await saisie.fill('99999999'); + await page.getByRole('button', { name: 'Enregistrer le relevé' }).first().click(); + await expect(page.locator('.chrono').first()).toContainText('99 999 999'); + + await saisie.fill('12'); + await page.getByRole('button', { name: 'Enregistrer le relevé' }).first().click(); + await expect(page.locator('.erreur-form')).toContainText('ne redescend pas'); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 03bc9bd..08841a6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -5,13 +5,19 @@ import { Coquille } from '@/layout/coquille'; import PageActivation from '@/pages/activation'; import PageAscenseurs from '@/pages/ascenseurs'; import PageCategories from '@/pages/categories'; +import PageCompteurs from '@/pages/compteurs'; import PageConnexion from '@/pages/connexion'; +import PageDemandes from '@/pages/demandes'; import PageDesign from '@/pages/design'; import PageEtiquette from '@/pages/etiquette'; import PageFicheAscenseur from '@/pages/fiche-ascenseur'; +import PageFicheOT from '@/pages/fiche-ot'; import PageFicheSite from '@/pages/fiche-site'; import PageNouvelAscenseur from '@/pages/nouvel-ascenseur'; +import PageNouvelOT from '@/pages/nouvel-ot'; +import PageOrdresTravail from '@/pages/ordres-travail'; import PagePersonnes from '@/pages/personnes'; +import PagePreventif from '@/pages/preventif'; import PageSites from '@/pages/sites'; import PageTableauDeBord from '@/pages/tableau-de-bord'; @@ -36,8 +42,14 @@ export default function App() { )} /> )} /> )} /> + )} /> )} /> )} /> + )} /> + )} /> + )} /> + )} /> + )} /> diff --git a/apps/web/src/api/exploitation.ts b/apps/web/src/api/exploitation.ts new file mode 100644 index 0000000..cebb013 --- /dev/null +++ b/apps/web/src/api/exploitation.ts @@ -0,0 +1,235 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + AssigneesUpdate, + ChecklistPatch, + MeterReadingCreate, + PreventiveGenerate, + ReportUpsert, + RequestApprove, + RequestCreate, + RequestReject, + TaskTemplateCreate, + TaskTemplateUpdate, + TransitionRequest, + WorkOrderCreate, +} from '@siop/shared'; +import { api } from './client'; + +/** Hooks R2 — client typé, invalidations ciblées. */ + +async function unwrap(res: { data?: T; error?: unknown; response: Response }): Promise { + if (res.error || res.data === undefined) { + const message = + (res.error as { message?: string } | undefined)?.message ?? + `Le serveur a répondu ${res.response.status}`; + throw new Error(Array.isArray(message) ? message.join(' — ') : message); + } + return res.data; +} + +function useInvalidate(...keys: string[]) { + const queryClient = useQueryClient(); + return () => + Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] }))); +} + +// ————— Ordres de travail ————— + +export function useWorkOrders(enabled = true) { + return useQuery({ + queryKey: ['work-orders'], + enabled, + queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders, + }); +} + +export function useWorkOrder(id: string | undefined) { + return useQuery({ + queryKey: ['work-orders', id], + enabled: !!id, + queryFn: async () => + unwrap(await api.GET('/work-orders/{id}', { params: { path: { id: id! } } })), + }); +} + +export function useCreateWorkOrder() { + const invalidate = useInvalidate('work-orders'); + return useMutation({ + mutationFn: async (body: WorkOrderCreate) => + unwrap(await api.POST('/work-orders', { body })), + onSuccess: invalidate, + }); +} + +export function useTransitionWorkOrder(id: string) { + const invalidate = useInvalidate('work-orders', 'requests', 'preventive-status'); + return useMutation({ + mutationFn: async (body: TransitionRequest) => + unwrap(await api.POST('/work-orders/{id}/transition', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} + +export function useCommentWorkOrder(id: string) { + const invalidate = useInvalidate('work-orders'); + return useMutation({ + mutationFn: async (message: string) => + unwrap( + await api.POST('/work-orders/{id}/comments', { + params: { path: { id } }, + body: { message }, + }), + ), + onSuccess: invalidate, + }); +} + +export function useSetAssignees(id: string) { + const invalidate = useInvalidate('work-orders'); + return useMutation({ + mutationFn: async (body: AssigneesUpdate) => + unwrap(await api.PUT('/work-orders/{id}/assignees', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} + +export function useUpsertReport(id: string) { + const invalidate = useInvalidate('work-orders'); + return useMutation({ + mutationFn: async (body: ReportUpsert) => + unwrap(await api.PUT('/work-orders/{id}/report', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} + +export function usePatchChecklist(id: string) { + const invalidate = useInvalidate('work-orders'); + return useMutation({ + mutationFn: async ({ itemId, ...body }: ChecklistPatch & { itemId: string }) => + unwrap( + await api.PATCH('/work-orders/{id}/checklist/{itemId}', { + params: { path: { id, itemId } }, + body, + }), + ), + onSuccess: invalidate, + }); +} + +// ————— Demandes ————— + +export function useRequests() { + return useQuery({ + queryKey: ['requests'], + queryFn: async () => (await unwrap(await api.GET('/requests'))).requests, + }); +} + +export function useCreateRequest() { + const invalidate = useInvalidate('requests'); + return useMutation({ + mutationFn: async (body: RequestCreate) => unwrap(await api.POST('/requests', { body })), + onSuccess: invalidate, + }); +} + +export function useApproveRequest() { + const invalidate = useInvalidate('requests', 'work-orders'); + return useMutation({ + mutationFn: async ({ id, ...body }: RequestApprove & { id: string }) => + unwrap(await api.POST('/requests/{id}/approve', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} + +export function useRejectRequest() { + const invalidate = useInvalidate('requests'); + return useMutation({ + mutationFn: async ({ id, ...body }: RequestReject & { id: string }) => + unwrap(await api.POST('/requests/{id}/reject', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} + +// ————— Référentiels du bilan ————— + +export function useReferenceValues() { + return useQuery({ + queryKey: ['reference-values'], + staleTime: 5 * 60_000, + queryFn: async () => + (await unwrap(await api.GET('/reference-values'))).referenceValues, + }); +} + +// ————— Préventif ————— + +export function useTaskTemplates() { + return useQuery({ + queryKey: ['task-templates'], + queryFn: async () => (await unwrap(await api.GET('/preventive/templates'))).templates, + }); +} + +export function useCreateTaskTemplate() { + const invalidate = useInvalidate('task-templates'); + return useMutation({ + mutationFn: async (body: TaskTemplateCreate) => + unwrap(await api.POST('/preventive/templates', { body })), + onSuccess: invalidate, + }); +} + +export function useUpdateTaskTemplate() { + const invalidate = useInvalidate('task-templates'); + return useMutation({ + mutationFn: async ({ id, ...body }: TaskTemplateUpdate & { id: string }) => + unwrap( + await api.PATCH('/preventive/templates/{id}', { params: { path: { id } }, body }), + ), + onSuccess: invalidate, + }); +} + +export function usePreventiveStatus(enabled = true) { + return useQuery({ + queryKey: ['preventive-status'], + enabled, + queryFn: async () => unwrap(await api.GET('/preventive/status')), + }); +} + +export function useGeneratePreventive() { + const invalidate = useInvalidate('preventive-status', 'work-orders'); + return useMutation({ + mutationFn: async (body: PreventiveGenerate) => + unwrap(await api.POST('/preventive/generate', { body })), + onSuccess: invalidate, + }); +} + +// ————— Compteurs ————— + +export function useMeters(assetId: string | undefined) { + return useQuery({ + queryKey: ['meters', assetId], + enabled: !!assetId, + queryFn: async () => + (await unwrap(await api.GET('/assets/{id}/meters', { params: { path: { id: assetId! } } }))) + .meters, + }); +} + +export function useAddMeterReading(assetId: string) { + const invalidate = useInvalidate('meters'); + return useMutation({ + mutationFn: async (body: MeterReadingCreate) => + unwrap( + await api.POST('/assets/{id}/meter-readings', { + params: { path: { id: assetId } }, + body, + }), + ), + onSuccess: invalidate, + }); +} diff --git a/apps/web/src/api/referentiel.ts b/apps/web/src/api/referentiel.ts index 8f8b001..edee536 100644 --- a/apps/web/src/api/referentiel.ts +++ b/apps/web/src/api/referentiel.ts @@ -38,6 +38,14 @@ export function useAssets() { }); } +/** Options minimales (signalement) — accessibles à tout rôle authentifié. */ +export function useAssetOptions() { + return useQuery({ + queryKey: ['asset-options'], + queryFn: async () => (await unwrap(await api.GET('/assets/options'))).options, + }); +} + export function useAsset(id: string | undefined) { return useQuery({ queryKey: ['assets', id], diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index 71c5300..e6cb608 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -183,6 +183,23 @@ export interface paths { patch?: never; trace?: never; }; + "/assets/options": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Options d’appareils pour le signalement (tout rôle authentifié) */ + get: operations["listAssetOptions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/assets/{id}": { parameters: { query?: never; @@ -851,6 +868,14 @@ export interface components { componentCount: number; }[]; }; + AssetOptionsResponse: { + options: { + /** Format: uuid */ + id: string; + reference: string; + siteName: string; + }[]; + }; AssetDetail: { /** Format: uuid */ id: string; @@ -1787,6 +1812,26 @@ export interface operations { }; }; }; + listAssetOptions: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Options */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssetOptionsResponse"]; + }; + }; + }; + }; getAsset: { parameters: { query?: never; diff --git a/apps/web/src/components/chips-ot.tsx b/apps/web/src/components/chips-ot.tsx new file mode 100644 index 0000000..5cbc90c --- /dev/null +++ b/apps/web/src/components/chips-ot.tsx @@ -0,0 +1,66 @@ +import { + REQUEST_STATUS_LABELS, + WORK_ORDER_PRIORITY_LABELS, + WORK_ORDER_STATUS_LABELS, + type RequestStatus, + type WorkOrderPriority, + type WorkOrderStatus, +} from '@siop/shared'; +import { cn } from '@/lib/cn'; + +const ST_CLASSES: Record = { + OPEN: 'ouvert', + IN_PROGRESS: 'encours', + ON_HOLD: 'attente', + DONE: 'termine', + CANCELLED: 'annule', +}; + +/** Statut OT — les couleurs réservées de la charte. */ +export function StatutOT({ statut }: { statut: WorkOrderStatus }) { + return ( + + {WORK_ORDER_STATUS_LABELS[statut]} + + ); +} + +const PRIO_CLASSES: Record = { + NONE: null, + LOW: 'basse', + MEDIUM: 'moyenne', + HIGH: 'haute', + PERSON_TRAPPED: 'bloque', +}; + +/** Priorité — pastille + libellé, jamais la couleur seule (charte). */ +export function PrioriteOT({ priorite }: { priorite: WorkOrderPriority }) { + const classe = PRIO_CLASSES[priorite]; + if (!classe) return ; + return ( + + + {WORK_ORDER_PRIORITY_LABELS[priorite]} + + ); +} + +const DEM_CLASSES: Record = { + RECEIVED: 'recue', + APPROVED: 'approuvee', + REJECTED: 'rejetee', +}; + +/** Statut de demande — « Résolue » quand l'OT lié est terminé. */ +export function StatutDemande({ + statut, + otStatut, +}: { + statut: RequestStatus; + otStatut?: WorkOrderStatus | null; +}) { + if (statut === 'APPROVED' && otStatut === 'DONE') { + return Résolue; + } + return {REQUEST_STATUS_LABELS[statut]}; +} diff --git a/apps/web/src/layout/coquille.tsx b/apps/web/src/layout/coquille.tsx index 0970964..8b4159c 100644 --- a/apps/web/src/layout/coquille.tsx +++ b/apps/web/src/layout/coquille.tsx @@ -6,7 +6,9 @@ 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 { useQuery } from '@tanstack/react-query'; import type { ObjectCategory, PermissionRight } from '@siop/shared'; +import { api } from '@/api/client'; import { usePermissions } from '@/auth/use-permissions'; import { IcoAscenseurs, @@ -40,6 +42,7 @@ interface LienNav { route?: string; release?: string; permission?: [ObjectCategory, PermissionRight]; + badge?: 'urgences' | 'demandes'; } const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [ @@ -47,9 +50,9 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [ groupe: 'Exploitation', liens: [ { libelle: 'Tableau de bord', icone: IcoTableauDeBord, route: '/' }, - { libelle: 'Ordres de travail', icone: IcoOrdresDeTravail, release: 'R2' }, - { libelle: 'Demandes', icone: IcoDemandes, release: 'R2' }, - { libelle: 'Préventif', icone: IcoPreventif, release: 'R2' }, + { libelle: 'Ordres de travail', icone: IcoOrdresDeTravail, route: '/ot', permission: ['WORK_ORDERS', 'view'], badge: 'urgences' }, + { libelle: 'Demandes', icone: IcoDemandes, route: '/demandes', permission: ['REQUESTS', 'view'], badge: 'demandes' }, + { libelle: 'Préventif', icone: IcoPreventif, route: '/preventif', permission: ['WORK_ORDERS', 'view'] }, ], }, { @@ -83,6 +86,29 @@ const THEME_ICONS: Record = { dark: Moon, }; +/** L'urgence « personne bloquée » et la file de demandes — visibles partout + * dans la coquille (chip topbar + badges), selon les droits du rôle. */ +function useSignauxExploitation(peutVoirOT: boolean, peutVoirDemandes: boolean) { + const { data: workOrders } = useQuery({ + queryKey: ['work-orders'], + enabled: peutVoirOT, + refetchInterval: 60_000, + queryFn: async () => (await api.GET('/work-orders')).data?.workOrders ?? [], + }); + const { data: requests } = useQuery({ + queryKey: ['requests'], + enabled: peutVoirDemandes, + refetchInterval: 60_000, + queryFn: async () => (await api.GET('/requests')).data?.requests ?? [], + }); + return { + urgences: (workOrders ?? []).filter( + (w) => w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED', + ), + demandesATraiter: (requests ?? []).filter((r) => r.status === 'RECEIVED').length, + }; +} + export function Coquille({ children }: { children: ReactNode }) { const navigate = useNavigate(); const { data: me } = useMe(); @@ -91,9 +117,18 @@ export function Coquille({ children }: { children: ReactNode }) { const logout = useLogout(); const { can } = usePermissions(); const [themeMode, setThemeMode] = useState(getThemeMode); + const { urgences, demandesATraiter } = useSignauxExploitation( + can('WORK_ORDERS', 'view'), + can('REQUESTS', 'view'), + ); if (!getToken()) return ; + const badges: Record<'urgences' | 'demandes', number> = { + urgences: urgences.length, + demandes: demandesATraiter, + }; + const cyclerTheme = () => { const mode = nextThemeMode(themeMode); applyThemeMode(mode); @@ -116,7 +151,7 @@ export function Coquille({ children }: { children: ReactNode }) {
{groupe}
{liens .filter(({ permission }) => !permission || can(...permission)) - .map(({ libelle, icone: Icone, route, release }) => + .map(({ libelle, icone: Icone, route, release, badge }) => route ? ( {libelle} + {badge && badges[badge] > 0 ? ( + {badges[badge]} + ) : null} ) : ( Rechercher un OT, un ascenseur, un site… ⌘K + {urgences.length > 0 ? ( + + ) : null} + + ) : null} + {ajout.isError ?

{ajout.error.message}

: null} +
    + {meter.readings.map((r) => ( +
  • + {fmt.format(r.value)} {UNITES[meter.kind]} +
    + + {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(r.createdAt))} + {r.readBy ? ` · ${r.readBy.displayName}` : ''} + +
  • + ))} + {meter.readings.length === 0 ? ( +
  • Aucun relevé pour l'instant.
  • + ) : null} +
+ + ); +} diff --git a/apps/web/src/pages/demandes.tsx b/apps/web/src/pages/demandes.tsx new file mode 100644 index 0000000..bdf03fb --- /dev/null +++ b/apps/web/src/pages/demandes.tsx @@ -0,0 +1,301 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { + WORK_ORDER_PRIORITY_LABELS, + type RequestSummary, +} from '@siop/shared'; +import { + useApproveRequest, + useCreateRequest, + useRejectRequest, + useRequests, +} from '@/api/exploitation'; +import { useAssetOptions, useUsers } from '@/api/referentiel'; +import { usePermissions } from '@/auth/use-permissions'; +import { StatutDemande } from '@/components/chips-ot'; +import { Button } from '@/components/ui/button'; +import { Modale } from '@/components/ui/modale'; + +/** Écran 1 des maquettes R2 : la file d'attente + le panneau d'approbation. + * Une demande approuvée devient UN OT (1-1) ; un rejet exige un motif. */ +export default function PageDemandes() { + const { data: requests } = useRequests(); + const { can } = usePermissions(); + const [selectionnee, setSelectionnee] = useState(null); + const [modaleSignalement, setModaleSignalement] = useState(false); + + const toutes = requests ?? []; + const aTraiter = toutes.filter((r) => r.status === 'RECEIVED').length; + const demande = toutes.find((r) => r.id === selectionnee) ?? + toutes.find((r) => r.status === 'RECEIVED') ?? null; + const peutTraiter = can('REQUESTS', 'edit'); + + return ( + <> +
+

Demandes

+ + {aTraiter} à traiter · {toutes.length} au total + +
+ {can('REQUESTS', 'create') ? ( + + ) : null} +
+
+
+
+
+ + + + + + + + + + + + {toutes.map((r) => ( + setSelectionnee(r.id)} + > + + + + + + + ))} + +
SignalementÉquipement · SiteParStatut
{r.reference}{r.isPersonTrapped ? {r.description} : r.description} + Asc. {r.assetReference} + {r.siteName} + {r.requesterLabel} + {r.workOrder ? ( + e.stopPropagation()}> + + + ) : ( + + )} +
+
+
+ {peutTraiter && demande ? : null} +
+ + + ); +} + +function PanneauApprobation({ demande }: { demande: RequestSummary }) { + const navigate = useNavigate(); + const { data: users } = useUsers(); + const approbation = useApproveRequest(); + const rejet = useRejectRequest(); + const [motif, setMotif] = useState(null); // null = fermé + const [priorite, setPriorite] = useState('HIGH'); + const [assigne, setAssigne] = useState(''); + const techniciens = (users ?? []).filter( + (u) => u.status === 'active' && u.role.name.startsWith('Technicien'), + ); + const traitee = demande.status !== 'RECEIVED'; + + return ( +
+

+ {demande.reference} — {traitee ? 'déjà traitée' : 'approbation'} +

+
+
Équipement
+
+ + Ascenseur {demande.assetReference} + {' '} + — {demande.siteName} +
+
Signalé par
+
{demande.requesterLabel}
+
Description
+
« {demande.description} »
+ {demande.rejectionReason ? ( + <> +
Motif du rejet
+
{demande.rejectionReason}
+ + ) : null} +
+ {!traitee ? ( + <> +
+ + +
+
+ + +
+ {(approbation.isError || rejet.isError) ? ( +

+ {approbation.error?.message ?? rejet.error?.message} +

+ ) : null} +
+ + +
+

+ L'OT reprend équipement et description — la demande suit ensuite l'OT + (Reçu → Intervention → Résolu côté demandeur). +

+ + ) : null} + setMotif(null)}> +
{ + e.preventDefault(); + rejet.mutate({ id: demande.id, reason: motif ?? '' }, { onSuccess: () => setMotif(null) }); + }} + className="flex flex-col gap-3" + > +
+ + setMotif(e.target.value)} + placeholder="Ex. : fausse alerte confirmée sur place" + /> +
+ {rejet.isError ?

{rejet.error.message}

: null} +
+ + +
+
+
+
+ ); +} + +/** Signalement interne (le portail public QR arrive en R2.4). */ +function ModaleSignalement({ + ouverte, + surFermeture, +}: { + ouverte: boolean; + surFermeture: (o: boolean) => void; +}) { + const { data: options } = useAssetOptions(); + const creation = useCreateRequest(); + const [personneBloquee, setPersonneBloquee] = useState(false); + + const surEnvoi = (e: FormEvent) => { + e.preventDefault(); + const donnees = new FormData(e.currentTarget); + creation.mutate( + { + assetId: String(donnees.get('assetId')), + description: String(donnees.get('description')), + isPersonTrapped: personneBloquee, + }, + { + onSuccess: () => { + surFermeture(false); + setPersonneBloquee(false); + }, + }, + ); + }; + + return ( + +
+
+ + +
+
+ Une personne est-elle bloquée ? +
+
+ + +
+ {creation.isError ?

{creation.error.message}

: null} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/pages/fiche-ascenseur.tsx b/apps/web/src/pages/fiche-ascenseur.tsx index 92408bf..db84570 100644 --- a/apps/web/src/pages/fiche-ascenseur.tsx +++ b/apps/web/src/pages/fiche-ascenseur.tsx @@ -2,6 +2,7 @@ import { QRCodeSVG } from 'qrcode.react'; import { useState, type FormEvent } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared'; +import { useWorkOrders } from '@/api/exploitation'; import { useAddComponent, useAsset, @@ -10,6 +11,7 @@ import { useUpdateAsset, } from '@/api/referentiel'; import { usePermissions } from '@/auth/use-permissions'; +import { StatutOT } from '@/components/chips-ot'; import { StatutEquipement } from '@/components/statut-equipement'; import { Button } from '@/components/ui/button'; import { Modale } from '@/components/ui/modale'; @@ -51,6 +53,9 @@ export default function PageFicheAscenseur() { ))} ) : null} + @@ -129,12 +134,7 @@ export default function PageFicheAscenseur() { ) : null}
-
-

Historique

-

- Les interventions de cet appareil apparaîtront ici avec l’exploitation (R2). -

-
+

Documents

@@ -148,6 +148,37 @@ export default function PageFicheAscenseur() { ); } +/** Historique récent (écran 5 validé R0) — les OT de CET appareil. */ +function CarteHistorique({ assetId }: { assetId: string }) { + const { can } = usePermissions(); + const { data: workOrders } = useWorkOrders(can('WORK_ORDERS', 'view')); + const historique = (workOrders ?? []).filter((w) => w.assetId === assetId).slice(0, 4); + if (!can('WORK_ORDERS', 'view')) return null; + return ( +

+

Historique récent

+ {historique.length ? ( +
    + {historique.map((w) => ( +
  • + + {w.reference} + {' '} + · {w.title} — +
    + + {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(w.createdAt))} + +
  • + ))} +
+ ) : ( +

Aucune intervention enregistrée.

+ )} +
+ ); +} + function NouvelOrgane({ assetId, ouverte, diff --git a/apps/web/src/pages/fiche-ot.tsx b/apps/web/src/pages/fiche-ot.tsx new file mode 100644 index 0000000..5fbefaf --- /dev/null +++ b/apps/web/src/pages/fiche-ot.tsx @@ -0,0 +1,354 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { + BILAN_FIELD_LABELS, + REQUIRED_BILAN_FIELDS, + WORK_ORDER_TYPE_LABELS, + type BilanField, + type WorkOrderDetail, + type WorkOrderStatus, +} from '@siop/shared'; +import { + useCommentWorkOrder, + usePatchChecklist, + useReferenceValues, + useSetAssignees, + useTransitionWorkOrder, + useUpsertReport, + useWorkOrder, +} from '@/api/exploitation'; +import { useUsers } from '@/api/referentiel'; +import { usePermissions } from '@/auth/use-permissions'; +import { Avatar } from '@/components/avatar'; +import { PrioriteOT, StatutOT } from '@/components/chips-ot'; +import { Button } from '@/components/ui/button'; +import { formatDateFr } from '@/lib/lieux'; + +const BOUTONS_TRANSITION: Record = { + IN_PROGRESS: { libelle: 'Démarrer', variant: 'prim' }, + ON_HOLD: { libelle: 'En attente', variant: 'ghost' }, + DONE: { libelle: "Clôturer l'intervention", variant: 'succes' }, + CANCELLED: { libelle: 'Annuler', variant: 'dangerLeger' }, + OPEN: { libelle: 'Rouvrir' }, +}; + +/** Écran 4 validé R0 (+ checklist maquette R2) : tout le carnet sur un écran. */ +export default function PageFicheOT() { + const { id } = useParams<{ id: string }>(); + const { data: ot } = useWorkOrder(id); + const { can } = usePermissions(); + const transition = useTransitionWorkOrder(id ?? ''); + + if (!ot) return null; + const peutEditer = can('WORK_ORDERS', 'edit'); + const cloturable = ot.closureBlockers.length === 0; + + return ( + <> +
+ Ordres de travail / {ot.reference} +
+
+

{ot.title}

+ +
+ {peutEditer + ? ot.allowedTransitions.map((cible) => { + const bouton = BOUTONS_TRANSITION[cible]; + const bloquee = cible === 'DONE' && !cloturable; + return ( + + ); + }) + : null} +
+
+ {transition.isError ? ( +

{transition.error.message}

+ ) : null} +
+ + +
+
+
+ {ot.checklist.length > 0 ? : null} + +
+
+
+

Pièces & main-d'œuvre

+

Consommations et coûts arrivent avec la gestion (R3).

+
+
+

Documents

+

Photos et pièces jointes arrivent avec la bibliothèque (R3).

+
+
+
+ + ); +} + +function CarteIntervention({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) { + const { data: users } = useUsers(); + const assignation = useSetAssignees(ot.id); + const [enAssignation, setEnAssignation] = useState(false); + const techniciens = (users ?? []).filter( + (u) => u.status === 'active' && u.role.name.startsWith('Technicien'), + ); + + return ( +
+

Intervention

+
+
+
{ot.reference} · {WORK_ORDER_TYPE_LABELS[ot.type]}
+
Équipement
+
+ + Ascenseur {ot.assetReference} + {' '} + — {ot.siteName} +
+
Priorité
+
+
Échéance
+
{formatDateFr(ot.dueDate)}
+
Assignés
+
+ {ot.assignees.length ? ( + <> + + {ot.assignees.map((a) => ( + + ))} + + {ot.assignees.map((a) => a.displayName).join(', ')} + + ) : ( + '—' + )} + {peutEditer ? ( + + ) : null} +
+ {ot.request ? ( + <> +
Demande liée
+
+ {ot.request.reference} — {ot.request.requesterLabel} +
+ + ) : null} +
+ {enAssignation ? ( +
+ + {ot.assignees.length ? ( + + ) : null} +
+ ) : null} +
+ ); +} + +const KIND_LABELS: Record = { + CREATED: 'OT créé', + GENERATED: 'Généré', + FROM_REQUEST: 'Créé depuis une demande', + STATUS_CHANGED: 'Changement d’état', + ASSIGNED: 'Assignation', + COMMENT: 'Commentaire', +}; + +function CarteActivite({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) { + const commentaire = useCommentWorkOrder(ot.id); + const surEnvoi = (e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const message = String(new FormData(form).get('message') ?? '').trim(); + if (!message) return; + commentaire.mutate(message, { onSuccess: () => form.reset() }); + }; + return ( +
+

Activité

+ {peutEditer ? ( +
+ + +
+ ) : null} +
    + {ot.events.map((e) => ( +
  • + {e.kind === 'COMMENT' ? ( + <>Commentaire {e.by ? {e.by.displayName} : null} : « {e.message} » + ) : ( + <> + {KIND_LABELS[e.kind] ?? e.kind} + {e.message ? <> — {e.message} : null} + + )} +
    + + {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(e.createdAt))} + {e.by && e.kind !== 'COMMENT' ? ` · ${e.by.displayName}` : ''} + +
  • + ))} +
+
+ ); +} + +function CarteChecklist({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) { + const patch = usePatchChecklist(ot.id); + const traitees = ot.checklist.filter((c) => c.state !== 'PENDING').length; + return ( +
+

+ Checklist du mois{' '} + + — {traitees} / {ot.checklist.length} traitées + +

+ {ot.checklist.map((item) => ( +
+ + {item.label} + + {item.doneBy + ? `${item.doneBy.displayName.split(' ')[0]} · ${new Intl.DateTimeFormat('fr-FR', { timeStyle: 'short' }).format(new Date(item.doneAt!))}` + : '—'} + +
+ ))} + {ot.checklist.some((c) => c.state === 'PENDING') ? ( +

+ ⚠ La clôture reste bloquée : chaque tâche doit être Fait ou N-A. +

+ ) : null} +
+ ); +} + +const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable & string; dto: string }[] = [ + { champ: 'DOOR_STATE', cle: 'doorState', dto: 'doorStateId' }, + { champ: 'CABIN_POSITION', cle: 'cabinPosition', dto: 'cabinPositionId' }, + { champ: 'ANOMALY', cle: 'anomaly', dto: 'anomalyId' }, + { champ: 'EXTERNAL_CAUSE', cle: 'externalCause', dto: 'externalCauseId' }, + { champ: 'ACTION_TAKEN', cle: 'actionTaken', dto: 'actionTakenId' }, + { champ: 'COMPONENT_CONCERNED', cle: 'componentConcerned', dto: 'componentConcernedId' }, +]; + +function CarteBilan({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) { + const { data: valeurs } = useReferenceValues(); + const maj = useUpsertReport(ot.id); + const bloqueurBilan = ot.closureBlockers.find((b) => b.includes('bilan')); + + return ( +
+

+ Bilan d'intervention{' '} + + — requis pour clôturer + +

+
+ {CHAMPS_BILAN.map(({ champ, cle, dto }) => { + const options = (valeurs ?? []).filter((v) => v.field === champ && v.isActive); + const valeur = ot.report?.[cle as 'doorState'] ?? null; + const requis = REQUIRED_BILAN_FIELDS.includes(champ); + return ( +
+ + +
+ ); + })} +
+ {bloqueurBilan && ot.status !== 'DONE' && ot.status !== 'CANCELLED' ? ( +

⚠ {bloqueurBilan} — la clôture restera bloquée.

+ ) : null} + {maj.isError ?

{maj.error.message}

: null} +
+ ); +} diff --git a/apps/web/src/pages/nouvel-ot.tsx b/apps/web/src/pages/nouvel-ot.tsx new file mode 100644 index 0000000..4fd6bc5 --- /dev/null +++ b/apps/web/src/pages/nouvel-ot.tsx @@ -0,0 +1,135 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { + WORK_ORDER_PRIORITY_LABELS, + WORK_ORDER_TYPE_LABELS, + WORK_ORDER_TYPES, + type WorkOrderPriority, + type WorkOrderType, +} from '@siop/shared'; +import { useCreateWorkOrder } from '@/api/exploitation'; +import { useAssets } from '@/api/referentiel'; +import { useUsers } from '@/api/referentiel'; +import { Button } from '@/components/ui/button'; + +/** Écran 2 des maquettes R2 : l'OT manuel — « personne bloquée » est un + * choix EXPLICITE (interrupteur) qui déclenche l'urgence partout. */ +export default function PageNouvelOT() { + const navigate = useNavigate(); + const [params] = useSearchParams(); + const { data: assets } = useAssets(); + const { data: users } = useUsers(); + const creation = useCreateWorkOrder(); + const [personneBloquee, setPersonneBloquee] = useState(false); + + const techniciens = (users ?? []).filter( + (u) => u.status === 'active' && u.role.name.startsWith('Technicien'), + ); + + const surEnvoi = (e: FormEvent) => { + e.preventDefault(); + const donnees = new FormData(e.currentTarget); + const champ = (n: string) => String(donnees.get(n) ?? '').trim(); + const assigne = champ('assigneeId'); + creation.mutate( + { + title: champ('title'), + description: champ('description') || undefined, + type: champ('type') as WorkOrderType, + priority: personneBloquee + ? 'PERSON_TRAPPED' + : (champ('priority') as WorkOrderPriority), + assetId: champ('assetId'), + dueDate: champ('dueDate') ? new Date(champ('dueDate')).toISOString() : undefined, + assigneeIds: assigne ? [assigne] : undefined, + }, + { onSuccess: (ot) => navigate(`/ot/${ot.id}`) }, + ); + }; + + return ( +
+
+ Ordres de travail / Nouvel OT +
+

Nouvel ordre de travail

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ Personne bloquée dans la cabine ? + + passe l'OT en urgence absolue (bandeau + tête de liste) + +
+
+ {creation.isError ? ( +

{creation.error.message}

+ ) : null} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/pages/ordres-travail.tsx b/apps/web/src/pages/ordres-travail.tsx new file mode 100644 index 0000000..3d2fda3 --- /dev/null +++ b/apps/web/src/pages/ordres-travail.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + WORK_ORDER_STATUS_LABELS, + WORK_ORDER_STATUSES, + WORK_ORDER_TYPE_LABELS, + WORK_ORDER_TYPES, + type WorkOrderStatus, + type WorkOrderType, +} from '@siop/shared'; +import { useWorkOrders } from '@/api/exploitation'; +import { usePermissions } from '@/auth/use-permissions'; +import { Avatar } from '@/components/avatar'; +import { PrioriteOT, StatutOT } from '@/components/chips-ot'; +import { Button } from '@/components/ui/button'; +import { formatDateFr } from '@/lib/lieux'; + +/** Écran 3 validé R0 : table dense « bureau », strie rouge sur l'urgence. */ +export default function PageOrdresTravail() { + const navigate = useNavigate(); + const { data: workOrders } = useWorkOrders(); + const { can } = usePermissions(); + const [statut, setStatut] = useState<'actifs' | 'tous' | WorkOrderStatus>('actifs'); + const [type, setType] = useState<'' | WorkOrderType>(''); + + const tous = workOrders ?? []; + const visibles = tous.filter((w) => { + if (statut === 'actifs' && (w.status === 'DONE' || w.status === 'CANCELLED')) return false; + if (statut !== 'actifs' && statut !== 'tous' && w.status !== statut) return false; + if (type && w.type !== type) return false; + return true; + }); + + return ( + <> +
+

Ordres de travail

+
+ {can('WORK_ORDERS', 'create') ? ( + + ) : null} +
+
+
+ + + + {visibles.length} résultats + +
+
+
+ + + + + + + + + + + + + + + {visibles.map((w) => ( + navigate(`/ot/${w.id}`)} + style={{ cursor: 'pointer' }} + > + + + + + + + + + + ))} + +
ObjetÉquipement · SiteTypePrioritéStatutAssignésÉchéance
{w.reference}{w.priority === 'PERSON_TRAPPED' ? {w.title} : w.title} + Asc. {w.assetReference} + {w.siteName} + {WORK_ORDER_TYPE_LABELS[w.type]} + {w.assignees.length ? ( +
+ {w.assignees.map((a) => ( + + ))} +
+ ) : ( + '—' + )} +
+ {w.priority === 'PERSON_TRAPPED' ? immédiat : formatDateFr(w.dueDate)} +
+
+
+ + ); +} diff --git a/apps/web/src/pages/preventif.tsx b/apps/web/src/pages/preventif.tsx new file mode 100644 index 0000000..e432bfd --- /dev/null +++ b/apps/web/src/pages/preventif.tsx @@ -0,0 +1,166 @@ +import { useState, type FormEvent } from 'react'; +import type { PreventiveGenerationResult } from '@siop/shared'; +import { + useCreateTaskTemplate, + useGeneratePreventive, + usePreventiveStatus, + useTaskTemplates, + useUpdateTaskTemplate, +} from '@/api/exploitation'; +import { usePermissions } from '@/auth/use-permissions'; +import { Button } from '@/components/ui/button'; + +const MOIS_FR = [ + 'janvier', 'février', 'mars', 'avril', 'mai', 'juin', + 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre', +]; +const libelleMois = (period: string) => { + const [annee, mois] = period.split('-').map(Number); + return `${MOIS_FR[mois! - 1]} ${annee}`; +}; + +/** Écran 3 des maquettes R2 : gabarits à périodicité + génération idempotente. */ +export default function PagePreventif() { + const { data: status } = usePreventiveStatus(); + const { data: templates } = useTaskTemplates(); + const { can } = usePermissions(); + const generation = useGeneratePreventive(); + const creation = useCreateTaskTemplate(); + const maj = useUpdateTaskTemplate(); + const [resultat, setResultat] = useState(null); + const [nouveau, setNouveau] = useState({ label: '', periodMonths: 1 }); + + const peutGenerer = can('WORK_ORDERS', 'create'); + const peutAdministrer = can('SETTINGS', 'edit'); + + const ajouterGabarit = (e: FormEvent) => { + e.preventDefault(); + if (!nouveau.label.trim()) return; + creation.mutate( + { label: nouveau.label.trim(), periodMonths: nouveau.periodMonths }, + { onSuccess: () => setNouveau({ label: '', periodMonths: 1 }) }, + ); + }; + + return ( + <> +
+

Préventif

+
+ {peutGenerer && status ? ( + + ) : null} +
+
+ {status ? ( +
+
+
Grilles générées
+
{status.generated} / {status.assetsUnderContract}
+
+
+
Terminées
+
{status.done}
+
+
+
En retard
+
{status.late}
+
+
+
Premiers contrôles
+
{status.firstControls}
+
+
+ ) : null} + {resultat ? ( +
+ + ✓ Génération {libelleMois(resultat.month)} : {resultat.created} OT créés + {resultat.firstControls ? <> (dont {resultat.firstControls} premiers contrôles) : null},{' '} + {resultat.skipped} déjà en place — regénérer ne double rien. + +
+ ) : null} + {generation.isError ? ( +

{generation.error.message}

+ ) : null} +
+
+ + + + + + + {peutAdministrer ? : null} + + + + {(templates ?? []).map((t) => ( + + + + + {peutAdministrer ? ( + + ) : null} + + ))} + +
Tâche du gabaritOrgane viséPériodicité
{t.isRegulatory ? {t.label} : t.label}{!t.isActive ? ' (désactivé)' : ''}{t.componentTypeName ?? '—'} + + {t.periodMonths === 1 + ? 'Tous les mois' + : `${t.periodMonths} mois${t.isRegulatory ? ' · réglementaire' : ''}`} + + + +
+
+
+ {peutAdministrer ? ( +
+
+ + setNouveau((n) => ({ ...n, label: e.target.value }))} + /> +
+
+ + setNouveau((n) => ({ ...n, periodMonths: Number(e.target.value) }))} + /> +
+ +
+ ) : null} + {creation.isError ?

{creation.error.message}

: null} +
+ Le gabarit s'applique à tout appareil sous contrat préventif. Un appareil sans + historique reçoit un OT « premier contrôle » : toutes les tâches, quelle que soit + la périodicité. Les autres tâches tombent aux mois anniversaires de la mise en service. +
+ + ); +} diff --git a/apps/web/src/pages/tableau-de-bord.tsx b/apps/web/src/pages/tableau-de-bord.tsx index 71e7b00..54c5f36 100644 --- a/apps/web/src/pages/tableau-de-bord.tsx +++ b/apps/web/src/pages/tableau-de-bord.tsx @@ -1,49 +1,206 @@ -import { Link } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; +import { + WORK_ORDER_STATUS_LABELS, + WORK_ORDER_TYPE_LABELS, + type WorkOrderStatus, +} from '@siop/shared'; +import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation'; import { useMe } from '@/auth/use-auth'; +import { usePermissions } from '@/auth/use-permissions'; +import { Avatar } from '@/components/avatar'; +import { PrioriteOT, StatutOT } from '@/components/chips-ot'; +import { Button } from '@/components/ui/button'; -/** R0 : coquille seulement — le vrai tableau de bord (KPI, urgences, - * graphiques) arrive avec l'exploitation (R2), conformément au plan. */ +const ST_COULEURS: Record = { + OPEN: 'var(--st-ouvert)', + IN_PROGRESS: 'var(--st-encours)', + ON_HOLD: 'var(--st-attente)', + DONE: 'var(--st-termine)', +}; + +/** Écran 2 validé R0 — alimenté par les données réelles de R2. + * (Coûts → R3 ; pannes par organe → analytics R3.) */ export default function PageTableauDeBord() { - const { data: me } = useMe(); + const navigate = useNavigate(); + const { can, chargé } = usePermissions(); + const peutVoirOT = can('WORK_ORDERS', 'view'); + const { data: workOrders } = useWorkOrders(peutVoirOT); + const { data: requests } = useRequests(); + const { data: preventif } = usePreventiveStatus(peutVoirOT); + + if (!chargé) return null; + if (!peutVoirOT) return ; + + const tous = workOrders ?? []; + const urgences = tous.filter( + (w) => w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED', + ); + const ouverts = tous.filter((w) => w.status !== 'DONE' && w.status !== 'CANCELLED'); + const enRetard = ouverts.filter((w) => w.dueDate && new Date(w.dueDate) < new Date()); + const aTraiter = (requests ?? []).filter((r) => r.status === 'RECEIVED'); + const parStatut = (['OPEN', 'IN_PROGRESS', 'ON_HOLD', 'DONE'] as WorkOrderStatus[]).map( + (statut) => ({ + statut, + n: tous.filter((w) => w.status === statut).length, + }), + ); + const max = Math.max(1, ...parStatut.map((s) => s.n)); + const recents = tous.slice(0, 5); return ( <> -
-

Tableau de bord

-
-
-

Fondations (R0)

-

- Bienvenue{me ? ` ${me.displayName}` : ''} — vous êtes connecté avec le - rôle {me?.role.name}. Cette coquille valide l'authentification, - la bascule de rôle en un clic et le bi-thème ; les indicateurs - d'exploitation arriveront avec la release R2. -

-

- La référence vivante du design est consultable sur la page{' '} - - /design - - . -

-
- {me ? ( -
-

Vos droits (matrice en base)

-
- {me.permissions - .filter((p) => p.canView) - .map((p) => ( - - {p.objectCategory} - - ))} - {me.permissions.every((p) => !p.canView) ? ( -

Ce rôle ne voit aucune catégorie d'objets.

- ) : null} +

Tableau de bord

+ {urgences.map((u) => ( +
+ +
+ {u.title} — Ascenseur {u.assetReference} ({u.siteName}) +
+ + {u.reference} · signalé{' '} + {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(u.createdAt))} + {u.assignees.length === 0 ? ' · aucune intervention en cours' : ''} + +
+
+
- ) : null} + ))} +
+
+
OT ouverts
+
{ouverts.length}
+
+ dont {tous.filter((w) => w.status === 'IN_PROGRESS').length} en cours +
+
+
+
En retard
+
{enRetard.length}
+
+
+
Préventif · {preventif ? mois(preventif.month) : '…'}
+
+ {preventif && preventif.generated > 0 + ? `${Math.round((preventif.done / preventif.generated) * 100)} %` + : '—'} +
+
+ {preventif ? `${preventif.done} / ${preventif.generated} grilles` : ''} +
+
+
+
Demandes à traiter
+
{aTraiter.length}
+
+ {aTraiter[0] + ? `la + ancienne : ${new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(aTraiter[aTraiter.length - 1]!.createdAt))}` + : 'file vide'} +
+
+
+
+
+

OT par statut

+
+ {parStatut.map(({ statut, n }) => ( +
+ {WORK_ORDER_STATUS_LABELS[statut]} +
+
+
+ {n} +
+ ))} +
+
+
+

Pannes par organe · 12 mois

+

+ Ce graphique s'alimentera des bilans codés — il arrive avec les analytics (R3). +

+
+
+
+

Interventions récentes

+
+ + + + + + + + {recents.map((w) => ( + navigate(`/ot/${w.id}`)} + style={{ cursor: 'pointer' }} + > + + + + + + + + + ))} + +
ObjetÉquipementTypePrioritéStatutAssignés
{w.reference}{w.title} + Asc. {w.assetReference} + {w.siteName} + {WORK_ORDER_TYPE_LABELS[w.type]} + {w.assignees.length ? ( +
+ {w.assignees.map((a) => ( + + ))} +
+ ) : ( + '—' + )} +
+
+
+ + ); +} + +const MOIS_FR = [ + 'janvier', 'février', 'mars', 'avril', 'mai', 'juin', + 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre', +]; +const mois = (period: string) => MOIS_FR[Number(period.split('-')[1]) - 1]; + +/** Accueil des rôles sans exploitation (Demandeur) : ses demandes. */ +function AccueilSansExploitation() { + const { data: me } = useMe(); + const { data: requests } = useRequests(); + const { can } = usePermissions(); + return ( + <> +

Mes signalements

+
+

+ Bienvenue{me ? ` ${me.displayName}` : ''} — retrouvez ici vos signalements + et leur avancement. +

+ {can('REQUESTS', 'view') ? ( +

+ + Voir mes demandes ({(requests ?? []).length}) + +

+ ) : null} +
); } diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index a548168..c69dc8f 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -750,6 +750,129 @@ table { .avatars .avatar { width: 24px; height: 24px; font-size: 10px; border: 2px solid var(--surface); margin-left: -6px; } .avatars .avatar:first-child { margin-left: 0; } +/* ═══ R2 — Exploitation (classes extraites des maquettes validées) ═══ */ +/* Statuts de DEMANDE (validés R2 : extension cohérente des couleurs) */ +.st.recue { color: var(--st-ouvert); background: var(--st-ouvert-fond); } +.st.approuvee { color: var(--st-encours); background: var(--st-encours-fond); } +.st.rejetee { color: var(--st-annule); background: var(--st-annule-fond); } +.st.resolue { color: var(--st-termine); background: var(--st-termine-fond); } + +/* Urgence « personne bloquée » — seule animation continue de l'app */ +.chip-urgence { + display: flex; + align-items: center; + gap: 7px; + background: var(--prio-bloque-fond); + color: var(--prio-bloque); + border: 1px solid color-mix(in srgb, var(--prio-bloque) 30%, transparent); + padding: 6px 12px; + border-radius: var(--rayon-pastille); + font-weight: 700; + font-size: 12.5px; +} +.bandeau-urgence { + display: flex; + align-items: center; + gap: 14px; + background: var(--prio-bloque-fond); + border: 1px solid color-mix(in srgb, var(--prio-bloque) 35%, transparent); + border-left: 4px solid var(--prio-bloque); + border-radius: var(--rayon); + padding: 12px 16px; +} +.bandeau-urgence b { color: var(--prio-bloque); } +.bandeau-urgence .depuis { color: var(--encre-2); font-size: 12.5px; } + +/* Tuiles KPI (tableau de bord + préventif) */ +.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +@media (max-width: 1100px) { .kpis { grid-template-columns: repeat(2, 1fr); } } +.kpi { background: var(--surface); border: 1px solid var(--bordure); border-radius: var(--rayon); padding: 14px 16px; } +.kpi .lib { font-size: 11.5px; letter-spacing: 0.05em; text-transform: uppercase; color: var(--encre-2); font-weight: 600; } +.kpi .val { font-size: 27px; font-weight: 700; letter-spacing: -0.02em; margin-top: 4px; } +.kpi .delta { font-size: 12px; font-weight: 600; margin-top: 2px; } +.delta.bon { color: var(--succes); } +.delta.mauvais { color: var(--danger); } +.delta.neutre { color: var(--encre-3); } +.kpi.alarme { border-color: color-mix(in srgb, var(--danger) 45%, transparent); } +.kpi.alarme .val { color: var(--danger); } + +/* Barres horizontales (OT par statut) */ +.barres-h .ligne-b { display: grid; grid-template-columns: 110px 1fr 40px; align-items: center; gap: 10px; font-size: 13px; } +.barres-h .fond-b { background: var(--graf-grille); border-radius: 4px; height: 14px; overflow: hidden; } +.barres-h .rempli { height: 100%; border-radius: 4px 0 0 4px; } +.barres-h .valeur { text-align: right; font-weight: 600; } + +/* Activité chronologique */ +.chrono { list-style: none; display: flex; flex-direction: column; gap: 0; font-size: 13px; padding: 0; } +.chrono li { position: relative; padding: 0 0 16px 22px; } +.chrono li::before { content: ''; position: absolute; left: 5px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--bordure-forte); } +.chrono li.fait::before { background: var(--primaire); } +.chrono li::after { content: ''; position: absolute; left: 8.5px; top: 15px; bottom: -2px; width: 1px; background: var(--bordure); } +.chrono li:last-child::after { display: none; } +.chrono .quand { color: var(--encre-3); font-size: 11.5px; } + +/* Checklist Fait / N-A */ +.check { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--bordure); font-size: 13.5px; } +.check:last-of-type { border-bottom: 0; } +.coche { + width: 19px; height: 19px; border-radius: 5px; border: 1.5px solid var(--bordure-forte); + flex: none; display: flex; align-items: center; justify-content: center; + color: #fff; font-size: 11px; background: none; padding: 0; cursor: pointer; +} +.coche.ok { background: var(--succes); border-color: var(--succes); } +.coche.na { background: var(--encre-3); border-color: var(--encre-3); font-size: 8.5px; } +.check .qui-fait { margin-left: auto; color: var(--encre-3); font-size: 12px; white-space: nowrap; } + +/* Bilan codé (6 champs) */ +.bilan { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; } +@media (max-width: 900px) { .bilan { grid-template-columns: 1fr; } } +.bilan .champ-b { font-size: 12.5px; } +.bilan .champ-b label { color: var(--encre-2); font-weight: 600; display: block; margin-bottom: 4px; } +.bilan .champ-b label em { color: var(--danger); font-style: normal; } +.bilan select { + width: 100%; padding: 8px 11px; border: 1px solid var(--bordure-forte); + border-radius: var(--rayon-controle); font: inherit; background: var(--surface); color: var(--encre); +} +.avertissement { margin-top: 12px; font-size: 12.5px; color: var(--alerte); } + +/* Préventif */ +.periode { + display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 700; + color: var(--primaire); background: var(--primaire-doux); padding: 2px 9px; border-radius: var(--rayon-pastille); +} +.periode.reglementaire { color: var(--safran); background: var(--safran-doux); } +.gen-info { border-left: 3px solid var(--succes); } + +/* Panneau d'approbation (demandes) */ +.panneau { border: 1px solid var(--bordure); border-radius: var(--rayon); background: var(--surface); padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; } + +/* Interrupteur (personne bloquée) */ +.gros-toggle { + display: flex; align-items: center; gap: 12px; background: var(--prio-bloque-fond); + border: 1px solid color-mix(in srgb, var(--prio-bloque) 30%, transparent); + border-radius: 10px; padding: 12px 14px; +} +.gros-toggle b { color: var(--prio-bloque); } +.interrupteur { + margin-left: auto; width: 44px; height: 24px; border-radius: var(--rayon-pastille); + background: var(--bordure-forte); position: relative; flex: none; border: 0; cursor: pointer; + transition: background var(--transition); +} +.interrupteur::after { + content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px; + border-radius: 50%; background: #fff; transition: left var(--transition); +} +.interrupteur[aria-checked='true'] { background: var(--prio-bloque); } +.interrupteur[aria-checked='true']::after { left: 22px; } + +/* Relevés de compteurs */ +.releve-saisie { display: flex; gap: 8px; align-items: center; } +.releve-saisie input { + width: 140px; padding: 8px 11px; border: 1px solid var(--bordure-forte); + border-radius: var(--rayon-controle); font: inherit; background: var(--surface); + color: var(--encre); text-align: right; +} + /* ═══ Page /design : nuanciers ═══ */ .nuancier { display: flex; diff --git a/docs/journal/journal.md b/docs/journal/journal.md index da6f05c..af8f2b7 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,20 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-07-16 — Pr. Daaif (+ Claude) — R2.3 : écrans web de l'exploitation + +**Actions** + +- **7 écrans/évolutions fidèles aux maquettes validées** : Liste OT (filtres, strie rouge, « immédiat »), **Fiche OT** (boutons de transition issus d'`allowedTransitions`, clôture grisée avec l'explication de la garde, bilan codé 6 selects alimentés par les référentiels, checklist Fait→N-A→à faire au clic, activité chronologique + commentaires, assignation, annulation motivée), Nouvel OT (interrupteur « personne bloquée » qui force la priorité), Demandes (table + panneau d'approbation, rejet en modale à motif), Préventif (4 tuiles réelles, bouton générer avec résumé « regénérer ne double rien », gabarits administrables), Compteurs (saisie + historique), **Tableau de bord réel** (bandeau urgence cliquable, KPIs, OT par statut, interventions récentes ; coûts et pannes par organe annoncés R3). +- **L'urgence traverse l'app** : chip pulsante dans la topbar (rafraîchie 60 s), badges de nav (urgences OT, demandes à traiter), bandeau dashboard, tête de liste. Fiche appareil : historique réel + accès compteurs. Accueil dédié aux rôles sans exploitation (Demandeur). +- **Trou de conception débusqué par l'e2e** : le Demandeur n'a pas `ASSETS.view` → son sélecteur d'équipement était vide. Correctif : `GET /assets/options` (authentification seule — les références sont affichées en cabine). 49 opérations au contrat. +- **Deux fragilités réelles corrigées dans la génération du préventif** : collision de référence (course avec une création d'OT) désormais RETENTÉE au lieu de sauter silencieusement un appareil ; appareil supprimé entre lecture et écriture toléré (P2003). 3 runs Jest complets consécutifs verts. +- **9 tests Playwright verts** dont la recette R2 officielle : Karim signale → Salma approuve+assigne → OT → démarrage → clôture bloquée visible → bilan → clôture → la demande de Karim passe « Résolue » ; + préventif idempotent via l'UI, urgence bout-en-bout, compteur croissant. 50 tests API (94,6 % / 78,9 %). + +**Prochaine étape** : R2.4 — portail public QR (`/q/{ref}` → signalement prérempli sans compte, suivi sans jargon — écran validé R0), puis recette R2 + déploiement + tag. + +--- + ## 2026-07-16 — Pr. Daaif (+ Claude) — R2.2 : préventif (génération idempotente) + compteurs **Actions** diff --git a/docs/openapi.json b/docs/openapi.json index d4c4913..beb5894 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -456,6 +456,32 @@ } } }, + "/assets/options": { + "get": { + "operationId": "listAssetOptions", + "summary": "Options d’appareils pour le signalement (tout rôle authentifié)", + "tags": [ + "assets" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Options", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetOptionsResponse" + } + } + } + } + } + } + }, "/assets/{id}": { "get": { "operationId": "getAsset", @@ -2692,6 +2718,41 @@ ], "additionalProperties": false }, + "AssetOptionsResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "options": { + "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" + }, + "siteName": { + "type": "string" + } + }, + "required": [ + "id", + "reference", + "siteName" + ], + "additionalProperties": false + } + } + }, + "required": [ + "options" + ], + "additionalProperties": false + }, "AssetDetail": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index c52e36d..7ccf0b9 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -12,6 +12,7 @@ import { AssetComponentCreateSchema, AssetComponentSchema, AssetDetailSchema, + AssetOptionsResponseSchema, AssetsResponseSchema, AssetUpdateSchema, CategoriesResponseSchema, @@ -244,6 +245,20 @@ export const API_CONTRACT: ApiOperation[] = [ 200: { description: 'Liste', name: 'AssetsResponse', schema: AssetsResponseSchema }, }, }, + { + operationId: 'listAssetOptions', + method: 'get', + path: '/assets/options', + summary: 'Options d’appareils pour le signalement (tout rôle authentifié)', + tags: ['assets'], + responses: { + 200: { + description: 'Options', + name: 'AssetOptionsResponse', + schema: AssetOptionsResponseSchema, + }, + }, + }, { operationId: 'getAsset', method: 'get', diff --git a/packages/shared/src/schemas/referentiel.ts b/packages/shared/src/schemas/referentiel.ts index 75fcf83..82cf50b 100644 --- a/packages/shared/src/schemas/referentiel.ts +++ b/packages/shared/src/schemas/referentiel.ts @@ -97,6 +97,18 @@ export type AssetDto = z.infer; export const AssetsResponseSchema = z.object({ assets: z.array(AssetSchema) }); export type AssetsResponse = z.infer; +/** Option minimale pour désigner un appareil (signalement) — accessible à + * tout rôle authentifié : les références sont affichées en cabine. */ +export const AssetOptionSchema = z.object({ + id: z.uuid(), + reference: z.string(), + siteName: z.string(), +}); +export const AssetOptionsResponseSchema = z.object({ + options: z.array(AssetOptionSchema), +}); +export type AssetOptionsResponse = z.infer; + export const AssetDetailSchema = AssetSchema.extend({ components: z.array(AssetComponentSchema), });