diff --git a/CLAUDE.md b/CLAUDE.md index 25c01a4..02a6f48 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,5 +51,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - 🏁 **R2 CLOSE (16/07/2026, tag `release/r2`)** : recettée (1 anomalie corrigée en recette : tri « Interventions récentes »), déployée et vérifiée en ligne (portail `/q/A1`, urgence en tête, préventif de juillet généré). - ✅ **R3 — maquettes validées** (16/07) + **R3.1 socle backend gestion** : migration `r3_gestion` (Partner, Part sans colonne de quantité, StockMovement signé/tracé/PU figé, PurchaseOrder, LaborTime taux figé, Document, User.hourlyRate) ; 66 opérations ; stock = Σ mouvements (jamais négatif, en transaction), réception BC → RECEIPT + lastUnitPrice, conso/MO à prix/taux FIGÉS, `WorkOrderDetail.costs` immuable après clôture ; seed maquette (OT-0341 = 505 MAD, testé) ; 55 tests (92 %/74,9 %). **Durcissement : références par séquences Postgres** (fin des courses max+1). - ✅ **R3.2 — bibliothèque + analytics** : `FileStorage` complet (put/stream/remove, bucket auto), upload multipart typé (20 Mo, rattachement requis, permission sur la cible), download **streamé par l'API**, octets vérifiés à l'identique en e2e ; `GET /analytics/summary` dérivé du réel (pannes par organe via bilans codés, coûts figés, taux préventif, top équipements) ; MinIO en CI ; 71 opérations, 58 tests (6 runs consécutifs verts). -- 🔄 **R3.3 — reprise ici** : écrans web fidèles à maquette-r3 (stock + alertes seuil, fiche pièce/mouvements, BC + panneau réception, coûts réels sur fiche OT [consommer/saisir temps], statistiques, tiers, bibliothèque + carte Documents des fiches, taux horaire dans Personnes) → e2e Playwright recette R3 → recette référent + déploiement + tag `release/r3`. +- ✅ **R3.3** : 7 écrans web fidèles à maquette-r3 (stock/alertes, fiche pièce, BC + réception, création BC préremplie depuis l'alerte, tiers, bibliothèque, statistiques) + fiche OT complète (coûts réels : consommer à prix figé / saisir temps à taux figé ; carte Documents) + fiche ascenseur (Documents) + taux horaire dans Personnes + nav Ressources/Statistiques activée par la matrice. Recette R3 automatisée (13/13 e2e Playwright). +- 🔄 **Reprise ici** : recette R3 avec le référent (revue pixel écrans ↔ maquette-r3) → vérification sur le déploiement Dokploy → tag `release/r3` → ouverture R4 Mobile (Expo, design d'abord). - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5. diff --git a/apps/api/prisma/cleanup-e2e.ts b/apps/api/prisma/cleanup-e2e.ts index 12e6770..274d9fb 100644 --- a/apps/api/prisma/cleanup-e2e.ts +++ b/apps/api/prisma/cleanup-e2e.ts @@ -6,6 +6,39 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); async function main() { + // R3 — d'abord les traces de gestion (les mouvements liés à un OT E2E + // seraient orphelins après sa suppression et fausseraient les stocks seedés) + await prisma.stockMovement.deleteMany({ + where: { + OR: [ + { part: { designation: { contains: 'E2E' } } }, + { workOrder: { title: { contains: 'E2E' } } }, + { purchaseOrder: { supplier: { name: { contains: 'E2E' } } } }, + ], + }, + }); + await prisma.document.deleteMany({ + where: { + OR: [ + { fileName: { contains: 'e2e' } }, + { workOrder: { title: { contains: 'E2E' } } }, + ], + }, + }); + await prisma.purchaseOrderLine.deleteMany({ + where: { + OR: [ + { part: { designation: { contains: 'E2E' } } }, + { purchaseOrder: { supplier: { name: { contains: 'E2E' } } } }, + ], + }, + }); + await prisma.purchaseOrder.deleteMany({ + where: { supplier: { name: { contains: 'E2E' } } }, + }); + await prisma.part.deleteMany({ where: { designation: { contains: 'E2E' } } }); + await prisma.partner.deleteMany({ where: { name: { contains: 'E2E' } } }); + 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 } } }); diff --git a/apps/web/e2e/parcours-r3.spec.ts b/apps/web/e2e/parcours-r3.spec.ts new file mode 100644 index 0000000..019f272 --- /dev/null +++ b/apps/web/e2e/parcours-r3.spec.ts @@ -0,0 +1,159 @@ +import { expect, test } from '@playwright/test'; + +/** + * Recette R3 (plan de releases) : pièce sous le seuil → BC prérempli → + * réception (le stock remonte, l'alerte s'éteint) ; coûts réels sur OT + * (prix figé + taux figé) ; document rattaché téléversé puis téléchargé ; + * statistiques lisibles par la Vue seule. + * Chaque test crée SES données (marquées E2E, purgées au global-setup). + */ + +const suffix = Date.now().toString(36); + +/** PNG 1×1 valide — assez pour tester upload/download réels via MinIO. */ +const PNG_1PX = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', +); + +async function connexionDemo(page: import('@playwright/test').Page, nom: RegExp) { + await page.goto('/connexion'); + await page.getByRole('button', { name: nom }).click(); + await expect(page.locator('.topbar')).toBeVisible(); +} + +async function changerDeCompte(page: import('@playwright/test').Page, nom: RegExp) { + await page.locator('.compte').click(); + await page.getByRole('menuitem', { name: nom }).click(); +} + +test('recette R3 : tiers → pièce sous seuil → BC prérempli → réception → alerte éteinte', async ({ + page, +}) => { + const fournisseur = `E2E Fournisseur ${suffix}`; + const piece = `E2E Pièce ${suffix}`; + + // 1 · Nadia (gestionnaire) crée le fournisseur + await connexionDemo(page, /Nadia Berrada/); + await page.getByRole('link', { name: 'Tiers' }).click(); + await page.getByRole('button', { name: '+ Nouveau tiers' }).click(); + await page.getByLabel('Nom *').fill(fournisseur); + await page.getByLabel('Type *').selectOption('SUPPLIER'); + await page.getByRole('button', { name: 'Créer', exact: true }).click(); + await expect(page.getByRole('cell', { name: fournisseur })).toBeVisible(); + + // 2 · Nouvelle pièce (seuil 5, prix initial 100) → fiche + await page.getByRole('link', { name: 'Stock & achats' }).click(); + await page.getByRole('button', { name: '+ Nouvelle pièce' }).click(); + await page.getByLabel('Désignation *').fill(piece); + await page.getByLabel("Seuil d'alerte").fill('5'); + await page.getByLabel('Prix initial (MAD)').fill('100'); + await page.getByLabel('Fournisseur').selectOption({ label: fournisseur }); + await page.getByRole('button', { name: 'Créer la pièce' }).click(); + await expect(page.getByRole('heading', { name: piece })).toBeVisible(); + + // 3 · Entrée de 2 → stock 2 < seuil 5 : alerte + await page.getByRole('button', { name: 'Entrée de stock' }).click(); + await page.getByLabel(/Quantité/).fill('2'); + await page.getByRole('button', { name: 'Tracer le mouvement' }).click(); + await expect(page.locator('.entete-page .stock-bas')).toHaveText('Sous le seuil'); + await expect(page.locator('dd', { hasText: 'seuil : 5' })).toContainText('2'); + + // 4 · « Préparer le BC » → formulaire prérempli (fournisseur, qté 8, PU 100) + await page.getByRole('button', { name: 'Préparer le BC' }).click(); + await expect(page.getByRole('heading', { name: 'Nouveau bon de commande' })).toBeVisible(); + await expect(page.getByLabel('Fournisseur *')).toHaveValue(/.+/); + await expect(page.getByLabel('Quantité de la ligne 1')).toHaveValue('8'); // seuil − stock + 5 + await expect(page.getByLabel('Prix unitaire de la ligne 1')).toHaveValue('100'); + await page.getByRole('button', { name: 'Créer le BC (brouillon)' }).click(); + + // 5 · Envoyer puis réceptionner — le panneau suit le BC + const ligneBC = page.locator('tr', { hasText: fournisseur }).first(); + await ligneBC.click(); + await expect(page.locator('.panneau .st')).toHaveText('Brouillon'); + await page.getByRole('button', { name: 'Envoyer au fournisseur' }).click(); + await expect(page.locator('.panneau .st')).toHaveText('Envoyé'); + await page.getByRole('button', { name: /Réceptionner/ }).click(); + await expect(page.locator('.panneau .st')).toHaveText('Reçu'); + + // 6 · Le stock est remonté (2 + 8 = 10), l'alerte est éteinte + await page.getByRole('link', { name: 'Stock & achats' }).click(); + const lignePiece = page.locator('tr', { hasText: piece }); + await expect(lignePiece.locator('td').nth(2)).toHaveText('10'); + await expect(lignePiece.locator('.stock-bas')).toHaveCount(0); + + // 7 · La fiche pièce raconte tout : entrée manuelle + réception à PU figé + await lignePiece.click(); + await expect(page.locator('.mvt.entree')).toHaveCount(2); + await expect(page.getByText('100 MAD/u')).toBeVisible(); +}); + +test('recette R3 : coûts réels sur OT (prix + taux figés) + document + statistiques', async ({ + page, +}) => { + const piece = `E2E PièceOT ${suffix}`; + const titreOT = `E2E ${suffix} — coûts`; + + // 1 · Nadia crée une pièce avec du stock (10 à 100 MAD) + await connexionDemo(page, /Nadia Berrada/); + await page.getByRole('link', { name: 'Stock & achats' }).click(); + await page.getByRole('button', { name: '+ Nouvelle pièce' }).click(); + await page.getByLabel('Désignation *').fill(piece); + await page.getByLabel('Prix initial (MAD)').fill('100'); + await page.getByRole('button', { name: 'Créer la pièce' }).click(); + await expect(page.getByRole('heading', { name: piece })).toBeVisible(); + await page.getByRole('button', { name: 'Entrée de stock' }).click(); + await page.getByLabel(/Quantité/).fill('10'); + await page.getByRole('button', { name: 'Tracer le mouvement' }).click(); + await expect(page.locator('dd', { hasText: 'seuil : 0' })).toContainText('10'); + + // 2 · Salma (dispatcher) crée l'OT et l'assigne à Ahmed + await changerDeCompte(page, /Salma Idrissi/); + await page.getByRole('link', { name: /Ordres de travail/ }).click(); + await page.getByRole('button', { name: '+ Nouvel OT' }).click(); + await page.getByLabel('Objet *').fill(titreOT); + await page.getByLabel('Équipement *').selectOption({ index: 1 }); + await page.getByLabel('Assigner à').selectOption({ label: 'Ahmed Benali' }); + await page.getByRole('button', { name: "Créer l'OT (statut Ouvert)" }).click(); + await expect(page.getByRole('heading', { name: titreOT })).toBeVisible(); + + // 3 · Ahmed (technicien) démarre, consomme 2 pièces, saisit 1 h 30 + await changerDeCompte(page, /Ahmed Benali/); + await page.getByRole('link', { name: /Ordres de travail/ }).click(); + await page.getByRole('cell', { name: titreOT }).click(); + await page.getByRole('button', { name: 'Démarrer' }).click(); + await expect(page.locator('.entete-page .st')).toHaveText('En cours'); + + await page.getByRole('button', { name: '+ Consommer une pièce' }).click(); + await page.getByLabel('Pièce *').selectOption({ label: `${piece} — stock : 10` }); + await expect(page.getByText('Prix figé à la consommation')).toBeVisible(); + await page.getByLabel('Quantité *').fill('2'); + await page.getByRole('button', { name: /^Consommer/ }).click(); + await expect(page.locator('.item-liste', { hasText: `${piece} × 2` })).toBeVisible(); + + await page.getByRole('button', { name: '+ Saisir du temps' }).click(); + await page.getByLabel('Heures').fill('1'); + await page.getByLabel('Minutes').fill('30'); + await page.getByRole('button', { name: 'Enregistrer' }).click(); + // 2 × 100 + 1,5 h × 120 MAD/h (taux seedé d'Ahmed) = 380 MAD + await expect(page.getByText('1 h 30 × 120 MAD/h')).toBeVisible(); + await expect(page.locator('.total .num')).toHaveText('380 MAD'); + + // 4 · Ahmed joint une photo — puis la retélécharge (aller-retour MinIO réel) + await page.getByLabel('Choisir un fichier').setInputFiles({ + name: `photo-e2e-${suffix}.png`, + mimeType: 'image/png', + buffer: PNG_1PX, + }); + await expect(page.getByText(`photo-e2e-${suffix}.png`)).toBeVisible(); + const attenteTelechargement = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Télécharger' }).click(); + expect((await attenteTelechargement).suggestedFilename()).toBe(`photo-e2e-${suffix}.png`); + + // 5 · Rachid (vue seule) lit les statistiques + await changerDeCompte(page, /Rachid Bennis/); + await page.getByRole('link', { name: 'Statistiques', exact: true }).click(); + await expect(page.getByText('OT clôturés · 12 mois')).toBeVisible(); + await expect(page.getByText('Pannes par organe · 12 mois')).toBeVisible(); + await expect(page.getByText('Top équipements en coût · 12 mois')).toBeVisible(); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b83dfe2..5657ca1 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,8 +2,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; import type { ReactNode } from 'react'; import { Coquille } from '@/layout/coquille'; +import PageAchats from '@/pages/achats'; import PageActivation from '@/pages/activation'; import PageAscenseurs from '@/pages/ascenseurs'; +import PageBibliotheque from '@/pages/bibliotheque'; import PageCategories from '@/pages/categories'; import PageCompteurs from '@/pages/compteurs'; import PageConnexion from '@/pages/connexion'; @@ -12,7 +14,9 @@ import PageDesign from '@/pages/design'; import PageEtiquette from '@/pages/etiquette'; import PageFicheAscenseur from '@/pages/fiche-ascenseur'; import PageFicheOT from '@/pages/fiche-ot'; +import PageFichePiece from '@/pages/fiche-piece'; import PageFicheSite from '@/pages/fiche-site'; +import PageNouvelAchat from '@/pages/nouvel-achat'; import PageNouvelAscenseur from '@/pages/nouvel-ascenseur'; import PageNouvelOT from '@/pages/nouvel-ot'; import PageOrdresTravail from '@/pages/ordres-travail'; @@ -20,7 +24,10 @@ import PagePersonnes from '@/pages/personnes'; import PagePortail from '@/pages/portail'; import PagePreventif from '@/pages/preventif'; import PageSites from '@/pages/sites'; +import PageStatistiques from '@/pages/statistiques'; +import PageStock from '@/pages/stock'; import PageTableauDeBord from '@/pages/tableau-de-bord'; +import PageTiers from '@/pages/tiers'; const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false } }, @@ -53,6 +60,13 @@ export default function App() { )} /> )} /> )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> diff --git a/apps/web/src/api/gestion.ts b/apps/web/src/api/gestion.ts new file mode 100644 index 0000000..11e619a --- /dev/null +++ b/apps/web/src/api/gestion.ts @@ -0,0 +1,243 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + ConsumePart, + LaborTimeCreate, + PartCreate, + PartnerCreate, + PartnerUpdate, + PartUpdate, + PurchaseOrderCreate, + PurchaseOrderTransition, + StockMovementCreate, +} from '@siop/shared'; +import { api, getToken } from './client'; + +/** Hooks R3 — client typé ; l'upload passe par FormData (multipart). */ + +async function unwrap(res: { data?: T; error?: unknown; response: Response }): Promise { + if (res.error || res.data === undefined) { + const message = + (res.error as { message?: string } | undefined)?.message ?? + `Le serveur a répondu ${res.response.status}`; + throw new Error(Array.isArray(message) ? message.join(' — ') : message); + } + return res.data; +} + +function useInvalidate(...keys: string[]) { + const queryClient = useQueryClient(); + return () => + Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] }))); +} + +// ————— Tiers ————— + +export function usePartners(enabled = true) { + return useQuery({ + queryKey: ['partners'], + enabled, + queryFn: async () => (await unwrap(await api.GET('/partners'))).partners, + }); +} + +export function useCreatePartner() { + const invalidate = useInvalidate('partners'); + return useMutation({ + mutationFn: async (body: PartnerCreate) => unwrap(await api.POST('/partners', { body })), + onSuccess: invalidate, + }); +} + +export function useUpdatePartner() { + const invalidate = useInvalidate('partners'); + return useMutation({ + mutationFn: async ({ id, ...body }: PartnerUpdate & { id: string }) => + unwrap(await api.PATCH('/partners/{id}', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} + +// ————— Pièces & stock ————— + +export function useParts(enabled = true) { + return useQuery({ + queryKey: ['parts'], + enabled, + queryFn: async () => (await unwrap(await api.GET('/parts'))).parts, + }); +} + +export function usePart(id: string | undefined) { + return useQuery({ + queryKey: ['parts', id], + enabled: !!id, + queryFn: async () => + unwrap(await api.GET('/parts/{id}', { params: { path: { id: id! } } })), + }); +} + +export function useCreatePart() { + const invalidate = useInvalidate('parts'); + return useMutation({ + mutationFn: async (body: PartCreate) => unwrap(await api.POST('/parts', { body })), + onSuccess: invalidate, + }); +} + +export function useUpdatePart(id: string) { + const invalidate = useInvalidate('parts'); + return useMutation({ + mutationFn: async (body: PartUpdate) => + unwrap(await api.PATCH('/parts/{id}', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} + +export function useAddStockMovement(id: string) { + const invalidate = useInvalidate('parts'); + return useMutation({ + mutationFn: async (body: StockMovementCreate) => + unwrap(await api.POST('/parts/{id}/movements', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} + +// ————— Bons de commande ————— + +export function usePurchaseOrders(enabled = true) { + return useQuery({ + queryKey: ['purchase-orders'], + enabled, + queryFn: async () => + (await unwrap(await api.GET('/purchase-orders'))).purchaseOrders, + }); +} + +export function useCreatePurchaseOrder() { + const invalidate = useInvalidate('purchase-orders'); + return useMutation({ + mutationFn: async (body: PurchaseOrderCreate) => + unwrap(await api.POST('/purchase-orders', { body })), + onSuccess: invalidate, + }); +} + +export function useTransitionPurchaseOrder() { + const invalidate = useInvalidate('purchase-orders', 'parts'); + return useMutation({ + mutationFn: async ({ id, ...body }: PurchaseOrderTransition & { id: string }) => + unwrap( + await api.POST('/purchase-orders/{id}/transition', { params: { path: { id } }, body }), + ), + onSuccess: invalidate, + }); +} + +// ————— Coûts sur OT ————— + +export function useConsumePart(workOrderId: string) { + const invalidate = useInvalidate('work-orders', 'parts'); + return useMutation({ + mutationFn: async (body: ConsumePart) => + unwrap( + await api.POST('/work-orders/{id}/consume-part', { + params: { path: { id: workOrderId } }, + body, + }), + ), + onSuccess: invalidate, + }); +} + +export function useAddLaborTime(workOrderId: string) { + const invalidate = useInvalidate('work-orders'); + return useMutation({ + mutationFn: async (body: LaborTimeCreate) => + unwrap( + await api.POST('/work-orders/{id}/labor', { + params: { path: { id: workOrderId } }, + body, + }), + ), + onSuccess: invalidate, + }); +} + +// ————— Bibliothèque ————— + +export function useDocuments(filtres: { assetId?: string; workOrderId?: string; kind?: string } = {}) { + return useQuery({ + queryKey: ['documents', filtres], + queryFn: async () => + ( + await unwrap( + await api.GET('/documents', { params: { query: filtres } }), + ) + ).documents, + }); +} + +export function useUploadDocument() { + const invalidate = useInvalidate('documents'); + return useMutation({ + mutationFn: async (input: { + file: File; + kind: string; + assetId?: string; + workOrderId?: string; + }) => { + const form = new FormData(); + form.append('file', input.file); + form.append('kind', input.kind); + if (input.assetId) form.append('assetId', input.assetId); + if (input.workOrderId) form.append('workOrderId', input.workOrderId); + const res = await fetch('/api/documents', { + method: 'POST', + headers: { Authorization: `Bearer ${getToken()}` }, + body: form, + }); + if (!res.ok) { + const corps = (await res.json().catch(() => null)) as { message?: string } | null; + throw new Error(corps?.message ?? `Téléversement refusé (${res.status})`); + } + return res.json(); + }, + onSuccess: invalidate, + }); +} + +export function useDeleteDocument() { + const invalidate = useInvalidate('documents'); + return useMutation({ + mutationFn: async (id: string) => { + const res = await api.DELETE('/documents/{id}', { params: { path: { id } } }); + if (res.response.status >= 400) throw new Error('Suppression impossible'); + }, + onSuccess: invalidate, + }); +} + +/** Téléchargement authentifié (un lien nu ne porte pas le jeton). */ +export async function telechargerDocument(id: string, fileName: string): Promise { + const res = await fetch(`/api/documents/${id}/download`, { + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (!res.ok) throw new Error('Téléchargement impossible'); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); +} + +// ————— Analytics ————— + +export function useAnalyticsSummary(enabled = true) { + return useQuery({ + queryKey: ['analytics'], + enabled, + queryFn: async () => unwrap(await api.GET('/analytics/summary')), + }); +} diff --git a/apps/web/src/components/carte-documents.tsx b/apps/web/src/components/carte-documents.tsx new file mode 100644 index 0000000..f664f7f --- /dev/null +++ b/apps/web/src/components/carte-documents.tsx @@ -0,0 +1,129 @@ +import { useRef, useState } from 'react'; +import { + DOCUMENT_KIND_LABELS, + DOCUMENT_KINDS, + DOCUMENT_MAX_BYTES, + type DocumentDto, + type DocumentKind, +} from '@siop/shared'; +import { telechargerDocument, useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion'; +import { Button } from '@/components/ui/button'; + +const CLASSE_TYPE: Partial> = { + CERTIFICATE: 'type-doc certificat', + PHOTO: 'type-doc photo', +}; + +export function tailleLisible(octets: number): string { + if (octets === 0) return '0 Ko'; + if (octets >= 1024 * 1024) return `${(octets / (1024 * 1024)).toFixed(1).replace('.', ',')} Mo`; + return `${Math.max(1, Math.round(octets / 1024))} Ko`; +} + +export function VignetteDoc({ doc, surSuppression }: { doc: DocumentDto; surSuppression?: (id: string) => void }) { + return ( +
+
{doc.contentType.startsWith('image/') ? '🖼' : '📄'}
+ {DOCUMENT_KIND_LABELS[doc.kind]} + {doc.fileName} + + {doc.assetReference ? `Asc. ${doc.assetReference} · ` : ''} + {doc.workOrderReference ? `${doc.workOrderReference} · ` : ''} + {tailleLisible(doc.size)} ·{' '} + {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(doc.createdAt))} + {doc.uploadedByName ? ` · ${doc.uploadedByName}` : ''} + + + + {surSuppression ? ( + + ) : null} + +
+ ); +} + +/** Carte « Documents » des fiches OT / ascenseur — la même bibliothèque, + * filtrée sur la cible ; l'upload rattache automatiquement. */ +export function CarteDocuments({ + cible, + peutEditer, +}: { + cible: { assetId: string } | { workOrderId: string }; + peutEditer: boolean; +}) { + const { data: documents } = useDocuments(cible); + const upload = useUploadDocument(); + const suppression = useDeleteDocument(); + const fichierRef = useRef(null); + const [kind, setKind] = useState('workOrderId' in cible ? 'PHOTO' : 'NOTICE'); + + const surFichier = (file: File | undefined) => { + if (!file) return; + if (file.size > DOCUMENT_MAX_BYTES) { + window.alert('Fichier trop lourd (20 Mo max).'); + return; + } + upload.mutate({ file, kind, ...cible }); + if (fichierRef.current) fichierRef.current.value = ''; + }; + + return ( +
+

Documents

+ {documents?.length ? ( +
+ {documents.map((d) => ( + suppression.mutate(id) : undefined} + /> + ))} +
+ ) : ( +

Aucun document rattaché.

+ )} + {peutEditer ? ( +
+ + surFichier(e.target.files?.[0])} + /> + +
+ ) : null} + {upload.isError ?

{upload.error.message}

: null} +
+ ); +} diff --git a/apps/web/src/layout/coquille.tsx b/apps/web/src/layout/coquille.tsx index 8b4159c..eb51396 100644 --- a/apps/web/src/layout/coquille.tsx +++ b/apps/web/src/layout/coquille.tsx @@ -66,15 +66,15 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [ { groupe: 'Ressources', liens: [ - { libelle: 'Stock & achats', icone: IcoStock, release: 'R3' }, - { libelle: 'Tiers', icone: IcoTiers, release: 'R3' }, - { libelle: 'Fichiers', icone: IcoFichiers, release: 'R3' }, + { libelle: 'Stock & achats', icone: IcoStock, route: '/stock', permission: ['PARTS', 'view'] }, + { libelle: 'Tiers', icone: IcoTiers, route: '/tiers', permission: ['PURCHASE_ORDERS', 'view'] }, + { libelle: 'Fichiers', icone: IcoFichiers, route: '/bibliotheque', permission: ['ASSETS', 'view'] }, ], }, { groupe: 'Pilotage', liens: [ - { libelle: 'Statistiques', icone: IcoStatistiques, release: 'R3' }, + { libelle: 'Statistiques', icone: IcoStatistiques, route: '/statistiques', permission: ['ANALYTICS', 'view'] }, { libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] }, ], }, diff --git a/apps/web/src/pages/achats.tsx b/apps/web/src/pages/achats.tsx new file mode 100644 index 0000000..261b8e5 --- /dev/null +++ b/apps/web/src/pages/achats.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + PURCHASE_ORDER_STATUS_LABELS, + type PurchaseOrderDto, + type PurchaseOrderStatus, +} from '@siop/shared'; +import { usePurchaseOrders, useTransitionPurchaseOrder } from '@/api/gestion'; +import { usePermissions } from '@/auth/use-permissions'; +import { Button } from '@/components/ui/button'; + +const fmt = new Intl.NumberFormat('fr-FR'); + +const CLASSE_STATUT: Record = { + DRAFT: 'st brouillon', + SENT: 'st envoye', + RECEIVED: 'st recu', + CANCELLED: 'st annule', +}; + +export function StatutBC({ statut }: { statut: PurchaseOrderStatus }) { + return {PURCHASE_ORDER_STATUS_LABELS[statut]}; +} + +/** Écran 3 des maquettes R3 : liste + panneau — la réception crée les + * mouvements d'entrée et fige le PU côté API, en une transaction. */ +export default function PageAchats() { + const { data: bons } = usePurchaseOrders(); + const { can } = usePermissions(); + const [selection, setSelection] = useState(null); + + const liste = bons ?? []; + // sans sélection explicite, le panneau montre le premier BC de la liste + const bc = liste.find((b) => b.id === selection) ?? liste[0]; + + return ( + <> +
+

Bons de commande

+
+ {can('PURCHASE_ORDERS', 'create') ? ( + + + + ) : null} +
+
+
+
+
+ + + + + + {liste.map((b) => ( + setSelection(b.id)} + style={{ + cursor: 'pointer', + background: bc?.id === b.id ? 'var(--primaire-doux)' : undefined, + }} + > + + + + + + + ))} + +
FournisseurLignesTotalStatut
{b.reference}{b.supplierName}{b.lines.length}{fmt.format(b.total)} MAD
+
+
+ {bc ? : null} +
+ + ); +} + +function PanneauBC({ bc }: { bc: PurchaseOrderDto }) { + const { can } = usePermissions(); + const transition = useTransitionPurchaseOrder(); + const peutEditer = can('PURCHASE_ORDERS', 'edit'); + + return ( +
+

+ {bc.reference} — {bc.supplierName} +

+
+ + + + + + {bc.lines.map((l) => ( + + + + + + + ))} + +
PièceQtéPUTotal
{l.designation}{l.quantity}{fmt.format(l.unitPrice)}{fmt.format(l.quantity * l.unitPrice)}
+
+
+ Total + {fmt.format(bc.total)} MAD +
+ {peutEditer && (bc.status === 'DRAFT' || bc.status === 'SENT') ? ( +
+ + {bc.status === 'DRAFT' ? ( + + ) : ( + + )} +
+ ) : null} + {transition.isError ?

{transition.error.message}

: null} +

+ La réception crée un mouvement d'entrée par ligne et fige le PU — le stock + remonte, l'alerte s'éteint. +

+
+ ); +} diff --git a/apps/web/src/pages/bibliotheque.tsx b/apps/web/src/pages/bibliotheque.tsx new file mode 100644 index 0000000..a57e756 --- /dev/null +++ b/apps/web/src/pages/bibliotheque.tsx @@ -0,0 +1,154 @@ +import { useRef, useState } from 'react'; +import { + DOCUMENT_KIND_LABELS, + DOCUMENT_KINDS, + DOCUMENT_MAX_BYTES, + type DocumentKind, +} from '@siop/shared'; +import { useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion'; +import { useAssetOptions } from '@/api/referentiel'; +import { usePermissions } from '@/auth/use-permissions'; +import { tailleLisible, VignetteDoc } from '@/components/carte-documents'; +import { Button } from '@/components/ui/button'; +import { Modale } from '@/components/ui/modale'; + +/** Écran 7 des maquettes R3 : notices, certificats, photos — toujours + * RATTACHÉS (appareil ou OT). Ces documents nourriront le RAG en R5. */ +export default function PageBibliotheque() { + const { can } = usePermissions(); + const [kind, setKind] = useState(''); + const { data: documents } = useDocuments(kind ? { kind } : {}); + const suppression = useDeleteDocument(); + const [modale, setModale] = useState(false); + + const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0); + const peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit'); + + return ( + <> +
+

Bibliothèque

+ + {documents?.length ?? 0} documents · {tailleLisible(totalOctets)} + +
+ {peutEditer ? ( + + ) : null} +
+
+
+ +
+ {documents?.length ? ( +
+ {documents.map((d) => ( + suppression.mutate(id) : undefined} + /> + ))} +
+ ) : ( +
Aucun document.
+ )} +
+ Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) — + le rattachement propre commence ici. +
+ {peutEditer ? : null} + + ); +} + +function ModaleTeleversement({ + ouverte, + surFermeture, +}: { + ouverte: boolean; + surFermeture: (o: boolean) => void; +}) { + const { data: options } = useAssetOptions(); + const upload = useUploadDocument(); + const fichierRef = useRef(null); + const [kind, setKind] = useState('NOTICE'); + const [assetId, setAssetId] = useState(''); + const [fichier, setFichier] = useState(null); + + const envoyer = () => { + if (!fichier || !assetId) return; + if (fichier.size > DOCUMENT_MAX_BYTES) { + window.alert('Fichier trop lourd (20 Mo max).'); + return; + } + upload.mutate( + { file: fichier, kind, assetId }, + { + onSuccess: () => { + setFichier(null); + surFermeture(false); + }, + }, + ); + }; + + return ( + +
+
+ + +
+
+ + +
+ setFichier(e.target.files?.[0] ?? null)} + /> + +

+ Les photos prises sur une intervention se téléversent plutôt depuis la fiche OT — + elles restent rattachées à l'intervention. +

+ {upload.isError ?

{upload.error.message}

: null} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/pages/fiche-ascenseur.tsx b/apps/web/src/pages/fiche-ascenseur.tsx index db84570..6d17e20 100644 --- a/apps/web/src/pages/fiche-ascenseur.tsx +++ b/apps/web/src/pages/fiche-ascenseur.tsx @@ -11,6 +11,7 @@ import { useUpdateAsset, } from '@/api/referentiel'; import { usePermissions } from '@/auth/use-permissions'; +import { CarteDocuments } from '@/components/carte-documents'; import { StatutOT } from '@/components/chips-ot'; import { StatutEquipement } from '@/components/statut-equipement'; import { Button } from '@/components/ui/button'; @@ -18,7 +19,7 @@ import { Modale } from '@/components/ui/modale'; import { formatDateFr } from '@/lib/lieux'; /** Fiche appareil — écran validé en R0 (maquette-web, écran 5) ; historique - * (R2) et documents (R3) sont annoncés, pas simulés. */ + * réel depuis R2, documents réels depuis R3. */ export default function PageFicheAscenseur() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -135,12 +136,7 @@ export default function PageFicheAscenseur() {
-
-

Documents

-

- Notices et certificats rejoindront la bibliothèque en R3. -

-
+
diff --git a/apps/web/src/pages/fiche-ot.tsx b/apps/web/src/pages/fiche-ot.tsx index 5fbefaf..795623f 100644 --- a/apps/web/src/pages/fiche-ot.tsx +++ b/apps/web/src/pages/fiche-ot.tsx @@ -17,9 +17,12 @@ import { useUpsertReport, useWorkOrder, } from '@/api/exploitation'; +import { useAddLaborTime, useConsumePart, useParts } from '@/api/gestion'; import { useUsers } from '@/api/referentiel'; import { usePermissions } from '@/auth/use-permissions'; import { Avatar } from '@/components/avatar'; +import { CarteDocuments } from '@/components/carte-documents'; +import { Modale } from '@/components/ui/modale'; import { PrioriteOT, StatutOT } from '@/components/chips-ot'; import { Button } from '@/components/ui/button'; import { formatDateFr } from '@/lib/lieux'; @@ -91,14 +94,8 @@ export default function PageFicheOT() {
-
-

Pièces & main-d'œuvre

-

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

-
-
-

Documents

-

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

-
+ +
@@ -298,6 +295,180 @@ function CarteChecklist({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: b ); } +const fmtMad = new Intl.NumberFormat('fr-FR'); + +function minutesLisibles(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return h ? `${h} h${m ? ` ${String(m).padStart(2, '0')}` : ''}` : `${m} min`; +} + +/** Maquette R3 écran 4 : pièces à prix FIGÉ + main-d'œuvre à taux FIGÉ. + * Le coût suit l'OT pour toujours, même si prix et taux changent ensuite. */ +function CarteCouts({ ot, peutEditer }: { ot: WorkOrderDetail; peutEditer: boolean }) { + const [modale, setModale] = useState<'piece' | 'temps' | null>(null); + const cloture = ot.status === 'DONE' || ot.status === 'CANCELLED'; + const { parts, labor, total } = ot.costs; + + return ( +
+

Pièces & main-d'œuvre

+ {parts.length + labor.length === 0 ? ( +

Aucune consommation ni temps saisi.

+ ) : ( + <> + {parts.map((p) => ( +
+ + {p.designation} × {p.quantity} + {fmtMad.format(p.total)} MAD +
+ ))} + {labor.map((l) => ( +
+ + {l.displayName} · {minutesLisibles(l.minutes)} × {fmtMad.format(l.hourlyRate)} MAD/h + {l.note ? — {l.note} : null} + {fmtMad.format(l.total)} MAD +
+ ))} +
+ Coût total de l'OT + {fmtMad.format(total)} MAD +
+ + )} + {peutEditer && !cloture ? ( +
+ + +
+ ) : null} + {modale === 'piece' ? ( + setModale(null)} /> + ) : null} + {modale === 'temps' ? ( + setModale(null)} /> + ) : null} +
+ ); +} + +function ModaleConsommation({ otId, surFermeture }: { otId: string; surFermeture: () => void }) { + const { data: pieces } = useParts(); + const consommation = useConsumePart(otId); + const [partId, setPartId] = useState(''); + const [quantite, setQuantite] = useState(1); + const piece = (pieces ?? []).find((p) => p.id === partId); + const stockApres = piece ? piece.stock - quantite : null; + + return ( + +
+
+ + +
+
+ + setQuantite(Number(e.target.value))} + /> +
+ {piece ? ( +

+ Prix figé à la consommation :{' '} + + {piece.lastUnitPrice != null ? `${fmtMad.format(piece.lastUnitPrice)} MAD/u` : 'aucun prix connu'} + {' '} + (dernier prix d'achat). Le stock passera à {stockApres} + {stockApres != null && stockApres <= piece.threshold + ? ' — sous le seuil, l’alerte s’allumera.' + : '.'} +

+ ) : null} + {consommation.isError ?

{consommation.error.message}

: null} +
+ + +
+
+
+ ); +} + +function ModaleTemps({ otId, surFermeture }: { otId: string; surFermeture: () => void }) { + const saisie = useAddLaborTime(otId); + const surEnvoi = (e: FormEvent) => { + e.preventDefault(); + const donnees = new FormData(e.currentTarget); + const heures = Number(donnees.get('heures') || 0); + const minutes = Number(donnees.get('minutes') || 0); + const totalMinutes = heures * 60 + minutes; + if (totalMinutes < 1) return; + saisie.mutate( + { + minutes: totalMinutes, + note: String(donnees.get('note') ?? '').trim() || undefined, + }, + { onSuccess: surFermeture }, + ); + }; + return ( + +
+
+
+ + +
+
+ + +
+
+ + +
+
+

+ Le temps est valorisé au taux horaire figé de la personne connectée + (géré dans Personnes) — il ne bougera plus, même si le taux change. +

+ {saisie.isError ?

{saisie.error.message}

: null} +
+ + +
+
+
+ ); +} + const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable & string; dto: string }[] = [ { champ: 'DOOR_STATE', cle: 'doorState', dto: 'doorStateId' }, { champ: 'CABIN_POSITION', cle: 'cabinPosition', dto: 'cabinPositionId' }, diff --git a/apps/web/src/pages/fiche-piece.tsx b/apps/web/src/pages/fiche-piece.tsx new file mode 100644 index 0000000..46fb147 --- /dev/null +++ b/apps/web/src/pages/fiche-piece.tsx @@ -0,0 +1,180 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { STOCK_MOVEMENT_KIND_LABELS } from '@siop/shared'; +import { useAddStockMovement, usePart } from '@/api/gestion'; +import { usePermissions } from '@/auth/use-permissions'; +import { Button } from '@/components/ui/button'; +import { Modale } from '@/components/ui/modale'; + +const fmt = new Intl.NumberFormat('fr-FR'); + +/** Écran 2 des maquettes R3 : les mouvements SONT le stock. */ +export default function PageFichePiece() { + const { id } = useParams<{ id: string }>(); + const { data: piece } = usePart(id); + const { can } = usePermissions(); + const [modale, setModale] = useState<'ENTRY' | 'ADJUSTMENT' | null>(null); + + if (!piece) return null; + + return ( + <> +
+ Stock / {piece.reference} +
+
+

{piece.designation}

+ {piece.belowThreshold ? Sous le seuil : null} +
+ {can('PARTS', 'edit') ? ( + <> + + + + ) : null} + {piece.belowThreshold && can('PURCHASE_ORDERS', 'create') ? ( + + + + ) : null} +
+
+
+
+

Identité

+
+
Référence
+
{piece.reference}
+
Stock actuel
+
{piece.stock} (seuil : {piece.threshold})
+
Dernier PU
+
+ {piece.lastUnitPrice != null ? `${fmt.format(piece.lastUnitPrice)} MAD` : 'aucun prix connu'} +
+
Fournisseur
+
{piece.supplierName ?? '—'}
+
Compatible
+
{piece.compatible ?? '—'}
+
+
+
+

+ Mouvements{' '} + + — le stock EST cette liste + +

+
+ + + + + + {piece.movements.map((m) => ( + + + + + + + ))} + +
DateMouvementQtéOrigine
+ {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(m.createdAt))} + + 0 ? 'mvt entree' : 'mvt sortie'}> + {m.quantity > 0 ? '+ ' : '− '} + {STOCK_MOVEMENT_KIND_LABELS[m.kind]} + + {Math.abs(m.quantity)} + {m.workOrderReference ? ( + {m.workOrderReference} + ) : m.purchaseOrderReference ? ( + <> + {m.purchaseOrderReference} + {m.unitPrice != null ? ` · ${fmt.format(m.unitPrice)} MAD/u` : ''} + + ) : ( + m.reason ?? '—' + )} + {m.byName ? ` · ${m.byName}` : ''} +
+
+
+
+ setModale(null)} + /> + + ); +} + +function ModaleMouvement({ + pieceId, + kind, + surFermeture, +}: { + pieceId: string; + kind: 'ENTRY' | 'ADJUSTMENT' | null; + surFermeture: () => void; +}) { + const mouvement = useAddStockMovement(pieceId); + if (!kind) return null; + + const surEnvoi = (e: FormEvent) => { + e.preventDefault(); + const donnees = new FormData(e.currentTarget); + mouvement.mutate( + { + kind, + quantity: Number(donnees.get('quantity')), + reason: String(donnees.get('reason') ?? '').trim() || undefined, + }, + { onSuccess: surFermeture }, + ); + }; + + return ( + +
+
+ + +
+
+ + +
+ {mouvement.isError ?

{mouvement.error.message}

: null} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/pages/nouvel-achat.tsx b/apps/web/src/pages/nouvel-achat.tsx new file mode 100644 index 0000000..a754c96 --- /dev/null +++ b/apps/web/src/pages/nouvel-achat.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { useCreatePurchaseOrder, usePartners, useParts } from '@/api/gestion'; +import { Button } from '@/components/ui/button'; + +const fmt = new Intl.NumberFormat('fr-FR'); + +type Ligne = { partId: string; quantity: number; unitPrice: number }; + +/** Création de BC — préremplie depuis « Préparer le BC » (?part=…) : + * fournisseur de la pièce, quantité proposée pour repasser le seuil, + * dernier PU connu. Tout reste modifiable avant l'envoi. */ +export default function PageNouvelAchat() { + const navigate = useNavigate(); + const [params] = useSearchParams(); + const partPrerempli = params.get('part'); + const { data: parts, isFetching } = useParts(); + const { data: partners } = usePartners(); + const creation = useCreatePurchaseOrder(); + + const [supplierId, setSupplierId] = useState(''); + const [lignes, setLignes] = useState([]); + const [initialise, setInitialise] = useState(false); + + const fournisseurs = (partners ?? []).filter((p) => p.kind === 'SUPPLIER' && p.isActive); + const pieces = (parts ?? []).filter((p) => p.isActive); + + useEffect(() => { + // attendre la fin du refetch : le cache peut porter un stock périmé + if (initialise || !parts || isFetching) return; + setInitialise(true); + const piece = parts.find((p) => p.id === partPrerempli); + if (!piece) return; + setSupplierId(piece.supplierId ?? ''); + setLignes([ + { + partId: piece.id, + // proposition : revenir 5 unités au-dessus du seuil + quantity: Math.max(piece.threshold - piece.stock + 5, 1), + unitPrice: piece.lastUnitPrice ?? 0, + }, + ]); + }, [initialise, parts, isFetching, partPrerempli]); + + const majLigne = (index: number, patch: Partial) => + setLignes((l) => l.map((ligne, i) => (i === index ? { ...ligne, ...patch } : ligne))); + + const total = lignes.reduce((somme, l) => somme + l.quantity * l.unitPrice, 0); + const valide = + supplierId && lignes.length > 0 && lignes.every((l) => l.partId && l.quantity > 0 && l.unitPrice > 0); + + return ( + <> +
+ Bons de commande / nouveau +
+
+

Nouveau bon de commande

+
+
+
+ + +
+
+ + + + + + {lignes.map((ligne, i) => ( + + + + + + + + ))} + +
PièceQtéPU (MAD)Total
+ + + majLigne(i, { quantity: Number(e.target.value) })} + /> + + majLigne(i, { unitPrice: Number(e.target.value) })} + /> + {fmt.format(ligne.quantity * ligne.unitPrice)} + +
+
+
+ +
+
+ Total + {fmt.format(total)} MAD +
+ {creation.isError ?

{creation.error.message}

: null} +
+ + +
+
+ + ); +} diff --git a/apps/web/src/pages/personnes.tsx b/apps/web/src/pages/personnes.tsx index d76d223..e45f7ac 100644 --- a/apps/web/src/pages/personnes.tsx +++ b/apps/web/src/pages/personnes.tsx @@ -6,6 +6,7 @@ import { useResendInvitation, useRoles, useTeams, + useUpdateUser, useUsers, } from '@/api/referentiel'; import { usePermissions } from '@/auth/use-permissions'; @@ -44,6 +45,7 @@ export default function PagePersonnes() { Personne Rôle Équipe + Taux horaire Statut @@ -66,6 +68,13 @@ export default function PagePersonnes() { {u.role.name} {u.teams.map((t) => t.name).join(', ') || '—'} + + + {u.status === 'active' ? ( Actif @@ -139,6 +148,64 @@ export default function PagePersonnes() { ); } +/** Taux horaire courant (R3) — appliqué aux futures saisies de temps ; les + * temps déjà valorisés gardent leur taux figé. */ +function CelluleTaux({ + userId, + taux, + editable, +}: { + userId: string; + taux: number | null; + editable: boolean; +}) { + const maj = useUpdateUser(); + const [edition, setEdition] = useState(false); + + if (!edition) { + return ( + + {taux != null ? `${new Intl.NumberFormat('fr-FR').format(taux)} MAD/h` : '—'} + {editable ? ( + + ) : null} + + ); + } + return ( + { + const valeur = e.target.value.trim(); + const nombre = Number(valeur); + if (valeur && nombre > 0 && nombre !== taux) { + maj.mutate({ id: userId, hourlyRate: nombre }, { onSettled: () => setEdition(false) }); + } else { + setEdition(false); + } + }} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + if (e.key === 'Escape') setEdition(false); + }} + /> + ); +} + function ModaleInvitation({ ouverte, surFermeture, diff --git a/apps/web/src/pages/statistiques.tsx b/apps/web/src/pages/statistiques.tsx new file mode 100644 index 0000000..e9da204 --- /dev/null +++ b/apps/web/src/pages/statistiques.tsx @@ -0,0 +1,145 @@ +import { useAnalyticsSummary } from '@/api/gestion'; + +const fmt = new Intl.NumberFormat('fr-FR'); +const fmtCompact = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 1 }); + +function enMilliers(v: number): string { + return v >= 1000 ? `${fmtCompact.format(v / 1000)} k` : fmt.format(v); +} + +/** « 2026-03 » → « mars » (maquette : étiquettes de mois en toutes lettres). */ +function moisLisible(period: string): string { + const [annee, mois] = period.split('-').map(Number); + return new Intl.DateTimeFormat('fr-FR', { month: 'long' }).format(new Date(annee, mois - 1, 1)); +} + +/** Écran 5 des maquettes R3 : M. Bennis lit en 30 s — barres horizontales + * à étiquettes directes (palette CVD validée), chiffres tabulaires. */ +export default function PageStatistiques() { + const { data } = useAnalyticsSummary(); + if (!data) return null; + + const delta = + data.previousMonthCost > 0 + ? Math.round(((data.monthCost - data.previousMonthCost) / data.previousMonthCost) * 100) + : null; + const maxPannes = Math.max(...data.failuresByComponent.map((f) => f.count), 1); + const maxCout = Math.max(...data.costsByMonth.map((c) => c.total), 1); + + return ( + <> +
+

Statistiques

+ 12 derniers mois +
+
+
+
+ Coût · {new Intl.DateTimeFormat('fr-FR', { month: 'long' }).format(new Date())} +
+
+ {fmt.format(data.monthCost)}{' '} + MAD +
+ {delta != null ? ( +
+ {delta > 0 ? '+' : '−'}{Math.abs(delta)} % vs mois précédent +
+ ) : ( +
pas de mois précédent
+ )} +
+
+
OT clôturés · 12 mois
+
{fmt.format(data.closed12m.total)}
+
dont {fmt.format(data.closed12m.preventive)} préventifs
+
+
+
Taux de préventif
+
+ {data.preventiveRate != null ? `${Math.round(data.preventiveRate * 100)} %` : '—'} +
+
grilles faites / dues
+
+
+
Durée moyenne de résolution
+
+ {data.avgResolutionDays != null ? `${fmtCompact.format(data.avgResolutionDays)} j` : '—'} +
+
dépannages, 12 mois
+
+
+
+
+

+ Pannes par organe · 12 mois{' '} + + (bilans codés) + +

+
+ {data.failuresByComponent.map((f) => ( +
+ {f.label} +
+
+
+ {f.count} +
+ ))} + {!data.failuresByComponent.length ? ( +

Aucun bilan codé sur la période.

+ ) : null} +
+
+
+

+ Coûts par mois{' '} + + (pièces + main-d'œuvre, MAD) + +

+
+ {data.costsByMonth.map((c) => ( +
+ {moisLisible(c.month)} +
+
+
+ {enMilliers(c.total)} +
+ ))} +
+
+
+
+

Top équipements en coût · 12 mois

+
+ + + + + + {data.topAssets.map((a) => ( + + + + + + + + + ))} + +
AppareilSiteDépannagesPiècesMain-d'œuvreTotal
{a.reference}{a.siteName}{a.correctives}{fmt.format(a.partsCost)}{fmt.format(a.laborCost)}{fmt.format(a.total)} MAD
+
+
+ + ); +} diff --git a/apps/web/src/pages/stock.tsx b/apps/web/src/pages/stock.tsx new file mode 100644 index 0000000..0e2eb33 --- /dev/null +++ b/apps/web/src/pages/stock.tsx @@ -0,0 +1,183 @@ +import { useState, type FormEvent } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useCreatePart, usePartners, useParts } from '@/api/gestion'; +import { usePermissions } from '@/auth/use-permissions'; +import { Button } from '@/components/ui/button'; +import { Modale } from '@/components/ui/modale'; + +const fmt = new Intl.NumberFormat('fr-FR'); + +/** Écran 1 des maquettes R3 : le stock DÉRIVÉ des mouvements — jamais de + * quantité saisie. Sous le seuil : strie ambre + « Préparer le BC ». */ +export default function PageStock() { + const navigate = useNavigate(); + const { data: parts } = useParts(); + const { can } = usePermissions(); + const [modale, setModale] = useState(false); + const [alerte, setAlerte] = useState(false); + + const toutes = parts ?? []; + const sousSeuil = toutes.filter((p) => p.belowThreshold && p.isActive); + const visibles = alerte ? sousSeuil : toutes; + + return ( + <> +
+

Stock de pièces

+ + {toutes.length} références + {sousSeuil.length ? ( + <> + {' · '} + {sousSeuil.length} sous le seuil + + ) : null} + +
+ {can('PURCHASE_ORDERS', 'view') ? ( + + ) : null} + {can('PARTS', 'create') ? ( + + ) : null} +
+
+
+ +
+
+
+ + + + + + + + + + + + + + {visibles.map((p) => ( + navigate(`/stock/${p.id}`)} + style={{ cursor: 'pointer', color: !p.isActive ? 'var(--encre-3)' : undefined }} + > + + + + + + + + + ))} + +
Réf.DésignationStockSeuilDernier PUFournisseur
{p.reference}{p.designation}{!p.isActive ? ' (désactivée)' : ''}{p.belowThreshold ? {p.stock} : p.stock}{p.threshold}{p.lastUnitPrice != null ? `${fmt.format(p.lastUnitPrice)} MAD` : '—'}{p.supplierName ?? '—'} e.stopPropagation()}> + {p.belowThreshold && can('PURCHASE_ORDERS', 'create') ? ( + <> + Sous le seuil{' '} + + Préparer le BC + + + ) : null} +
+
+
+
+ Le stock affiché = somme des mouvements (réceptions de BC, entrées manuelles, + consommations d'OT, ajustements motivés). Aucune saisie directe de quantité. +
+ {can('PARTS', 'create') ? : null} + + ); +} + +function NouvellePiece({ + ouverte, + surFermeture, +}: { + ouverte: boolean; + surFermeture: (o: boolean) => void; +}) { + const navigate = useNavigate(); + const { data: partners } = usePartners(); + const creation = useCreatePart(); + const fournisseurs = (partners ?? []).filter((p) => p.kind === 'SUPPLIER' && p.isActive); + + const surEnvoi = (e: FormEvent) => { + e.preventDefault(); + const donnees = new FormData(e.currentTarget); + const champ = (n: string) => String(donnees.get(n) ?? '').trim(); + creation.mutate( + { + designation: champ('designation'), + threshold: champ('threshold') ? Number(champ('threshold')) : undefined, + supplierId: champ('supplierId') || undefined, + compatible: champ('compatible') || undefined, + initialPrice: champ('initialPrice') ? Number(champ('initialPrice')) : undefined, + }, + { onSuccess: (piece) => navigate(`/stock/${piece.id}`) }, + ); + }; + + return ( + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+ La référence P-#### est générée. Le stock initial s'ajoute ensuite par + une entrée — jamais par un champ de quantité. +

+ {creation.isError ?

{creation.error.message}

: null} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/pages/tableau-de-bord.tsx b/apps/web/src/pages/tableau-de-bord.tsx index 54c5f36..fa3a423 100644 --- a/apps/web/src/pages/tableau-de-bord.tsx +++ b/apps/web/src/pages/tableau-de-bord.tsx @@ -5,6 +5,7 @@ import { type WorkOrderStatus, } from '@siop/shared'; import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation'; +import { useAnalyticsSummary } from '@/api/gestion'; import { useMe } from '@/auth/use-auth'; import { usePermissions } from '@/auth/use-permissions'; import { Avatar } from '@/components/avatar'; @@ -18,15 +19,16 @@ const ST_COULEURS: Record = { 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.) */ +/** Écran 2 validé R0 — données réelles R2, pannes par organe réelles R3. */ export default function PageTableauDeBord() { const navigate = useNavigate(); const { can, chargé } = usePermissions(); const peutVoirOT = can('WORK_ORDERS', 'view'); + const peutVoirStats = can('ANALYTICS', 'view'); const { data: workOrders } = useWorkOrders(peutVoirOT); const { data: requests } = useRequests(); const { data: preventif } = usePreventiveStatus(peutVoirOT); + const { data: stats } = useAnalyticsSummary(peutVoirStats); if (!chargé) return null; if (!peutVoirOT) return ; @@ -120,12 +122,47 @@ export default function PageTableauDeBord() { ))}
-
-

Pannes par organe · 12 mois

-

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

-
+ {peutVoirStats ? ( +
+

+ Pannes par organe · 12 mois{' '} + + (bilans codés) + +

+ {stats?.failuresByComponent.length ? ( +
+ {stats.failuresByComponent.map((f) => ( +
+ {f.label} +
+
x.count), 1)) * 100}%`, + background: 'var(--graf-1)', + }} + /> +
+ {f.count} +
+ ))} +
+ ) : ( +

Aucun bilan codé sur la période.

+ )} +

+ + Toutes les statistiques → + +

+
+ ) : ( +
+

Pannes par organe · 12 mois

+

Réservé aux rôles disposant du pilotage.

+
+ )}

Interventions récentes

diff --git a/apps/web/src/pages/tiers.tsx b/apps/web/src/pages/tiers.tsx new file mode 100644 index 0000000..7fbd4b2 --- /dev/null +++ b/apps/web/src/pages/tiers.tsx @@ -0,0 +1,131 @@ +import { useState, type FormEvent } from 'react'; +import { PARTNER_KIND_LABELS, PARTNER_KINDS } from '@siop/shared'; +import { useCreatePartner, usePartners } from '@/api/gestion'; +import { usePermissions } from '@/auth/use-permissions'; +import { Button } from '@/components/ui/button'; +import { Modale } from '@/components/ui/modale'; + +/** Écran 6 des maquettes R3 : annuaire minimal — pas un CRM. */ +export default function PageTiers() { + const { data: partners } = usePartners(); + const { can } = usePermissions(); + const [modale, setModale] = useState(false); + + return ( + <> +
+

Tiers

+
+ {can('PURCHASE_ORDERS', 'create') ? ( + + ) : null} +
+
+
+
+ + + + + + {(partners ?? []).map((p) => ( + + + + + + + ))} + +
NomTypeContactRattachements
+ {p.name} + {p.city ? {p.city} : null} + {PARTNER_KIND_LABELS[p.kind]} + {p.contactName} + {p.contactName && p.phone ? ' · ' : null} + {p.phone ? {p.phone} : null} + {!p.contactName && !p.phone && !p.email ? '—' : null} + {p.email ? {p.email} : null} + + {p.kind === 'SUPPLIER' + ? p.openOrders + ? `${p.openOrders} BC en cours` + : '—' + : '—'} +
+
+
+ + + ); +} + +function NouveauTiers({ + ouverte, + surFermeture, +}: { + ouverte: boolean; + surFermeture: (o: boolean) => void; +}) { + const creation = useCreatePartner(); + const surEnvoi = (e: FormEvent) => { + e.preventDefault(); + const donnees = new FormData(e.currentTarget); + const champ = (n: string) => String(donnees.get(n) ?? '').trim(); + creation.mutate( + { + name: champ('name'), + kind: champ('kind') as 'SUPPLIER' | 'CLIENT', + contactName: champ('contactName') || undefined, + phone: champ('phone') || undefined, + email: champ('email') || undefined, + city: champ('city') || undefined, + }, + { onSuccess: () => surFermeture(false) }, + ); + }; + + return ( + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ {creation.isError ?

{creation.error.message}

: null} +
+ + +
+
+
+ ); +} diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css index 934a55c..531f4a2 100644 --- a/apps/web/src/styles/app.css +++ b/apps/web/src/styles/app.css @@ -873,6 +873,50 @@ table { color: var(--encre); text-align: right; } +/* ═══ R3 — Gestion (classes extraites de maquette-r3.html validée) ═══ */ +/* Statuts de BON DE COMMANDE */ +.st.brouillon { color: var(--st-annule); background: var(--st-annule-fond); } +.st.envoye { color: var(--st-ouvert); background: var(--st-ouvert-fond); } +.st.recu { color: var(--st-termine); background: var(--st-termine-fond); } + +/* Alerte de stock sous seuil */ +.stock-bas { + display: inline-flex; align-items: center; gap: 6px; + color: var(--alerte); font-weight: 700; font-size: 12.5px; +} +.stock-bas::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--alerte); } +.ligne-seuil td:first-child { box-shadow: inset 3px 0 0 var(--alerte); } + +/* Mouvements de stock */ +.mvt { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; font-size: 12.5px; } +.mvt.entree { color: var(--succes); } +.mvt.sortie { color: var(--danger); } + +/* Coûts */ +.total { display: flex; justify-content: space-between; font-weight: 700; padding-top: 10px; font-size: 14px; } + +/* Bibliothèque */ +.docs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +@media (max-width: 1100px) { .docs { grid-template-columns: repeat(2, 1fr); } } +.doc { + border: 1px solid var(--bordure); border-radius: var(--rayon); background: var(--surface); + padding: 12px; display: flex; flex-direction: column; gap: 8px; font-size: 12.5px; +} +.doc .vignette { + height: 84px; border-radius: 8px; background: var(--surface-2); + display: flex; align-items: center; justify-content: center; + color: var(--encre-3); font-size: 24px; +} +.doc b { font-size: 13px; word-break: break-all; } +.doc .meta { color: var(--encre-2); font-size: 11.5px; } +.type-doc { + display: inline-flex; font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em; + text-transform: uppercase; color: var(--primaire); background: var(--primaire-doux); + padding: 2px 8px; border-radius: var(--rayon-pastille); align-self: flex-start; +} +.type-doc.certificat { color: var(--safran); background: var(--safran-doux); } +.type-doc.photo { color: var(--graf-3); background: var(--st-termine-fond); } + /* ═══ Portail demandeur (écran 6 validé R0 — aucun compte, aucun jargon) ═══ */ .portail { max-width: 430px; diff --git a/docs/journal/journal.md b/docs/journal/journal.md index 4f3a6b0..10271dc 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,24 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-07-16 — Pr. Daaif (+ Claude) — R3.3 : les écrans web de la gestion — R3 prête pour recette + +**Actions** + +- **7 écrans fidèles à `maquette-r3.html`** : Stock (alerte sous seuil, strie ambre, « Préparer le BC » sur la ligne), Fiche pièce (« les mouvements SONT le stock », entrée/ajustement motivé), Bons de commande (liste + panneau, envoi/annulation/**réception → entrées de stock**), création de BC **préremplie** depuis l'alerte (fournisseur de la pièce, quantité pour repasser le seuil + 5, dernier PU), Tiers, Bibliothèque (upload rattaché à un appareil, filtre par type), Statistiques (4 KPI, pannes par organe **depuis les bilans codés**, coûts par mois, top équipements). +- **La fiche OT s'achève** : carte « Pièces & main-d'œuvre » réelle (consommer une pièce — prix figé annoncé AVANT le clic, impact stock affiché ; saisir du temps au taux figé) + carte « Documents » réelle (photo d'intervention téléversée/retéléchargée). La fiche ascenseur gagne la même carte Documents ; le tableau de bord allume « Pannes par organe » ; **Personnes affiche et édite le taux horaire**. +- Nav « Ressources »/« Statistiques » activée par la matrice (PARTS/PURCHASE_ORDERS/ASSETS/ANALYTICS.view) ; captures des 9 écrans comparées aux maquettes (bi-thème), zéro erreur console. +- **Recette R3 automatisée** (2 parcours Playwright, données E2E autonomes purgées au setup) : tiers → pièce → sous seuil → BC prérempli → réception → **l'alerte s'éteint** ; OT : consommation + 1 h 30 → **380 MAD** exacts ; upload/download MinIO réel ; statistiques lues par la Vue seule. **13/13 e2e verts, 10,7 s.** + +**Décisions** + +- Le formulaire BC prérempli attend la fin du refetch avant d'initialiser ses lignes (le cache TanStack portait un stock périmé — attrapé par la recette, pas par l'œil). +- `cleanup-e2e` purge désormais les entités R3 (mouvements liés aux OT E2E supprimés AVANT les OT, sinon ils deviennent orphelins et faussent les stocks seedés d'un run à l'autre). + +**Prochaine étape** : recette R3 avec le référent (revue pixel), déploiement Dokploy, tag `release/r3` → puis R4 Mobile (design d'abord). + +--- + ## 2026-07-16 — Pr. Daaif (+ Claude) — R3.2 : bibliothèque de documents + analytics **Actions**