mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r2.3): écrans web exploitation — OT, demandes, préventif, compteurs, urgence
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 — 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.1 — socle backend exploitation** : migration `r2_exploitation` (10 tables), 15 opérations au contrat (41 total, transitions + champs requis du bilan dans `@siop/shared`), machine à états stricte avec garde de clôture (bilan 3 champs + checklist), demande→OT 1-1, rejet à motif, scoping « voir autre » (listes + accès directs), seed maquette (31 valeurs de bilan, 8 gabarits, OT/demandes/compteurs), 45 tests (95 %/79 %).
|
||||||
- ✅ **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production.
|
- ✅ **R2.2 — préventif + compteurs** : `underContract` + `periodKey` (unicité `[assetId, periodKey]` = idempotence EN BASE), génération mensuelle (ancrage mise en service, premier contrôle, gabarits administrables, statut du mois), compteurs strictement croissants ; +7 opérations (48), 50 tests (94,7 %/78 %). Automatisation cron/BullMQ notée pour le durcissement production.
|
||||||
- 🔄 **R2.3 — 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.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
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.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' }, parentId: { not: null } } });
|
||||||
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });
|
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ export class AssetsController {
|
|||||||
return this.assetsService.list();
|
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')
|
@Get(':id')
|
||||||
@RequirePermission('ASSETS', 'view')
|
@RequirePermission('ASSETS', 'view')
|
||||||
get(@Param('id', ParseUUIDPipe) id: string) {
|
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
AssetCreate,
|
AssetCreate,
|
||||||
AssetDetail,
|
AssetDetail,
|
||||||
AssetDto,
|
AssetDto,
|
||||||
|
AssetOptionsResponse,
|
||||||
AssetsResponse,
|
AssetsResponse,
|
||||||
AssetUpdate,
|
AssetUpdate,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
@@ -28,6 +29,21 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
|||||||
export class AssetsService {
|
export class AssetsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
|
||||||
|
async options(): Promise<AssetOptionsResponse> {
|
||||||
|
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<AssetsResponse> {
|
async list(): Promise<AssetsResponse> {
|
||||||
const rows = await this.prisma.asset.findMany({
|
const rows = await this.prisma.asset.findMany({
|
||||||
include: assetInclude,
|
include: assetInclude,
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ export class PreventiveService {
|
|||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let issue: 'créé' | 'sauté' | null = null;
|
||||||
|
for (let essai = 0; essai < 4 && issue === null; essai++) {
|
||||||
try {
|
try {
|
||||||
const ot = await this.prisma.workOrder.create({
|
const ot = await this.prisma.workOrder.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -158,16 +160,27 @@ export class PreventiveService {
|
|||||||
created++;
|
created++;
|
||||||
if (premierControle) firstControls++;
|
if (premierControle) firstControls++;
|
||||||
references.push(ot.reference);
|
references.push(ot.reference);
|
||||||
|
issue = 'créé';
|
||||||
} catch (e) {
|
} 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') {
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
skipped++;
|
const cible = (e.meta?.target as string[] | string | undefined) ?? '';
|
||||||
continue;
|
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(
|
this.logger.log(
|
||||||
`Préventif ${period} : ${created} générés, ${skipped} déjà en place, ${firstControls} premiers contrôles.`,
|
`Préventif ${period} : ${created} générés, ${skipped} déjà en place, ${firstControls} premiers contrôles.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,18 +80,37 @@ describe('Préventif & compteurs (e2e)', () => {
|
|||||||
where: { isActive: true, periodMonths: 1 },
|
where: { isActive: true, periodMonths: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
// B2 n'avait aucun OT préventif avant 2031-01 → premier contrôle
|
// Un appareil VIERGE créé par ce test (aucune génération n'a pu le toucher)
|
||||||
const b2 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'B2' } });
|
const categorie = await prisma.category.findFirstOrThrow({ where: { kind: 'EQUIPMENT' } });
|
||||||
const grilleB2 = await prisma.workOrder.findUniqueOrThrow({
|
const site = await prisma.location.findFirstOrThrow({ where: { parentId: null } });
|
||||||
where: { assetId_periodKey: { assetId: b2.id, periodKey: '2031-01' } },
|
const vierge = await prisma.asset.create({
|
||||||
|
data: {
|
||||||
|
reference: `PVT-${Date.now().toString(36)}`,
|
||||||
|
brand: 'Test',
|
||||||
|
categoryId: categorie.id,
|
||||||
|
locationId: site.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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 } } },
|
include: { _count: { select: { checklist: true } } },
|
||||||
});
|
});
|
||||||
expect(grilleB2.title).toContain('Premier contrôle');
|
expect(grille.title).toContain('Premier contrôle');
|
||||||
expect(grilleB2._count.checklist).toBe(templatesActifs);
|
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.
|
// A1 a toujours un historique préventif (seed) → grille normale.
|
||||||
// Mise en service mars 2020 : janvier 2031 n'est le mois ni du 12, ni du 3,
|
// Mise en service mars 2020 : janvier 2031 (écart 130 mois) n'est le mois
|
||||||
// ni du 6 mois (écart 130) → seules les tâches MENSUELLES sont dues.
|
// 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 a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
|
||||||
const grilleA1 = await prisma.workOrder.findUniqueOrThrow({
|
const grilleA1 = await prisma.workOrder.findUniqueOrThrow({
|
||||||
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-01' } },
|
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-01' } },
|
||||||
|
|||||||
117
apps/web/e2e/parcours-r2.spec.ts
Normal file
117
apps/web/e2e/parcours-r2.spec.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
@@ -5,13 +5,19 @@ import { Coquille } from '@/layout/coquille';
|
|||||||
import PageActivation from '@/pages/activation';
|
import PageActivation from '@/pages/activation';
|
||||||
import PageAscenseurs from '@/pages/ascenseurs';
|
import PageAscenseurs from '@/pages/ascenseurs';
|
||||||
import PageCategories from '@/pages/categories';
|
import PageCategories from '@/pages/categories';
|
||||||
|
import PageCompteurs from '@/pages/compteurs';
|
||||||
import PageConnexion from '@/pages/connexion';
|
import PageConnexion from '@/pages/connexion';
|
||||||
|
import PageDemandes from '@/pages/demandes';
|
||||||
import PageDesign from '@/pages/design';
|
import PageDesign from '@/pages/design';
|
||||||
import PageEtiquette from '@/pages/etiquette';
|
import PageEtiquette from '@/pages/etiquette';
|
||||||
import PageFicheAscenseur from '@/pages/fiche-ascenseur';
|
import PageFicheAscenseur from '@/pages/fiche-ascenseur';
|
||||||
|
import PageFicheOT from '@/pages/fiche-ot';
|
||||||
import PageFicheSite from '@/pages/fiche-site';
|
import PageFicheSite from '@/pages/fiche-site';
|
||||||
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
|
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
|
||||||
|
import PageNouvelOT from '@/pages/nouvel-ot';
|
||||||
|
import PageOrdresTravail from '@/pages/ordres-travail';
|
||||||
import PagePersonnes from '@/pages/personnes';
|
import PagePersonnes from '@/pages/personnes';
|
||||||
|
import PagePreventif from '@/pages/preventif';
|
||||||
import PageSites from '@/pages/sites';
|
import PageSites from '@/pages/sites';
|
||||||
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
||||||
|
|
||||||
@@ -36,8 +42,14 @@ export default function App() {
|
|||||||
<Route path="/ascenseurs/nouveau" element={dansCoquille(<PageNouvelAscenseur />)} />
|
<Route path="/ascenseurs/nouveau" element={dansCoquille(<PageNouvelAscenseur />)} />
|
||||||
<Route path="/ascenseurs/:id" element={dansCoquille(<PageFicheAscenseur />)} />
|
<Route path="/ascenseurs/:id" element={dansCoquille(<PageFicheAscenseur />)} />
|
||||||
<Route path="/ascenseurs/:id/etiquette" element={dansCoquille(<PageEtiquette />)} />
|
<Route path="/ascenseurs/:id/etiquette" element={dansCoquille(<PageEtiquette />)} />
|
||||||
|
<Route path="/ascenseurs/:id/compteurs" element={dansCoquille(<PageCompteurs />)} />
|
||||||
<Route path="/personnes" element={dansCoquille(<PagePersonnes />)} />
|
<Route path="/personnes" element={dansCoquille(<PagePersonnes />)} />
|
||||||
<Route path="/categories" element={dansCoquille(<PageCategories />)} />
|
<Route path="/categories" element={dansCoquille(<PageCategories />)} />
|
||||||
|
<Route path="/ot" element={dansCoquille(<PageOrdresTravail />)} />
|
||||||
|
<Route path="/ot/nouveau" element={dansCoquille(<PageNouvelOT />)} />
|
||||||
|
<Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} />
|
||||||
|
<Route path="/demandes" element={dansCoquille(<PageDemandes />)} />
|
||||||
|
<Route path="/preventif" element={dansCoquille(<PagePreventif />)} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
235
apps/web/src/api/exploitation.ts
Normal file
235
apps/web/src/api/exploitation.ts
Normal file
@@ -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<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||||
|
if (res.error || res.data === undefined) {
|
||||||
|
const message =
|
||||||
|
(res.error as { message?: string } | undefined)?.message ??
|
||||||
|
`Le serveur a répondu ${res.response.status}`;
|
||||||
|
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useInvalidate(...keys: string[]) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return () =>
|
||||||
|
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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) {
|
export function useAsset(id: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['assets', id],
|
queryKey: ['assets', id],
|
||||||
|
|||||||
45
apps/web/src/api/schema.d.ts
vendored
45
apps/web/src/api/schema.d.ts
vendored
@@ -183,6 +183,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/assets/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -851,6 +868,14 @@ export interface components {
|
|||||||
componentCount: number;
|
componentCount: number;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
AssetOptionsResponse: {
|
||||||
|
options: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
siteName: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
AssetDetail: {
|
AssetDetail: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
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: {
|
getAsset: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
66
apps/web/src/components/chips-ot.tsx
Normal file
66
apps/web/src/components/chips-ot.tsx
Normal file
@@ -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<WorkOrderStatus, string> = {
|
||||||
|
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 (
|
||||||
|
<span className={cn('st', ST_CLASSES[statut])}>
|
||||||
|
{WORK_ORDER_STATUS_LABELS[statut]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIO_CLASSES: Record<WorkOrderPriority, string | null> = {
|
||||||
|
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 <span className="text-encre-3">—</span>;
|
||||||
|
return (
|
||||||
|
<span className={cn('prio', classe)}>
|
||||||
|
<i />
|
||||||
|
{WORK_ORDER_PRIORITY_LABELS[priorite]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEM_CLASSES: Record<RequestStatus, string> = {
|
||||||
|
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 <span className="st resolue">Résolue</span>;
|
||||||
|
}
|
||||||
|
return <span className={cn('st', DEM_CLASSES[statut])}>{REQUEST_STATUS_LABELS[statut]}</span>;
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import { getToken } from '@/api/client';
|
|||||||
import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth';
|
import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth';
|
||||||
import { Avatar, initialsOf } from '@/components/avatar';
|
import { Avatar, initialsOf } from '@/components/avatar';
|
||||||
import { Logo } from '@/components/logo';
|
import { Logo } from '@/components/logo';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import type { ObjectCategory, PermissionRight } from '@siop/shared';
|
import type { ObjectCategory, PermissionRight } from '@siop/shared';
|
||||||
|
import { api } from '@/api/client';
|
||||||
import { usePermissions } from '@/auth/use-permissions';
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
import {
|
import {
|
||||||
IcoAscenseurs,
|
IcoAscenseurs,
|
||||||
@@ -40,6 +42,7 @@ interface LienNav {
|
|||||||
route?: string;
|
route?: string;
|
||||||
release?: string;
|
release?: string;
|
||||||
permission?: [ObjectCategory, PermissionRight];
|
permission?: [ObjectCategory, PermissionRight];
|
||||||
|
badge?: 'urgences' | 'demandes';
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
|
const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
|
||||||
@@ -47,9 +50,9 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
|
|||||||
groupe: 'Exploitation',
|
groupe: 'Exploitation',
|
||||||
liens: [
|
liens: [
|
||||||
{ libelle: 'Tableau de bord', icone: IcoTableauDeBord, route: '/' },
|
{ libelle: 'Tableau de bord', icone: IcoTableauDeBord, route: '/' },
|
||||||
{ libelle: 'Ordres de travail', icone: IcoOrdresDeTravail, release: 'R2' },
|
{ libelle: 'Ordres de travail', icone: IcoOrdresDeTravail, route: '/ot', permission: ['WORK_ORDERS', 'view'], badge: 'urgences' },
|
||||||
{ libelle: 'Demandes', icone: IcoDemandes, release: 'R2' },
|
{ libelle: 'Demandes', icone: IcoDemandes, route: '/demandes', permission: ['REQUESTS', 'view'], badge: 'demandes' },
|
||||||
{ libelle: 'Préventif', icone: IcoPreventif, release: 'R2' },
|
{ libelle: 'Préventif', icone: IcoPreventif, route: '/preventif', permission: ['WORK_ORDERS', 'view'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,6 +86,29 @@ const THEME_ICONS: Record<ThemeMode, typeof Sun> = {
|
|||||||
dark: Moon,
|
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 }) {
|
export function Coquille({ children }: { children: ReactNode }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: me } = useMe();
|
const { data: me } = useMe();
|
||||||
@@ -91,9 +117,18 @@ export function Coquille({ children }: { children: ReactNode }) {
|
|||||||
const logout = useLogout();
|
const logout = useLogout();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const [themeMode, setThemeMode] = useState<ThemeMode>(getThemeMode);
|
const [themeMode, setThemeMode] = useState<ThemeMode>(getThemeMode);
|
||||||
|
const { urgences, demandesATraiter } = useSignauxExploitation(
|
||||||
|
can('WORK_ORDERS', 'view'),
|
||||||
|
can('REQUESTS', 'view'),
|
||||||
|
);
|
||||||
|
|
||||||
if (!getToken()) return <Navigate to="/connexion" replace />;
|
if (!getToken()) return <Navigate to="/connexion" replace />;
|
||||||
|
|
||||||
|
const badges: Record<'urgences' | 'demandes', number> = {
|
||||||
|
urgences: urgences.length,
|
||||||
|
demandes: demandesATraiter,
|
||||||
|
};
|
||||||
|
|
||||||
const cyclerTheme = () => {
|
const cyclerTheme = () => {
|
||||||
const mode = nextThemeMode(themeMode);
|
const mode = nextThemeMode(themeMode);
|
||||||
applyThemeMode(mode);
|
applyThemeMode(mode);
|
||||||
@@ -116,7 +151,7 @@ export function Coquille({ children }: { children: ReactNode }) {
|
|||||||
<div className="groupe">{groupe}</div>
|
<div className="groupe">{groupe}</div>
|
||||||
{liens
|
{liens
|
||||||
.filter(({ permission }) => !permission || can(...permission))
|
.filter(({ permission }) => !permission || can(...permission))
|
||||||
.map(({ libelle, icone: Icone, route, release }) =>
|
.map(({ libelle, icone: Icone, route, release, badge }) =>
|
||||||
route ? (
|
route ? (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={libelle}
|
key={libelle}
|
||||||
@@ -126,6 +161,9 @@ export function Coquille({ children }: { children: ReactNode }) {
|
|||||||
>
|
>
|
||||||
<Icone />
|
<Icone />
|
||||||
{libelle}
|
{libelle}
|
||||||
|
{badge && badges[badge] > 0 ? (
|
||||||
|
<span className="badge-nav num">{badges[badge]}</span>
|
||||||
|
) : null}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
) : (
|
) : (
|
||||||
<a
|
<a
|
||||||
@@ -151,6 +189,19 @@ export function Coquille({ children }: { children: ReactNode }) {
|
|||||||
<IcoRecherche />
|
<IcoRecherche />
|
||||||
Rechercher un OT, un ascenseur, un site… ⌘K
|
Rechercher un OT, un ascenseur, un site… ⌘K
|
||||||
</div>
|
</div>
|
||||||
|
{urgences.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="chip-urgence"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/ot/${urgences[0]!.id}`)}
|
||||||
|
>
|
||||||
|
<span className="point-pulse" />
|
||||||
|
{urgences.length === 1
|
||||||
|
? '1 personne bloquée'
|
||||||
|
: `${urgences.length} personnes bloquées`}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn ghost"
|
className="btn ghost"
|
||||||
|
|||||||
89
apps/web/src/pages/compteurs.tsx
Normal file
89
apps/web/src/pages/compteurs.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useParams } from 'react-router-dom';
|
||||||
|
import { METER_KIND_LABELS, type MeterDto, type MeterKind } from '@siop/shared';
|
||||||
|
import { useAddMeterReading, useMeters } from '@/api/exploitation';
|
||||||
|
import { useAsset } from '@/api/referentiel';
|
||||||
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const UNITES: Record<MeterKind, string> = { RUNNING_HOURS: 'h', STARTS: 'cycles' };
|
||||||
|
const fmt = new Intl.NumberFormat('fr-FR');
|
||||||
|
|
||||||
|
/** Écran 5 des maquettes R2 : relevés simples du carnet, strictement croissants. */
|
||||||
|
export default function PageCompteurs() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { data: asset } = useAsset(id);
|
||||||
|
const { data: meters } = useMeters(id);
|
||||||
|
if (!asset) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/ascenseurs">Ascenseurs</Link> /{' '}
|
||||||
|
<Link to={`/ascenseurs/${asset.id}`}>Ascenseur {asset.reference}</Link> / Compteurs
|
||||||
|
</div>
|
||||||
|
<div className="entete-page"><h1>Compteurs — Ascenseur {asset.reference}</h1></div>
|
||||||
|
<div className="grille-2">
|
||||||
|
{(meters ?? []).map((meter) => (
|
||||||
|
<CarteCompteur key={meter.kind} assetId={asset.id} meter={meter} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||||
|
Un relevé <b>inférieur au précédent</b> est refusé (un compteur ne redescend pas).
|
||||||
|
Les seuils d'alerte (préventif conditionnel) sont au backlog — R2 stocke et trace.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarteCompteur({ assetId, meter }: { assetId: string; meter: MeterDto }) {
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const ajout = useAddMeterReading(assetId);
|
||||||
|
const [valeur, setValeur] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>{METER_KIND_LABELS[meter.kind]}</h2>
|
||||||
|
{can('METERS', 'create') ? (
|
||||||
|
<form
|
||||||
|
className="releve-saisie"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const v = Number(valeur.replace(/\s/g, ''));
|
||||||
|
if (!Number.isFinite(v)) return;
|
||||||
|
ajout.mutate({ kind: meter.kind, value: v }, { onSuccess: () => setValeur('') });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
aria-label={`Nouveau relevé — ${METER_KIND_LABELS[meter.kind]}`}
|
||||||
|
className="num"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder={meter.readings[0] ? fmt.format(meter.readings[0].value) : '0'}
|
||||||
|
value={valeur}
|
||||||
|
onChange={(e) => setValeur(e.target.value)}
|
||||||
|
/>
|
||||||
|
<span style={{ color: 'var(--encre-2)', fontSize: 13 }}>{UNITES[meter.kind]}</span>
|
||||||
|
<Button type="submit" variant="prim" disabled={ajout.isPending}>
|
||||||
|
Enregistrer le relevé
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
{ajout.isError ? <p className="erreur-form mt-2">{ajout.error.message}</p> : null}
|
||||||
|
<ul className="chrono" style={{ marginTop: 14 }}>
|
||||||
|
{meter.readings.map((r) => (
|
||||||
|
<li key={r.id} className="fait">
|
||||||
|
<span className="num">{fmt.format(r.value)} {UNITES[meter.kind]}</span>
|
||||||
|
<br />
|
||||||
|
<span className="quand">
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(r.createdAt))}
|
||||||
|
{r.readBy ? ` · ${r.readBy.displayName}` : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{meter.readings.length === 0 ? (
|
||||||
|
<li className="text-encre-3" style={{ padding: 0 }}>Aucun relevé pour l'instant.</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
301
apps/web/src/pages/demandes.tsx
Normal file
301
apps/web/src/pages/demandes.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Demandes</h1>
|
||||||
|
<span className="filajout">
|
||||||
|
{aTraiter} à traiter · {toutes.length} au total
|
||||||
|
</span>
|
||||||
|
<div className="actions">
|
||||||
|
{can('REQUESTS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => setModaleSignalement(true)}>
|
||||||
|
+ Signaler
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2" style={{ gridTemplateColumns: peutTraiter ? '1.4fr 1fr' : '1fr' }}>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>N°</th>
|
||||||
|
<th>Signalement</th>
|
||||||
|
<th>Équipement · Site</th>
|
||||||
|
<th>Par</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{toutes.map((r) => (
|
||||||
|
<tr
|
||||||
|
key={r.id}
|
||||||
|
className={r.isPersonTrapped && r.status === 'RECEIVED' ? 'ligne-bloque' : undefined}
|
||||||
|
style={{
|
||||||
|
cursor: peutTraiter ? 'pointer' : undefined,
|
||||||
|
background: r.id === demande?.id && peutTraiter ? 'var(--primaire-doux)' : undefined,
|
||||||
|
}}
|
||||||
|
onClick={() => setSelectionnee(r.id)}
|
||||||
|
>
|
||||||
|
<td className="ref num">{r.reference}</td>
|
||||||
|
<td>{r.isPersonTrapped ? <b>{r.description}</b> : r.description}</td>
|
||||||
|
<td>
|
||||||
|
Asc. {r.assetReference}
|
||||||
|
<span className="sous">{r.siteName}</span>
|
||||||
|
</td>
|
||||||
|
<td>{r.requesterLabel}</td>
|
||||||
|
<td>
|
||||||
|
{r.workOrder ? (
|
||||||
|
<Link to={`/ot/${r.workOrder.id}`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<StatutDemande statut={r.status} otStatut={r.workOrder.status} />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<StatutDemande statut={r.status} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{peutTraiter && demande ? <PanneauApprobation demande={demande} /> : null}
|
||||||
|
</div>
|
||||||
|
<ModaleSignalement ouverte={modaleSignalement} surFermeture={setModaleSignalement} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanneauApprobation({ demande }: { demande: RequestSummary }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: users } = useUsers();
|
||||||
|
const approbation = useApproveRequest();
|
||||||
|
const rejet = useRejectRequest();
|
||||||
|
const [motif, setMotif] = useState<string | null>(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 (
|
||||||
|
<div className="panneau">
|
||||||
|
<h2 style={{ fontSize: 13, fontWeight: 700, letterSpacing: '.04em', textTransform: 'uppercase', color: 'var(--encre-2)' }}>
|
||||||
|
{demande.reference} — {traitee ? 'déjà traitée' : 'approbation'}
|
||||||
|
</h2>
|
||||||
|
<dl className="infos" style={{ gridTemplateColumns: '110px 1fr' }}>
|
||||||
|
<dt>Équipement</dt>
|
||||||
|
<dd>
|
||||||
|
<Link to={`/ascenseurs/${demande.assetId}`} style={{ color: 'var(--primaire)', fontWeight: 600 }}>
|
||||||
|
Ascenseur {demande.assetReference}
|
||||||
|
</Link>{' '}
|
||||||
|
— {demande.siteName}
|
||||||
|
</dd>
|
||||||
|
<dt>Signalé par</dt>
|
||||||
|
<dd>{demande.requesterLabel}</dd>
|
||||||
|
<dt>Description</dt>
|
||||||
|
<dd>« {demande.description} »</dd>
|
||||||
|
{demande.rejectionReason ? (
|
||||||
|
<>
|
||||||
|
<dt>Motif du rejet</dt>
|
||||||
|
<dd>{demande.rejectionReason}</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
{!traitee ? (
|
||||||
|
<>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="app-prio">Priorité de l'OT</label>
|
||||||
|
<select
|
||||||
|
id="app-prio"
|
||||||
|
value={demande.isPersonTrapped ? 'PERSON_TRAPPED' : priorite}
|
||||||
|
disabled={demande.isPersonTrapped}
|
||||||
|
onChange={(e) => setPriorite(e.target.value)}
|
||||||
|
>
|
||||||
|
{(['LOW', 'MEDIUM', 'HIGH', 'PERSON_TRAPPED'] as const).map((p) => (
|
||||||
|
<option key={p} value={p}>{WORK_ORDER_PRIORITY_LABELS[p]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="app-assigne">Assigner à</label>
|
||||||
|
<select id="app-assigne" value={assigne} onChange={(e) => setAssigne(e.target.value)}>
|
||||||
|
<option value="">— plus tard —</option>
|
||||||
|
{techniciens.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.displayName}
|
||||||
|
{t.teams.length ? ` — équipe ${t.teams[0]!.name}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{(approbation.isError || rejet.isError) ? (
|
||||||
|
<p className="erreur-form" role="alert">
|
||||||
|
{approbation.error?.message ?? rejet.error?.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="dangerLeger" onClick={() => setMotif('')}>
|
||||||
|
Rejeter (motif requis)
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="prim"
|
||||||
|
disabled={approbation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
approbation.mutate(
|
||||||
|
{
|
||||||
|
id: demande.id,
|
||||||
|
priority: (demande.isPersonTrapped ? 'PERSON_TRAPPED' : priorite) as never,
|
||||||
|
assigneeIds: assigne ? [assigne] : undefined,
|
||||||
|
},
|
||||||
|
{ onSuccess: (ot) => navigate(`/ot/${ot.id}`) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Approuver → créer l'OT
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--encre-3)' }}>
|
||||||
|
L'OT reprend équipement et description — la demande suit ensuite l'OT
|
||||||
|
(Reçu → Intervention → Résolu côté demandeur).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Modale titre="Rejeter la demande" ouverte={motif !== null} surFermeture={() => setMotif(null)}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
rejet.mutate({ id: demande.id, reason: motif ?? '' }, { onSuccess: () => setMotif(null) });
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="rej-motif">Motif (lisible par le demandeur, sans jargon) *</label>
|
||||||
|
<input
|
||||||
|
id="rej-motif"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
value={motif ?? ''}
|
||||||
|
onChange={(e) => setMotif(e.target.value)}
|
||||||
|
placeholder="Ex. : fausse alerte confirmée sur place"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{rejet.isError ? <p className="erreur-form">{rejet.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => setMotif(null)}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="dangerLeger" disabled={rejet.isPending}>
|
||||||
|
Rejeter la demande
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<Modale titre="Signaler un problème" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="sig-asset">Équipement *</label>
|
||||||
|
<select id="sig-asset" name="assetId" required defaultValue="">
|
||||||
|
<option value="" disabled>Choisir…</option>
|
||||||
|
{(options ?? []).map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>{a.reference} — {a.siteName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="gros-toggle">
|
||||||
|
<b>Une personne est-elle bloquée ?</b>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="interrupteur"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={personneBloquee}
|
||||||
|
aria-label="Personne bloquée"
|
||||||
|
onClick={() => setPersonneBloquee((v) => !v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="sig-desc">Décrivez le problème *</label>
|
||||||
|
<input
|
||||||
|
id="sig-desc"
|
||||||
|
name="description"
|
||||||
|
required
|
||||||
|
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||||
|
Envoyer le signalement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { QRCodeSVG } from 'qrcode.react';
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
|
import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
|
||||||
|
import { useWorkOrders } from '@/api/exploitation';
|
||||||
import {
|
import {
|
||||||
useAddComponent,
|
useAddComponent,
|
||||||
useAsset,
|
useAsset,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
useUpdateAsset,
|
useUpdateAsset,
|
||||||
} from '@/api/referentiel';
|
} from '@/api/referentiel';
|
||||||
import { usePermissions } from '@/auth/use-permissions';
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { StatutOT } from '@/components/chips-ot';
|
||||||
import { StatutEquipement } from '@/components/statut-equipement';
|
import { StatutEquipement } from '@/components/statut-equipement';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Modale } from '@/components/ui/modale';
|
import { Modale } from '@/components/ui/modale';
|
||||||
@@ -51,6 +53,9 @@ export default function PageFicheAscenseur() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : null}
|
) : null}
|
||||||
|
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/compteurs`)}>
|
||||||
|
Compteurs
|
||||||
|
</Button>
|
||||||
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/etiquette`)}>
|
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/etiquette`)}>
|
||||||
Étiquette QR
|
Étiquette QR
|
||||||
</Button>
|
</Button>
|
||||||
@@ -129,12 +134,7 @@ export default function PageFicheAscenseur() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
<CarteHistorique assetId={asset.id} />
|
||||||
<h2>Historique</h2>
|
|
||||||
<p className="text-encre-3">
|
|
||||||
Les interventions de cet appareil apparaîtront ici avec l’exploitation (R2).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||||
<h2>Documents</h2>
|
<h2>Documents</h2>
|
||||||
<p className="text-encre-3">
|
<p className="text-encre-3">
|
||||||
@@ -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 (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Historique récent</h2>
|
||||||
|
{historique.length ? (
|
||||||
|
<ul className="chrono">
|
||||||
|
{historique.map((w) => (
|
||||||
|
<li key={w.id} className="fait">
|
||||||
|
<Link to={`/ot/${w.id}`} className="num" style={{ color: 'var(--primaire)', fontWeight: 600 }}>
|
||||||
|
{w.reference}
|
||||||
|
</Link>{' '}
|
||||||
|
· {w.title} — <StatutOT statut={w.status} />
|
||||||
|
<br />
|
||||||
|
<span className="quand">
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(w.createdAt))}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-encre-3">Aucune intervention enregistrée.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NouvelOrgane({
|
function NouvelOrgane({
|
||||||
assetId,
|
assetId,
|
||||||
ouverte,
|
ouverte,
|
||||||
|
|||||||
354
apps/web/src/pages/fiche-ot.tsx
Normal file
354
apps/web/src/pages/fiche-ot.tsx
Normal file
@@ -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<WorkOrderStatus, { libelle: string; variant?: 'prim' | 'succes' | 'dangerLeger' | 'ghost' }> = {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/ot">Ordres de travail</Link> / {ot.reference}
|
||||||
|
</div>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>{ot.title}</h1>
|
||||||
|
<StatutOT statut={ot.status} />
|
||||||
|
<div className="actions">
|
||||||
|
{peutEditer
|
||||||
|
? ot.allowedTransitions.map((cible) => {
|
||||||
|
const bouton = BOUTONS_TRANSITION[cible];
|
||||||
|
const bloquee = cible === 'DONE' && !cloturable;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={cible}
|
||||||
|
variant={bouton.variant}
|
||||||
|
disabled={transition.isPending || bloquee}
|
||||||
|
title={bloquee ? ot.closureBlockers.join(' ; ') : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
const commentaire =
|
||||||
|
cible === 'CANCELLED'
|
||||||
|
? window.prompt('Motif d’annulation (visible dans l’activité) :') ?? undefined
|
||||||
|
: undefined;
|
||||||
|
if (cible === 'CANCELLED' && !commentaire) return;
|
||||||
|
transition.mutate({ to: cible, comment: commentaire });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bouton.libelle}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{transition.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{transition.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="grille-2">
|
||||||
|
<CarteIntervention ot={ot} peutEditer={peutEditer} />
|
||||||
|
<CarteActivite ot={ot} peutEditer={peutEditer} />
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{ot.checklist.length > 0 ? <CarteChecklist ot={ot} peutEditer={peutEditer} /> : null}
|
||||||
|
<CarteBilan ot={ot} peutEditer={peutEditer} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||||
|
<h2>Pièces & main-d'œuvre</h2>
|
||||||
|
<p className="text-encre-3">Consommations et coûts arrivent avec la gestion (R3).</p>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||||
|
<h2>Documents</h2>
|
||||||
|
<p className="text-encre-3">Photos et pièces jointes arrivent avec la bibliothèque (R3).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Intervention</h2>
|
||||||
|
<dl className="infos">
|
||||||
|
<dt>N°</dt>
|
||||||
|
<dd className="num">{ot.reference} · {WORK_ORDER_TYPE_LABELS[ot.type]}</dd>
|
||||||
|
<dt>Équipement</dt>
|
||||||
|
<dd>
|
||||||
|
<Link to={`/ascenseurs/${ot.assetId}`} style={{ color: 'var(--primaire)', fontWeight: 600 }}>
|
||||||
|
Ascenseur {ot.assetReference}
|
||||||
|
</Link>{' '}
|
||||||
|
— {ot.siteName}
|
||||||
|
</dd>
|
||||||
|
<dt>Priorité</dt>
|
||||||
|
<dd><PrioriteOT priorite={ot.priority} /></dd>
|
||||||
|
<dt>Échéance</dt>
|
||||||
|
<dd className="num">{formatDateFr(ot.dueDate)}</dd>
|
||||||
|
<dt>Assignés</dt>
|
||||||
|
<dd className="flex items-center gap-2">
|
||||||
|
{ot.assignees.length ? (
|
||||||
|
<>
|
||||||
|
<span className="avatars">
|
||||||
|
{ot.assignees.map((a) => (
|
||||||
|
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
{ot.assignees.map((a) => a.displayName).join(', ')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
{peutEditer ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-[12px]"
|
||||||
|
onClick={() => setEnAssignation((v) => !v)}
|
||||||
|
>
|
||||||
|
+ Assigner
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</dd>
|
||||||
|
{ot.request ? (
|
||||||
|
<>
|
||||||
|
<dt>Demande liée</dt>
|
||||||
|
<dd>
|
||||||
|
<span className="num">{ot.request.reference}</span> — {ot.request.requesterLabel}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</dl>
|
||||||
|
{enAssignation ? (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="Choisir un technicien"
|
||||||
|
className="flex-1 rounded-lg border border-bordure-forte bg-surface p-2 text-encre"
|
||||||
|
defaultValue=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (!e.target.value) return;
|
||||||
|
assignation.mutate(
|
||||||
|
{ assigneeIds: [...new Set([...ot.assignees.map((a) => a.id), e.target.value])] },
|
||||||
|
{ onSuccess: () => setEnAssignation(false) },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="" disabled>Choisir un technicien…</option>
|
||||||
|
{techniciens.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.displayName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{ot.assignees.length ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-[12px]"
|
||||||
|
onClick={() =>
|
||||||
|
assignation.mutate({ assigneeIds: [] }, { onSuccess: () => setEnAssignation(false) })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Tout retirer
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<string, string> = {
|
||||||
|
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<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Activité</h2>
|
||||||
|
{peutEditer ? (
|
||||||
|
<form onSubmit={surEnvoi} className="mb-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
name="message"
|
||||||
|
aria-label="Commenter"
|
||||||
|
placeholder="Commenter…"
|
||||||
|
className="flex-1 rounded-lg border border-bordure-forte bg-surface px-3 py-2 text-encre"
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={commentaire.isPending}>Publier</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
<ul className="chrono">
|
||||||
|
{ot.events.map((e) => (
|
||||||
|
<li key={e.id} className="fait">
|
||||||
|
{e.kind === 'COMMENT' ? (
|
||||||
|
<>Commentaire {e.by ? <b>{e.by.displayName}</b> : null} : « {e.message} »</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<b>{KIND_LABELS[e.kind] ?? e.kind}</b>
|
||||||
|
{e.message ? <> — {e.message}</> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<br />
|
||||||
|
<span className="quand">
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(e.createdAt))}
|
||||||
|
{e.by && e.kind !== 'COMMENT' ? ` · ${e.by.displayName}` : ''}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CarteChecklist({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) {
|
||||||
|
const patch = usePatchChecklist(ot.id);
|
||||||
|
const traitees = ot.checklist.filter((c) => c.state !== 'PENDING').length;
|
||||||
|
return (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>
|
||||||
|
Checklist du mois{' '}
|
||||||
|
<span style={{ color: 'var(--encre-3)', textTransform: 'none', letterSpacing: 0 }}>
|
||||||
|
— {traitees} / {ot.checklist.length} traitées
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{ot.checklist.map((item) => (
|
||||||
|
<div className="check" key={item.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`coche ${item.state === 'DONE' ? 'ok' : item.state === 'NA' ? 'na' : ''}`}
|
||||||
|
aria-label={`${item.label} — ${item.state === 'PENDING' ? 'marquer Fait (clic) ou N-A (double clic)' : 'remettre à faire'}`}
|
||||||
|
disabled={!peutEditer || patch.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
patch.mutate({
|
||||||
|
itemId: item.id,
|
||||||
|
state: item.state === 'PENDING' ? 'DONE' : item.state === 'DONE' ? 'NA' : 'PENDING',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.state === 'DONE' ? '✓' : item.state === 'NA' ? 'N-A' : ''}
|
||||||
|
</button>
|
||||||
|
{item.label}
|
||||||
|
<span className="qui-fait">
|
||||||
|
{item.doneBy
|
||||||
|
? `${item.doneBy.displayName.split(' ')[0]} · ${new Intl.DateTimeFormat('fr-FR', { timeStyle: 'short' }).format(new Date(item.doneAt!))}`
|
||||||
|
: '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{ot.checklist.some((c) => c.state === 'PENDING') ? (
|
||||||
|
<p className="avertissement">
|
||||||
|
⚠ La clôture reste bloquée : chaque tâche doit être <b>Fait</b> ou <b>N-A</b>.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail['report']> & 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 (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>
|
||||||
|
Bilan d'intervention{' '}
|
||||||
|
<span style={{ color: 'var(--encre-3)', textTransform: 'none', letterSpacing: 0 }}>
|
||||||
|
— requis pour clôturer
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="bilan">
|
||||||
|
{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 (
|
||||||
|
<div className="champ-b" key={champ}>
|
||||||
|
<label htmlFor={`bilan-${champ}`}>
|
||||||
|
{BILAN_FIELD_LABELS[champ]} {requis ? <em>*</em> : null}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={`bilan-${champ}`}
|
||||||
|
disabled={!peutEditer || maj.isPending || ot.status === 'DONE' || ot.status === 'CANCELLED'}
|
||||||
|
value={valeur?.id ?? ''}
|
||||||
|
onChange={(e) => maj.mutate({ [dto]: e.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner…</option>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{bloqueurBilan && ot.status !== 'DONE' && ot.status !== 'CANCELLED' ? (
|
||||||
|
<p className="avertissement">⚠ {bloqueurBilan} — la clôture restera bloquée.</p>
|
||||||
|
) : null}
|
||||||
|
{maj.isError ? <p className="erreur-form" role="alert">{maj.error.message}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
135
apps/web/src/pages/nouvel-ot.tsx
Normal file
135
apps/web/src/pages/nouvel-ot.tsx
Normal file
@@ -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<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<div style={{ maxWidth: 760 }} className="flex flex-col gap-4">
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/ot">Ordres de travail</Link> / Nouvel OT
|
||||||
|
</div>
|
||||||
|
<div className="entete-page"><h1>Nouvel ordre de travail</h1></div>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-4">
|
||||||
|
<div className="carte">
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ pleine-l">
|
||||||
|
<label htmlFor="not-titre">Objet *</label>
|
||||||
|
<input id="not-titre" name="title" required placeholder="Ex. : Remplacement contact de porte" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="not-type">Type *</label>
|
||||||
|
<select id="not-type" name="type" required defaultValue="CORRECTIVE">
|
||||||
|
{WORK_ORDER_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{WORK_ORDER_TYPE_LABELS[t]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="not-asset">Équipement *</label>
|
||||||
|
<select id="not-asset" name="assetId" required defaultValue={params.get('asset') ?? ''}>
|
||||||
|
<option value="" disabled>Choisir…</option>
|
||||||
|
{(assets ?? []).map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.reference} — {a.siteName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="not-prio">Priorité *</label>
|
||||||
|
<select id="not-prio" name="priority" defaultValue="MEDIUM" disabled={personneBloquee}>
|
||||||
|
{(['NONE', 'LOW', 'MEDIUM', 'HIGH'] as const).map((p) => (
|
||||||
|
<option key={p} value={p}>{WORK_ORDER_PRIORITY_LABELS[p]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="not-echeance">Échéance</label>
|
||||||
|
<input id="not-echeance" name="dueDate" type="date" />
|
||||||
|
</div>
|
||||||
|
<div className="champ pleine-l">
|
||||||
|
<label htmlFor="not-desc">Description</label>
|
||||||
|
<input id="not-desc" name="description" placeholder="Constat, contexte, consignes…" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="not-assigne">Assigner à</label>
|
||||||
|
<select id="not-assigne" name="assigneeId" defaultValue="">
|
||||||
|
<option value="">—</option>
|
||||||
|
{techniciens.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>{t.displayName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="gros-toggle" style={{ marginTop: 14 }}>
|
||||||
|
<b>Personne bloquée dans la cabine ?</b>
|
||||||
|
<span style={{ marginLeft: 6, fontSize: 12, color: 'var(--encre-2)' }}>
|
||||||
|
passe l'OT en urgence absolue (bandeau + tête de liste)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="interrupteur"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={personneBloquee}
|
||||||
|
aria-label="Personne bloquée"
|
||||||
|
onClick={() => setPersonneBloquee((v) => !v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{creation.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button onClick={() => navigate('/ot')}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>
|
||||||
|
Créer l'OT (statut Ouvert)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
apps/web/src/pages/ordres-travail.tsx
Normal file
126
apps/web/src/pages/ordres-travail.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Ordres de travail</h1>
|
||||||
|
<div className="actions">
|
||||||
|
{can('WORK_ORDERS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => navigate('/ot/nouveau')}>
|
||||||
|
+ Nouvel OT
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filtres">
|
||||||
|
<select
|
||||||
|
aria-label="Filtrer par statut"
|
||||||
|
value={statut}
|
||||||
|
onChange={(e) => setStatut(e.target.value as typeof statut)}
|
||||||
|
>
|
||||||
|
<option value="actifs">Statut : actifs</option>
|
||||||
|
<option value="tous">Statut : tous</option>
|
||||||
|
{WORK_ORDER_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>{WORK_ORDER_STATUS_LABELS[s]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label="Filtrer par type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as '' | WorkOrderType)}
|
||||||
|
>
|
||||||
|
<option value="">Type : tous</option>
|
||||||
|
{WORK_ORDER_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{WORK_ORDER_TYPE_LABELS[t]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="num" style={{ color: 'var(--encre-3)', fontSize: 12.5, marginLeft: 'auto' }}>
|
||||||
|
{visibles.length} résultats
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>N°</th>
|
||||||
|
<th>Objet</th>
|
||||||
|
<th>Équipement · Site</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Priorité</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th>Assignés</th>
|
||||||
|
<th>Échéance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibles.map((w) => (
|
||||||
|
<tr
|
||||||
|
key={w.id}
|
||||||
|
className={w.priority === 'PERSON_TRAPPED' ? 'ligne-bloque' : undefined}
|
||||||
|
onClick={() => navigate(`/ot/${w.id}`)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<td className="ref num">{w.reference}</td>
|
||||||
|
<td>{w.priority === 'PERSON_TRAPPED' ? <b>{w.title}</b> : w.title}</td>
|
||||||
|
<td>
|
||||||
|
Asc. {w.assetReference}
|
||||||
|
<span className="sous">{w.siteName}</span>
|
||||||
|
</td>
|
||||||
|
<td>{WORK_ORDER_TYPE_LABELS[w.type]}</td>
|
||||||
|
<td><PrioriteOT priorite={w.priority} /></td>
|
||||||
|
<td><StatutOT statut={w.status} /></td>
|
||||||
|
<td>
|
||||||
|
{w.assignees.length ? (
|
||||||
|
<div className="avatars">
|
||||||
|
{w.assignees.map((a) => (
|
||||||
|
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{w.priority === 'PERSON_TRAPPED' ? <b>immédiat</b> : formatDateFr(w.dueDate)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/web/src/pages/preventif.tsx
Normal file
166
apps/web/src/pages/preventif.tsx
Normal file
@@ -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<PreventiveGenerationResult | null>(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 (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Préventif</h1>
|
||||||
|
<div className="actions">
|
||||||
|
{peutGenerer && status ? (
|
||||||
|
<Button
|
||||||
|
variant="prim"
|
||||||
|
disabled={generation.isPending}
|
||||||
|
onClick={() => generation.mutate({}, { onSuccess: setResultat })}
|
||||||
|
>
|
||||||
|
Générer la grille de {libelleMois(status.month)}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{status ? (
|
||||||
|
<div className="kpis">
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Grilles générées</div>
|
||||||
|
<div className="val num">{status.generated} / {status.assetsUnderContract}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Terminées</div>
|
||||||
|
<div className="val num">{status.done}</div>
|
||||||
|
</div>
|
||||||
|
<div className={status.late ? 'kpi alarme' : 'kpi'}>
|
||||||
|
<div className="lib">En retard</div>
|
||||||
|
<div className="val num">{status.late}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Premiers contrôles</div>
|
||||||
|
<div className="val num">{status.firstControls}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{resultat ? (
|
||||||
|
<div className="carte gen-info">
|
||||||
|
<span style={{ fontSize: 13 }}>
|
||||||
|
✓ Génération {libelleMois(resultat.month)} : <b className="num">{resultat.created}</b> OT créés
|
||||||
|
{resultat.firstControls ? <> (dont <b className="num">{resultat.firstControls}</b> premiers contrôles)</> : null},{' '}
|
||||||
|
<b className="num">{resultat.skipped}</b> déjà en place — <b>regénérer ne double rien</b>.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{generation.isError ? (
|
||||||
|
<p className="erreur-form" role="alert">{generation.error.message}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Tâche du gabarit</th>
|
||||||
|
<th>Organe visé</th>
|
||||||
|
<th>Périodicité</th>
|
||||||
|
{peutAdministrer ? <th></th> : null}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(templates ?? []).map((t) => (
|
||||||
|
<tr key={t.id} style={!t.isActive ? { color: 'var(--encre-3)' } : undefined}>
|
||||||
|
<td>{t.isRegulatory ? <b>{t.label}</b> : t.label}{!t.isActive ? ' (désactivé)' : ''}</td>
|
||||||
|
<td>{t.componentTypeName ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={t.isRegulatory ? 'periode reglementaire' : 'periode'}>
|
||||||
|
{t.periodMonths === 1
|
||||||
|
? 'Tous les mois'
|
||||||
|
: `${t.periodMonths} mois${t.isRegulatory ? ' · réglementaire' : ''}`}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{peutAdministrer ? (
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-[12px]"
|
||||||
|
onClick={() => maj.mutate({ id: t.id, isActive: !t.isActive })}
|
||||||
|
>
|
||||||
|
{t.isActive ? 'Désactiver' : 'Réactiver'}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
) : null}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{peutAdministrer ? (
|
||||||
|
<form className="carte flex items-end gap-3" onSubmit={ajouterGabarit}>
|
||||||
|
<div className="champ" style={{ flex: 2 }}>
|
||||||
|
<label htmlFor="gab-label">Nouvelle tâche</label>
|
||||||
|
<input
|
||||||
|
id="gab-label"
|
||||||
|
placeholder="Ex. : Contrôle éclairage de gaine"
|
||||||
|
value={nouveau.label}
|
||||||
|
onChange={(e) => setNouveau((n) => ({ ...n, label: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="gab-periode">Périodicité (mois)</label>
|
||||||
|
<input
|
||||||
|
id="gab-periode"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
value={nouveau.periodMonths}
|
||||||
|
onChange={(e) => setNouveau((n) => ({ ...n, periodMonths: Number(e.target.value) }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="prim" disabled={creation.isPending}>Ajouter</Button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||||
|
Le gabarit s'applique à tout appareil <b>sous contrat préventif</b>. Un appareil sans
|
||||||
|
historique reçoit un OT « <b>premier contrôle</b> » : toutes les tâches, quelle que soit
|
||||||
|
la périodicité. Les autres tâches tombent aux mois anniversaires de la mise en service.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { 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,
|
const ST_COULEURS: Record<string, string> = {
|
||||||
* graphiques) arrive avec l'exploitation (R2), conformément au plan. */
|
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() {
|
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 <AccueilSansExploitation />;
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="entete-page">
|
<div className="entete-page"><h1>Tableau de bord</h1></div>
|
||||||
<h1>Tableau de bord</h1>
|
{urgences.map((u) => (
|
||||||
</div>
|
<div className="bandeau-urgence" key={u.id}>
|
||||||
<div className="carte">
|
<span className="point-pulse" />
|
||||||
<h2>Fondations (R0)</h2>
|
<div>
|
||||||
<p>
|
<b>{u.title} — Ascenseur {u.assetReference}</b> ({u.siteName})
|
||||||
Bienvenue{me ? ` ${me.displayName}` : ''} — vous êtes connecté avec le
|
<br />
|
||||||
rôle <b>{me?.role.name}</b>. Cette coquille valide l'authentification,
|
<span className="depuis">
|
||||||
la bascule de rôle en un clic et le bi-thème ; les indicateurs
|
{u.reference} · signalé{' '}
|
||||||
d'exploitation arriveront avec la release R2.
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(u.createdAt))}
|
||||||
</p>
|
{u.assignees.length === 0 ? ' · aucune intervention en cours' : ''}
|
||||||
<p className="mt-2 text-encre-2">
|
|
||||||
La référence vivante du design est consultable sur la page{' '}
|
|
||||||
<Link to="/design" className="font-semibold text-primaire">
|
|
||||||
/design
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{me ? (
|
|
||||||
<div className="carte">
|
|
||||||
<h2>Vos droits (matrice en base)</h2>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{me.permissions
|
|
||||||
.filter((p) => p.canView)
|
|
||||||
.map((p) => (
|
|
||||||
<span key={p.objectCategory} className="st ouvert">
|
|
||||||
{p.objectCategory}
|
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginLeft: 'auto' }}>
|
||||||
|
<Button variant="prim" onClick={() => navigate(`/ot/${u.id}`)}>
|
||||||
|
Ouvrir l'OT d'urgence
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{me.permissions.every((p) => !p.canView) ? (
|
<div className="kpis">
|
||||||
<p className="text-encre-2">Ce rôle ne voit aucune catégorie d'objets.</p>
|
<div className="kpi">
|
||||||
) : null}
|
<div className="lib">OT ouverts</div>
|
||||||
|
<div className="val num">{ouverts.length}</div>
|
||||||
|
<div className="delta neutre">
|
||||||
|
dont {tous.filter((w) => w.status === 'IN_PROGRESS').length} en cours
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={enRetard.length ? 'kpi alarme' : 'kpi'}>
|
||||||
|
<div className="lib">En retard</div>
|
||||||
|
<div className="val num">{enRetard.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Préventif · {preventif ? mois(preventif.month) : '…'}</div>
|
||||||
|
<div className="val num">
|
||||||
|
{preventif && preventif.generated > 0
|
||||||
|
? `${Math.round((preventif.done / preventif.generated) * 100)} %`
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
<div className="delta bon">
|
||||||
|
{preventif ? `${preventif.done} / ${preventif.generated} grilles` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Demandes à traiter</div>
|
||||||
|
<div className="val num">{aTraiter.length}</div>
|
||||||
|
<div className="delta neutre">
|
||||||
|
{aTraiter[0]
|
||||||
|
? `la + ancienne : ${new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(aTraiter[aTraiter.length - 1]!.createdAt))}`
|
||||||
|
: 'file vide'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="carte">
|
||||||
|
<h2>OT par statut</h2>
|
||||||
|
<div className="barres-h flex flex-col gap-2">
|
||||||
|
{parStatut.map(({ statut, n }) => (
|
||||||
|
<div className="ligne-b" key={statut}>
|
||||||
|
<span>{WORK_ORDER_STATUS_LABELS[statut]}</span>
|
||||||
|
<div className="fond-b">
|
||||||
|
<div
|
||||||
|
className="rempli"
|
||||||
|
style={{ width: `${(n / max) * 100}%`, background: ST_COULEURS[statut] }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="valeur num">{n}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
||||||
|
<h2>Pannes par organe · 12 mois</h2>
|
||||||
|
<p className="text-encre-3">
|
||||||
|
Ce graphique s'alimentera des bilans codés — il arrive avec les analytics (R3).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Interventions récentes</h2>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>N°</th><th>Objet</th><th>Équipement</th><th>Type</th><th>Priorité</th><th>Statut</th><th>Assignés</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{recents.map((w) => (
|
||||||
|
<tr
|
||||||
|
key={w.id}
|
||||||
|
className={w.priority === 'PERSON_TRAPPED' ? 'ligne-bloque' : undefined}
|
||||||
|
onClick={() => navigate(`/ot/${w.id}`)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<td className="ref num">{w.reference}</td>
|
||||||
|
<td>{w.title}</td>
|
||||||
|
<td>
|
||||||
|
Asc. {w.assetReference}
|
||||||
|
<span className="sous">{w.siteName}</span>
|
||||||
|
</td>
|
||||||
|
<td>{WORK_ORDER_TYPE_LABELS[w.type]}</td>
|
||||||
|
<td><PrioriteOT priorite={w.priority} /></td>
|
||||||
|
<td><StatutOT statut={w.status} /></td>
|
||||||
|
<td>
|
||||||
|
{w.assignees.length ? (
|
||||||
|
<div className="avatars">
|
||||||
|
{w.assignees.map((a) => (
|
||||||
|
<Avatar key={a.id} initials={a.initials} className="h-6 w-6 text-[10px]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="entete-page"><h1>Mes signalements</h1></div>
|
||||||
|
<div className="carte">
|
||||||
|
<p>
|
||||||
|
Bienvenue{me ? ` ${me.displayName}` : ''} — retrouvez ici vos signalements
|
||||||
|
et leur avancement.
|
||||||
|
</p>
|
||||||
|
{can('REQUESTS', 'view') ? (
|
||||||
|
<p className="mt-2">
|
||||||
|
<Link to="/demandes" className="font-semibold text-primaire">
|
||||||
|
Voir mes demandes ({(requests ?? []).length})
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -750,6 +750,129 @@ table {
|
|||||||
.avatars .avatar { width: 24px; height: 24px; font-size: 10px; border: 2px solid var(--surface); margin-left: -6px; }
|
.avatars .avatar { width: 24px; height: 24px; font-size: 10px; border: 2px solid var(--surface); margin-left: -6px; }
|
||||||
.avatars .avatar:first-child { margin-left: 0; }
|
.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 ═══ */
|
/* ═══ Page /design : nuanciers ═══ */
|
||||||
.nuancier {
|
.nuancier {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -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
|
## 2026-07-16 — Pr. Daaif (+ Claude) — R2.2 : préventif (génération idempotente) + compteurs
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
@@ -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}": {
|
"/assets/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getAsset",
|
"operationId": "getAsset",
|
||||||
@@ -2692,6 +2718,41 @@
|
|||||||
],
|
],
|
||||||
"additionalProperties": false
|
"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": {
|
"AssetDetail": {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
AssetComponentCreateSchema,
|
AssetComponentCreateSchema,
|
||||||
AssetComponentSchema,
|
AssetComponentSchema,
|
||||||
AssetDetailSchema,
|
AssetDetailSchema,
|
||||||
|
AssetOptionsResponseSchema,
|
||||||
AssetsResponseSchema,
|
AssetsResponseSchema,
|
||||||
AssetUpdateSchema,
|
AssetUpdateSchema,
|
||||||
CategoriesResponseSchema,
|
CategoriesResponseSchema,
|
||||||
@@ -244,6 +245,20 @@ export const API_CONTRACT: ApiOperation[] = [
|
|||||||
200: { description: 'Liste', name: 'AssetsResponse', schema: AssetsResponseSchema },
|
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',
|
operationId: 'getAsset',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|||||||
@@ -97,6 +97,18 @@ export type AssetDto = z.infer<typeof AssetSchema>;
|
|||||||
export const AssetsResponseSchema = z.object({ assets: z.array(AssetSchema) });
|
export const AssetsResponseSchema = z.object({ assets: z.array(AssetSchema) });
|
||||||
export type AssetsResponse = z.infer<typeof AssetsResponseSchema>;
|
export type AssetsResponse = z.infer<typeof AssetsResponseSchema>;
|
||||||
|
|
||||||
|
/** 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<typeof AssetOptionsResponseSchema>;
|
||||||
|
|
||||||
export const AssetDetailSchema = AssetSchema.extend({
|
export const AssetDetailSchema = AssetSchema.extend({
|
||||||
components: z.array(AssetComponentSchema),
|
components: z.array(AssetComponentSchema),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user