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:
pr-daaif
2026-07-16 17:13:20 +01:00
parent 902efa6192
commit 6a2dae1710
26 changed files with 2274 additions and 93 deletions

View File

@@ -6,6 +6,13 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.request.deleteMany({ where: { description: { contains: 'E2E' } } });
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });
// Un appareil de test peut avoir reçu grilles/demandes (génération du mois) :
// on purge ses dépendances avant lui (FK RESTRICT).
await prisma.request.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
await prisma.workOrder.deleteMany({ where: { asset: { reference: { startsWith: 'E2E-' } } } });
await prisma.asset.deleteMany({ where: { reference: { startsWith: 'E2E-' } } });
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' }, parentId: { not: null } } });
await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } });

View File

@@ -31,6 +31,13 @@ export class AssetsController {
return this.assetsService.list();
}
/** Avant ':id' (ordre des routes) — authentification seule : le demandeur
* doit pouvoir désigner l'appareil qu'il signale. */
@Get('options')
options() {
return this.assetsService.options();
}
@Get(':id')
@RequirePermission('ASSETS', 'view')
get(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -11,6 +11,7 @@ import type {
AssetCreate,
AssetDetail,
AssetDto,
AssetOptionsResponse,
AssetsResponse,
AssetUpdate,
} from '@siop/shared';
@@ -28,6 +29,21 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
export class AssetsService {
constructor(private readonly prisma: PrismaService) {}
/** Options minimales pour le signalement — ouvert à tout rôle authentifié. */
async options(): Promise<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> {
const rows = await this.prisma.asset.findMany({
include: assetInclude,

View File

@@ -129,44 +129,57 @@ export class PreventiveService {
skipped++;
continue;
}
try {
const ot = await this.prisma.workOrder.create({
data: {
reference: await this.nextReference(),
title: premierControle
? `Premier contrôle — ${libelleMois}`
: `Grille du mois — ${libelleMois}`,
type: 'PREVENTIVE',
priority: 'LOW',
assetId: asset.id,
periodKey: period,
dueDate: dernierJour,
createdById: user.userId,
checklist: {
create: dues.map((t) => ({ label: t.label, templateId: t.id })),
},
events: {
create: {
kind: 'GENERATED',
message: `Généré (${premierControle ? 'premier contrôle' : 'grille mensuelle'} · ${dues.length} tâches)`,
byId: user.userId,
let issue: 'créé' | 'sauté' | null = null;
for (let essai = 0; essai < 4 && issue === null; essai++) {
try {
const ot = await this.prisma.workOrder.create({
data: {
reference: await this.nextReference(),
title: premierControle
? `Premier contrôle — ${libelleMois}`
: `Grille du mois — ${libelleMois}`,
type: 'PREVENTIVE',
priority: 'LOW',
assetId: asset.id,
periodKey: period,
dueDate: dernierJour,
createdById: user.userId,
checklist: {
create: dues.map((t) => ({ label: t.label, templateId: t.id })),
},
events: {
create: {
kind: 'GENERATED',
message: `Généré (${premierControle ? 'premier contrôle' : 'grille mensuelle'} · ${dues.length} tâches)`,
byId: user.userId,
},
},
},
},
select: { reference: true },
});
created++;
if (premierControle) firstControls++;
references.push(ot.reference);
} catch (e) {
// [assetId, periodKey] déjà pris → grille déjà en place : idempotence.
// (Collision de référence, rarissime : le prochain appel rattrape.)
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
skipped++;
continue;
select: { reference: true },
});
created++;
if (premierControle) firstControls++;
references.push(ot.reference);
issue = 'créé';
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
const cible = (e.meta?.target as string[] | string | undefined) ?? '';
if (String(cible).includes('periodKey')) {
issue = 'sauté'; // grille déjà en place : idempotence
}
// sinon : collision de référence (course avec une création d'OT)
// → on recalcule et on retente
} else if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2003'
) {
issue = 'sauté'; // appareil supprimé entre lecture et écriture
} else {
throw e;
}
}
throw e;
}
if (issue !== 'créé') skipped++;
}
this.logger.log(
`Préventif ${period} : ${created} générés, ${skipped} déjà en place, ${firstControls} premiers contrôles.`,

View File

@@ -80,18 +80,37 @@ describe('Préventif & compteurs (e2e)', () => {
where: { isActive: true, periodMonths: 1 },
});
// B2 n'avait aucun OT préventif avant 2031-01 → premier contrôle
const b2 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'B2' } });
const grilleB2 = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: b2.id, periodKey: '2031-01' } },
include: { _count: { select: { checklist: true } } },
// Un appareil VIERGE créé par ce test (aucune génération n'a pu le toucher)
const categorie = await prisma.category.findFirstOrThrow({ where: { kind: 'EQUIPMENT' } });
const site = await prisma.location.findFirstOrThrow({ where: { parentId: null } });
const vierge = await prisma.asset.create({
data: {
reference: `PVT-${Date.now().toString(36)}`,
brand: 'Test',
categoryId: categorie.id,
locationId: site.id,
},
});
expect(grilleB2.title).toContain('Premier contrôle');
expect(grilleB2._count.checklist).toBe(templatesActifs);
try {
await http()
.post('/preventive/generate')
.set(auth(admin))
.send({ month: '2031-09' })
.expect(200);
const grille = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: vierge.id, periodKey: '2031-09' } },
include: { _count: { select: { checklist: true } } },
});
expect(grille.title).toContain('Premier contrôle');
expect(grille._count.checklist).toBe(templatesActifs);
} finally {
await prisma.workOrder.deleteMany({ where: { assetId: vierge.id } });
await prisma.asset.delete({ where: { id: vierge.id } });
}
// A1 a un historique préventif (seed juillet) → grille normale.
// Mise en service mars 2020 : janvier 2031 n'est le mois ni du 12, ni du 3,
// ni du 6 mois (écart 130) → seules les tâches MENSUELLES sont dues.
// A1 a toujours un historique préventif (seed) → grille normale.
// Mise en service mars 2020 : janvier 2031 (écart 130 mois) n'est le mois
// ni du 3, ni du 6, ni du 12 → seules les tâches MENSUELLES sont dues.
const a1 = await prisma.asset.findUniqueOrThrow({ where: { reference: 'A1' } });
const grilleA1 = await prisma.workOrder.findUniqueOrThrow({
where: { assetId_periodKey: { assetId: a1.id, periodKey: '2031-01' } },

View 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');
});

View File

@@ -5,13 +5,19 @@ import { Coquille } from '@/layout/coquille';
import PageActivation from '@/pages/activation';
import PageAscenseurs from '@/pages/ascenseurs';
import PageCategories from '@/pages/categories';
import PageCompteurs from '@/pages/compteurs';
import PageConnexion from '@/pages/connexion';
import PageDemandes from '@/pages/demandes';
import PageDesign from '@/pages/design';
import PageEtiquette from '@/pages/etiquette';
import PageFicheAscenseur from '@/pages/fiche-ascenseur';
import PageFicheOT from '@/pages/fiche-ot';
import PageFicheSite from '@/pages/fiche-site';
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
import PageNouvelOT from '@/pages/nouvel-ot';
import PageOrdresTravail from '@/pages/ordres-travail';
import PagePersonnes from '@/pages/personnes';
import PagePreventif from '@/pages/preventif';
import PageSites from '@/pages/sites';
import PageTableauDeBord from '@/pages/tableau-de-bord';
@@ -36,8 +42,14 @@ export default function App() {
<Route path="/ascenseurs/nouveau" element={dansCoquille(<PageNouvelAscenseur />)} />
<Route path="/ascenseurs/:id" element={dansCoquille(<PageFicheAscenseur />)} />
<Route path="/ascenseurs/:id/etiquette" element={dansCoquille(<PageEtiquette />)} />
<Route path="/ascenseurs/:id/compteurs" element={dansCoquille(<PageCompteurs />)} />
<Route path="/personnes" element={dansCoquille(<PagePersonnes />)} />
<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>
</BrowserRouter>
</QueryClientProvider>

View 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,
});
}

View File

@@ -38,6 +38,14 @@ export function useAssets() {
});
}
/** Options minimales (signalement) — accessibles à tout rôle authentifié. */
export function useAssetOptions() {
return useQuery({
queryKey: ['asset-options'],
queryFn: async () => (await unwrap(await api.GET('/assets/options'))).options,
});
}
export function useAsset(id: string | undefined) {
return useQuery({
queryKey: ['assets', id],

View File

@@ -183,6 +183,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/assets/options": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Options dappareils pour le signalement (tout rôle authentifié) */
get: operations["listAssetOptions"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/assets/{id}": {
parameters: {
query?: never;
@@ -851,6 +868,14 @@ export interface components {
componentCount: number;
}[];
};
AssetOptionsResponse: {
options: {
/** Format: uuid */
id: string;
reference: string;
siteName: string;
}[];
};
AssetDetail: {
/** Format: uuid */
id: string;
@@ -1787,6 +1812,26 @@ export interface operations {
};
};
};
listAssetOptions: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Options */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AssetOptionsResponse"];
};
};
};
};
getAsset: {
parameters: {
query?: never;

View 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>;
}

View File

@@ -6,7 +6,9 @@ import { getToken } from '@/api/client';
import { useDemoAccounts, useDemoLogin, useLogout, useMe } from '@/auth/use-auth';
import { Avatar, initialsOf } from '@/components/avatar';
import { Logo } from '@/components/logo';
import { useQuery } from '@tanstack/react-query';
import type { ObjectCategory, PermissionRight } from '@siop/shared';
import { api } from '@/api/client';
import { usePermissions } from '@/auth/use-permissions';
import {
IcoAscenseurs,
@@ -40,6 +42,7 @@ interface LienNav {
route?: string;
release?: string;
permission?: [ObjectCategory, PermissionRight];
badge?: 'urgences' | 'demandes';
}
const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
@@ -47,9 +50,9 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
groupe: 'Exploitation',
liens: [
{ libelle: 'Tableau de bord', icone: IcoTableauDeBord, route: '/' },
{ libelle: 'Ordres de travail', icone: IcoOrdresDeTravail, release: 'R2' },
{ libelle: 'Demandes', icone: IcoDemandes, release: 'R2' },
{ libelle: 'Préventif', icone: IcoPreventif, release: 'R2' },
{ libelle: 'Ordres de travail', icone: IcoOrdresDeTravail, route: '/ot', permission: ['WORK_ORDERS', 'view'], badge: 'urgences' },
{ libelle: 'Demandes', icone: IcoDemandes, route: '/demandes', permission: ['REQUESTS', 'view'], badge: 'demandes' },
{ libelle: 'Préventif', icone: IcoPreventif, route: '/preventif', permission: ['WORK_ORDERS', 'view'] },
],
},
{
@@ -83,6 +86,29 @@ const THEME_ICONS: Record<ThemeMode, typeof Sun> = {
dark: Moon,
};
/** L'urgence « personne bloquée » et la file de demandes — visibles partout
* dans la coquille (chip topbar + badges), selon les droits du rôle. */
function useSignauxExploitation(peutVoirOT: boolean, peutVoirDemandes: boolean) {
const { data: workOrders } = useQuery({
queryKey: ['work-orders'],
enabled: peutVoirOT,
refetchInterval: 60_000,
queryFn: async () => (await api.GET('/work-orders')).data?.workOrders ?? [],
});
const { data: requests } = useQuery({
queryKey: ['requests'],
enabled: peutVoirDemandes,
refetchInterval: 60_000,
queryFn: async () => (await api.GET('/requests')).data?.requests ?? [],
});
return {
urgences: (workOrders ?? []).filter(
(w) => w.priority === 'PERSON_TRAPPED' && w.status !== 'DONE' && w.status !== 'CANCELLED',
),
demandesATraiter: (requests ?? []).filter((r) => r.status === 'RECEIVED').length,
};
}
export function Coquille({ children }: { children: ReactNode }) {
const navigate = useNavigate();
const { data: me } = useMe();
@@ -91,9 +117,18 @@ export function Coquille({ children }: { children: ReactNode }) {
const logout = useLogout();
const { can } = usePermissions();
const [themeMode, setThemeMode] = useState<ThemeMode>(getThemeMode);
const { urgences, demandesATraiter } = useSignauxExploitation(
can('WORK_ORDERS', 'view'),
can('REQUESTS', 'view'),
);
if (!getToken()) return <Navigate to="/connexion" replace />;
const badges: Record<'urgences' | 'demandes', number> = {
urgences: urgences.length,
demandes: demandesATraiter,
};
const cyclerTheme = () => {
const mode = nextThemeMode(themeMode);
applyThemeMode(mode);
@@ -116,7 +151,7 @@ export function Coquille({ children }: { children: ReactNode }) {
<div className="groupe">{groupe}</div>
{liens
.filter(({ permission }) => !permission || can(...permission))
.map(({ libelle, icone: Icone, route, release }) =>
.map(({ libelle, icone: Icone, route, release, badge }) =>
route ? (
<NavLink
key={libelle}
@@ -126,6 +161,9 @@ export function Coquille({ children }: { children: ReactNode }) {
>
<Icone />
{libelle}
{badge && badges[badge] > 0 ? (
<span className="badge-nav num">{badges[badge]}</span>
) : null}
</NavLink>
) : (
<a
@@ -151,6 +189,19 @@ export function Coquille({ children }: { children: ReactNode }) {
<IcoRecherche />
Rechercher un OT, un ascenseur, un site&ensp;K
</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
type="button"
className="btn ghost"

View 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>
);
}

View 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>
);
}

View File

@@ -2,6 +2,7 @@ import { QRCodeSVG } from 'qrcode.react';
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
import { useWorkOrders } from '@/api/exploitation';
import {
useAddComponent,
useAsset,
@@ -10,6 +11,7 @@ import {
useUpdateAsset,
} from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { StatutOT } from '@/components/chips-ot';
import { StatutEquipement } from '@/components/statut-equipement';
import { Button } from '@/components/ui/button';
import { Modale } from '@/components/ui/modale';
@@ -51,6 +53,9 @@ export default function PageFicheAscenseur() {
))}
</select>
) : null}
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/compteurs`)}>
Compteurs
</Button>
<Button onClick={() => navigate(`/ascenseurs/${asset.id}/etiquette`)}>
Étiquette QR
</Button>
@@ -129,12 +134,7 @@ export default function PageFicheAscenseur() {
) : null}
</div>
<div className="flex flex-col gap-4">
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Historique</h2>
<p className="text-encre-3">
Les interventions de cet appareil apparaîtront ici avec lexploitation (R2).
</p>
</div>
<CarteHistorique assetId={asset.id} />
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Documents</h2>
<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({
assetId,
ouverte,

View 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 dannulation (visible dans lactivité) :') ?? 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View File

@@ -1,49 +1,206 @@
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import {
WORK_ORDER_STATUS_LABELS,
WORK_ORDER_TYPE_LABELS,
type WorkOrderStatus,
} from '@siop/shared';
import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation';
import { useMe } from '@/auth/use-auth';
import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar';
import { PrioriteOT, StatutOT } from '@/components/chips-ot';
import { Button } from '@/components/ui/button';
/** R0 : coquille seulement — le vrai tableau de bord (KPI, urgences,
* graphiques) arrive avec l'exploitation (R2), conformément au plan. */
const ST_COULEURS: Record<string, string> = {
OPEN: 'var(--st-ouvert)',
IN_PROGRESS: 'var(--st-encours)',
ON_HOLD: 'var(--st-attente)',
DONE: 'var(--st-termine)',
};
/** Écran 2 validé R0 — alimenté par les données réelles de R2.
* (Coûts → R3 ; pannes par organe → analytics R3.) */
export default function PageTableauDeBord() {
const { data: me } = useMe();
const navigate = useNavigate();
const { can, chargé } = usePermissions();
const peutVoirOT = can('WORK_ORDERS', 'view');
const { data: workOrders } = useWorkOrders(peutVoirOT);
const { data: requests } = useRequests();
const { data: preventif } = usePreventiveStatus(peutVoirOT);
if (!chargé) return null;
if (!peutVoirOT) return <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 (
<>
<div className="entete-page">
<h1>Tableau de bord</h1>
</div>
<div className="carte">
<h2>Fondations (R0)</h2>
<p>
Bienvenue{me ? ` ${me.displayName}` : ''} vous êtes connecté avec le
rôle <b>{me?.role.name}</b>. Cette coquille valide l'authentification,
la bascule de rôle en un clic et le bi-thème ; les indicateurs
d'exploitation arriveront avec la release R2.
</p>
<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>
))}
{me.permissions.every((p) => !p.canView) ? (
<p className="text-encre-2">Ce rôle ne voit aucune catégorie d'objets.</p>
) : null}
<div className="entete-page"><h1>Tableau de bord</h1></div>
{urgences.map((u) => (
<div className="bandeau-urgence" key={u.id}>
<span className="point-pulse" />
<div>
<b>{u.title} Ascenseur {u.assetReference}</b> ({u.siteName})
<br />
<span className="depuis">
{u.reference} · signalé{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(u.createdAt))}
{u.assignees.length === 0 ? ' · aucune intervention en cours' : ''}
</span>
</div>
<div style={{ marginLeft: 'auto' }}>
<Button variant="prim" onClick={() => navigate(`/ot/${u.id}`)}>
Ouvrir l'OT d'urgence
</Button>
</div>
</div>
) : null}
))}
<div className="kpis">
<div className="kpi">
<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>
</>
);
}
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>
</>
);
}

View File

@@ -750,6 +750,129 @@ table {
.avatars .avatar { width: 24px; height: 24px; font-size: 10px; border: 2px solid var(--surface); margin-left: -6px; }
.avatars .avatar:first-child { margin-left: 0; }
/* ═══ R2 — Exploitation (classes extraites des maquettes validées) ═══ */
/* Statuts de DEMANDE (validés R2 : extension cohérente des couleurs) */
.st.recue { color: var(--st-ouvert); background: var(--st-ouvert-fond); }
.st.approuvee { color: var(--st-encours); background: var(--st-encours-fond); }
.st.rejetee { color: var(--st-annule); background: var(--st-annule-fond); }
.st.resolue { color: var(--st-termine); background: var(--st-termine-fond); }
/* Urgence « personne bloquée » — seule animation continue de l'app */
.chip-urgence {
display: flex;
align-items: center;
gap: 7px;
background: var(--prio-bloque-fond);
color: var(--prio-bloque);
border: 1px solid color-mix(in srgb, var(--prio-bloque) 30%, transparent);
padding: 6px 12px;
border-radius: var(--rayon-pastille);
font-weight: 700;
font-size: 12.5px;
}
.bandeau-urgence {
display: flex;
align-items: center;
gap: 14px;
background: var(--prio-bloque-fond);
border: 1px solid color-mix(in srgb, var(--prio-bloque) 35%, transparent);
border-left: 4px solid var(--prio-bloque);
border-radius: var(--rayon);
padding: 12px 16px;
}
.bandeau-urgence b { color: var(--prio-bloque); }
.bandeau-urgence .depuis { color: var(--encre-2); font-size: 12.5px; }
/* Tuiles KPI (tableau de bord + préventif) */
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
@media (max-width: 1100px) { .kpis { grid-template-columns: repeat(2, 1fr); } }
.kpi { background: var(--surface); border: 1px solid var(--bordure); border-radius: var(--rayon); padding: 14px 16px; }
.kpi .lib { font-size: 11.5px; letter-spacing: 0.05em; text-transform: uppercase; color: var(--encre-2); font-weight: 600; }
.kpi .val { font-size: 27px; font-weight: 700; letter-spacing: -0.02em; margin-top: 4px; }
.kpi .delta { font-size: 12px; font-weight: 600; margin-top: 2px; }
.delta.bon { color: var(--succes); }
.delta.mauvais { color: var(--danger); }
.delta.neutre { color: var(--encre-3); }
.kpi.alarme { border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
.kpi.alarme .val { color: var(--danger); }
/* Barres horizontales (OT par statut) */
.barres-h .ligne-b { display: grid; grid-template-columns: 110px 1fr 40px; align-items: center; gap: 10px; font-size: 13px; }
.barres-h .fond-b { background: var(--graf-grille); border-radius: 4px; height: 14px; overflow: hidden; }
.barres-h .rempli { height: 100%; border-radius: 4px 0 0 4px; }
.barres-h .valeur { text-align: right; font-weight: 600; }
/* Activité chronologique */
.chrono { list-style: none; display: flex; flex-direction: column; gap: 0; font-size: 13px; padding: 0; }
.chrono li { position: relative; padding: 0 0 16px 22px; }
.chrono li::before { content: ''; position: absolute; left: 5px; top: 5px; width: 8px; height: 8px; border-radius: 50%; background: var(--bordure-forte); }
.chrono li.fait::before { background: var(--primaire); }
.chrono li::after { content: ''; position: absolute; left: 8.5px; top: 15px; bottom: -2px; width: 1px; background: var(--bordure); }
.chrono li:last-child::after { display: none; }
.chrono .quand { color: var(--encre-3); font-size: 11.5px; }
/* Checklist Fait / N-A */
.check { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--bordure); font-size: 13.5px; }
.check:last-of-type { border-bottom: 0; }
.coche {
width: 19px; height: 19px; border-radius: 5px; border: 1.5px solid var(--bordure-forte);
flex: none; display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 11px; background: none; padding: 0; cursor: pointer;
}
.coche.ok { background: var(--succes); border-color: var(--succes); }
.coche.na { background: var(--encre-3); border-color: var(--encre-3); font-size: 8.5px; }
.check .qui-fait { margin-left: auto; color: var(--encre-3); font-size: 12px; white-space: nowrap; }
/* Bilan codé (6 champs) */
.bilan { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; }
@media (max-width: 900px) { .bilan { grid-template-columns: 1fr; } }
.bilan .champ-b { font-size: 12.5px; }
.bilan .champ-b label { color: var(--encre-2); font-weight: 600; display: block; margin-bottom: 4px; }
.bilan .champ-b label em { color: var(--danger); font-style: normal; }
.bilan select {
width: 100%; padding: 8px 11px; border: 1px solid var(--bordure-forte);
border-radius: var(--rayon-controle); font: inherit; background: var(--surface); color: var(--encre);
}
.avertissement { margin-top: 12px; font-size: 12.5px; color: var(--alerte); }
/* Préventif */
.periode {
display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 700;
color: var(--primaire); background: var(--primaire-doux); padding: 2px 9px; border-radius: var(--rayon-pastille);
}
.periode.reglementaire { color: var(--safran); background: var(--safran-doux); }
.gen-info { border-left: 3px solid var(--succes); }
/* Panneau d'approbation (demandes) */
.panneau { border: 1px solid var(--bordure); border-radius: var(--rayon); background: var(--surface); padding: 16px 18px; display: flex; flex-direction: column; gap: 12px; }
/* Interrupteur (personne bloquée) */
.gros-toggle {
display: flex; align-items: center; gap: 12px; background: var(--prio-bloque-fond);
border: 1px solid color-mix(in srgb, var(--prio-bloque) 30%, transparent);
border-radius: 10px; padding: 12px 14px;
}
.gros-toggle b { color: var(--prio-bloque); }
.interrupteur {
margin-left: auto; width: 44px; height: 24px; border-radius: var(--rayon-pastille);
background: var(--bordure-forte); position: relative; flex: none; border: 0; cursor: pointer;
transition: background var(--transition);
}
.interrupteur::after {
content: ''; position: absolute; top: 2px; left: 2px; width: 20px; height: 20px;
border-radius: 50%; background: #fff; transition: left var(--transition);
}
.interrupteur[aria-checked='true'] { background: var(--prio-bloque); }
.interrupteur[aria-checked='true']::after { left: 22px; }
/* Relevés de compteurs */
.releve-saisie { display: flex; gap: 8px; align-items: center; }
.releve-saisie input {
width: 140px; padding: 8px 11px; border: 1px solid var(--bordure-forte);
border-radius: var(--rayon-controle); font: inherit; background: var(--surface);
color: var(--encre); text-align: right;
}
/* ═══ Page /design : nuanciers ═══ */
.nuancier {
display: flex;