mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r3.3): écrans web de la gestion — stock, achats, coûts OT, documents, statistiques
- Stock : alertes sous seuil (strie ambre), « Préparer le BC » sur la ligne ; fiche pièce où les mouvements SONT le stock (entrée / ajustement motivé) - Bons de commande : liste + panneau (envoi, annulation, réception → entrées de stock à PU figé) ; création préremplie depuis l'alerte de seuil - Fiche OT : carte « Pièces & main-d'œuvre » réelle (consommation à prix figé annoncé avant le clic, temps au taux figé) + carte Documents (upload/download) ; même carte Documents sur la fiche ascenseur - Statistiques : 4 KPI, pannes par organe (bilans codés), coûts par mois, top équipements ; le tableau de bord allume « Pannes par organe » - Tiers, bibliothèque (upload rattaché, filtre par type), taux horaire éditable dans Personnes ; nav Ressources/Pilotage activée par la matrice - Recette R3 Playwright (2 parcours autonomes) ; cleanup-e2e étendu aux entités R3 ; le formulaire BC attend la fin du refetch (stock périmé en cache) 13/13 e2e verts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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é).
|
- 🏁 **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 — 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.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.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -6,6 +6,39 @@ import { PrismaClient } from '@prisma/client';
|
|||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function main() {
|
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.request.deleteMany({ where: { description: { contains: 'E2E' } } });
|
||||||
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
|
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
|
||||||
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });
|
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });
|
||||||
|
|||||||
159
apps/web/e2e/parcours-r3.spec.ts
Normal file
159
apps/web/e2e/parcours-r3.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
@@ -2,8 +2,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Coquille } from '@/layout/coquille';
|
import { Coquille } from '@/layout/coquille';
|
||||||
|
import PageAchats from '@/pages/achats';
|
||||||
import PageActivation from '@/pages/activation';
|
import PageActivation from '@/pages/activation';
|
||||||
import PageAscenseurs from '@/pages/ascenseurs';
|
import PageAscenseurs from '@/pages/ascenseurs';
|
||||||
|
import PageBibliotheque from '@/pages/bibliotheque';
|
||||||
import PageCategories from '@/pages/categories';
|
import PageCategories from '@/pages/categories';
|
||||||
import PageCompteurs from '@/pages/compteurs';
|
import PageCompteurs from '@/pages/compteurs';
|
||||||
import PageConnexion from '@/pages/connexion';
|
import PageConnexion from '@/pages/connexion';
|
||||||
@@ -12,7 +14,9 @@ import PageDesign from '@/pages/design';
|
|||||||
import PageEtiquette from '@/pages/etiquette';
|
import PageEtiquette from '@/pages/etiquette';
|
||||||
import PageFicheAscenseur from '@/pages/fiche-ascenseur';
|
import PageFicheAscenseur from '@/pages/fiche-ascenseur';
|
||||||
import PageFicheOT from '@/pages/fiche-ot';
|
import PageFicheOT from '@/pages/fiche-ot';
|
||||||
|
import PageFichePiece from '@/pages/fiche-piece';
|
||||||
import PageFicheSite from '@/pages/fiche-site';
|
import PageFicheSite from '@/pages/fiche-site';
|
||||||
|
import PageNouvelAchat from '@/pages/nouvel-achat';
|
||||||
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
|
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
|
||||||
import PageNouvelOT from '@/pages/nouvel-ot';
|
import PageNouvelOT from '@/pages/nouvel-ot';
|
||||||
import PageOrdresTravail from '@/pages/ordres-travail';
|
import PageOrdresTravail from '@/pages/ordres-travail';
|
||||||
@@ -20,7 +24,10 @@ import PagePersonnes from '@/pages/personnes';
|
|||||||
import PagePortail from '@/pages/portail';
|
import PagePortail from '@/pages/portail';
|
||||||
import PagePreventif from '@/pages/preventif';
|
import PagePreventif from '@/pages/preventif';
|
||||||
import PageSites from '@/pages/sites';
|
import PageSites from '@/pages/sites';
|
||||||
|
import PageStatistiques from '@/pages/statistiques';
|
||||||
|
import PageStock from '@/pages/stock';
|
||||||
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
import PageTableauDeBord from '@/pages/tableau-de-bord';
|
||||||
|
import PageTiers from '@/pages/tiers';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
defaultOptions: { queries: { refetchOnWindowFocus: false } },
|
||||||
@@ -53,6 +60,13 @@ export default function App() {
|
|||||||
<Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} />
|
<Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} />
|
||||||
<Route path="/demandes" element={dansCoquille(<PageDemandes />)} />
|
<Route path="/demandes" element={dansCoquille(<PageDemandes />)} />
|
||||||
<Route path="/preventif" element={dansCoquille(<PagePreventif />)} />
|
<Route path="/preventif" element={dansCoquille(<PagePreventif />)} />
|
||||||
|
<Route path="/stock" element={dansCoquille(<PageStock />)} />
|
||||||
|
<Route path="/stock/:id" element={dansCoquille(<PageFichePiece />)} />
|
||||||
|
<Route path="/achats" element={dansCoquille(<PageAchats />)} />
|
||||||
|
<Route path="/achats/nouveau" element={dansCoquille(<PageNouvelAchat />)} />
|
||||||
|
<Route path="/tiers" element={dansCoquille(<PageTiers />)} />
|
||||||
|
<Route path="/bibliotheque" element={dansCoquille(<PageBibliotheque />)} />
|
||||||
|
<Route path="/statistiques" element={dansCoquille(<PageStatistiques />)} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
243
apps/web/src/api/gestion.ts
Normal file
243
apps/web/src/api/gestion.ts
Normal file
@@ -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<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] })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ————— 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<void> {
|
||||||
|
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')),
|
||||||
|
});
|
||||||
|
}
|
||||||
129
apps/web/src/components/carte-documents.tsx
Normal file
129
apps/web/src/components/carte-documents.tsx
Normal file
@@ -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<Record<DocumentKind, string>> = {
|
||||||
|
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 (
|
||||||
|
<div className="doc">
|
||||||
|
<div className="vignette">{doc.contentType.startsWith('image/') ? '🖼' : '📄'}</div>
|
||||||
|
<span className={CLASSE_TYPE[doc.kind] ?? 'type-doc'}>{DOCUMENT_KIND_LABELS[doc.kind]}</span>
|
||||||
|
<b>{doc.fileName}</b>
|
||||||
|
<span className="meta">
|
||||||
|
{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}` : ''}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void telechargerDocument(doc.id, doc.fileName)}
|
||||||
|
style={{ color: 'var(--primaire)', fontWeight: 600, fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Télécharger
|
||||||
|
</button>
|
||||||
|
{surSuppression ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Supprimer « ${doc.fileName} » ?`)) surSuppression(doc.id);
|
||||||
|
}}
|
||||||
|
style={{ color: 'var(--danger)', fontWeight: 600, fontSize: 12.5 }}
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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<HTMLInputElement>(null);
|
||||||
|
const [kind, setKind] = useState<DocumentKind>('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 (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Documents</h2>
|
||||||
|
{documents?.length ? (
|
||||||
|
<div className="docs" style={{ gridTemplateColumns: 'repeat(2, 1fr)' }}>
|
||||||
|
{documents.map((d) => (
|
||||||
|
<VignetteDoc
|
||||||
|
key={d.id}
|
||||||
|
doc={d}
|
||||||
|
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-encre-3">Aucun document rattaché.</p>
|
||||||
|
)}
|
||||||
|
{peutEditer ? (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
aria-label="Type de document"
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => setKind(e.target.value as DocumentKind)}
|
||||||
|
className="rounded-lg border border-bordure-forte bg-surface p-2 text-encre"
|
||||||
|
>
|
||||||
|
{DOCUMENT_KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
ref={fichierRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/jpeg,image/png"
|
||||||
|
aria-label="Choisir un fichier"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => surFichier(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<Button disabled={upload.isPending} onClick={() => fichierRef.current?.click()}>
|
||||||
|
{upload.isPending ? 'Téléversement…' : 'Téléverser (PDF, JPG, PNG · 20 Mo)'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{upload.isError ? <p className="erreur-form">{upload.error.message}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,15 +66,15 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
|
|||||||
{
|
{
|
||||||
groupe: 'Ressources',
|
groupe: 'Ressources',
|
||||||
liens: [
|
liens: [
|
||||||
{ libelle: 'Stock & achats', icone: IcoStock, release: 'R3' },
|
{ libelle: 'Stock & achats', icone: IcoStock, route: '/stock', permission: ['PARTS', 'view'] },
|
||||||
{ libelle: 'Tiers', icone: IcoTiers, release: 'R3' },
|
{ libelle: 'Tiers', icone: IcoTiers, route: '/tiers', permission: ['PURCHASE_ORDERS', 'view'] },
|
||||||
{ libelle: 'Fichiers', icone: IcoFichiers, release: 'R3' },
|
{ libelle: 'Fichiers', icone: IcoFichiers, route: '/bibliotheque', permission: ['ASSETS', 'view'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
groupe: 'Pilotage',
|
groupe: 'Pilotage',
|
||||||
liens: [
|
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'] },
|
{ libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
157
apps/web/src/pages/achats.tsx
Normal file
157
apps/web/src/pages/achats.tsx
Normal file
@@ -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<PurchaseOrderStatus, string> = {
|
||||||
|
DRAFT: 'st brouillon',
|
||||||
|
SENT: 'st envoye',
|
||||||
|
RECEIVED: 'st recu',
|
||||||
|
CANCELLED: 'st annule',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatutBC({ statut }: { statut: PurchaseOrderStatus }) {
|
||||||
|
return <span className={CLASSE_STATUT[statut]}>{PURCHASE_ORDER_STATUS_LABELS[statut]}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** É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<string | null>(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 (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Bons de commande</h1>
|
||||||
|
<div className="actions">
|
||||||
|
{can('PURCHASE_ORDERS', 'create') ? (
|
||||||
|
<Link to="/achats/nouveau">
|
||||||
|
<Button variant="prim">+ Nouveau BC</Button>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2" style={{ gridTemplateColumns: '1.1fr 1fr' }}>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>N°</th><th>Fournisseur</th><th>Lignes</th><th>Total</th><th>Statut</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{liste.map((b) => (
|
||||||
|
<tr
|
||||||
|
key={b.id}
|
||||||
|
onClick={() => setSelection(b.id)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: bc?.id === b.id ? 'var(--primaire-doux)' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td className="ref num">{b.reference}</td>
|
||||||
|
<td>{b.supplierName}</td>
|
||||||
|
<td className="num">{b.lines.length}</td>
|
||||||
|
<td className="num">{fmt.format(b.total)} MAD</td>
|
||||||
|
<td><StatutBC statut={b.status} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{bc ? <PanneauBC bc={bc} /> : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanneauBC({ bc }: { bc: PurchaseOrderDto }) {
|
||||||
|
const { can } = usePermissions();
|
||||||
|
const transition = useTransitionPurchaseOrder();
|
||||||
|
const peutEditer = can('PURCHASE_ORDERS', 'edit');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panneau">
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
fontSize: 13, fontWeight: 700, letterSpacing: '0.04em',
|
||||||
|
textTransform: 'uppercase', color: 'var(--encre-2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bc.reference} — {bc.supplierName} <StatutBC statut={bc.status} />
|
||||||
|
</h2>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Pièce</th><th>Qté</th><th>PU</th><th>Total</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{bc.lines.map((l) => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td>{l.designation}</td>
|
||||||
|
<td className="num">{l.quantity}</td>
|
||||||
|
<td className="num">{fmt.format(l.unitPrice)}</td>
|
||||||
|
<td className="num">{fmt.format(l.quantity * l.unitPrice)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="total">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="num">{fmt.format(bc.total)} MAD</span>
|
||||||
|
</div>
|
||||||
|
{peutEditer && (bc.status === 'DRAFT' || bc.status === 'SENT') ? (
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<Button
|
||||||
|
variant="dangerLeger"
|
||||||
|
disabled={transition.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Annuler le ${bc.reference} ?`)) {
|
||||||
|
transition.mutate({ id: bc.id, to: 'CANCELLED' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Annuler le BC
|
||||||
|
</Button>
|
||||||
|
{bc.status === 'DRAFT' ? (
|
||||||
|
<Button
|
||||||
|
variant="prim"
|
||||||
|
disabled={transition.isPending}
|
||||||
|
onClick={() => transition.mutate({ id: bc.id, to: 'SENT' })}
|
||||||
|
>
|
||||||
|
Envoyer au fournisseur
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="succes"
|
||||||
|
disabled={transition.isPending}
|
||||||
|
onClick={() => transition.mutate({ id: bc.id, to: 'RECEIVED' })}
|
||||||
|
>
|
||||||
|
Réceptionner (→ entrées de stock)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{transition.isError ? <p className="erreur-form">{transition.error.message}</p> : null}
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--encre-3)' }}>
|
||||||
|
La réception crée un mouvement d'entrée par ligne et fige le PU — le stock
|
||||||
|
remonte, l'alerte s'éteint.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
apps/web/src/pages/bibliotheque.tsx
Normal file
154
apps/web/src/pages/bibliotheque.tsx
Normal file
@@ -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<DocumentKind | ''>('');
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Bibliothèque</h1>
|
||||||
|
<span className="filajout">
|
||||||
|
{documents?.length ?? 0} documents · {tailleLisible(totalOctets)}
|
||||||
|
</span>
|
||||||
|
<div className="actions">
|
||||||
|
{peutEditer ? (
|
||||||
|
<Button variant="prim" onClick={() => setModale(true)}>Téléverser</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filtres">
|
||||||
|
<select
|
||||||
|
aria-label="Filtrer par type"
|
||||||
|
value={kind}
|
||||||
|
onChange={(e) => setKind(e.target.value as DocumentKind | '')}
|
||||||
|
>
|
||||||
|
<option value="">Type : tous</option>
|
||||||
|
{DOCUMENT_KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{documents?.length ? (
|
||||||
|
<div className="docs">
|
||||||
|
{documents.map((d) => (
|
||||||
|
<VignetteDoc
|
||||||
|
key={d.id}
|
||||||
|
doc={d}
|
||||||
|
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>
|
||||||
|
)}
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||||
|
Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) —
|
||||||
|
le rattachement propre commence ici.
|
||||||
|
</div>
|
||||||
|
{peutEditer ? <ModaleTeleversement ouverte={modale} surFermeture={setModale} /> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModaleTeleversement({
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const { data: options } = useAssetOptions();
|
||||||
|
const upload = useUploadDocument();
|
||||||
|
const fichierRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [kind, setKind] = useState<DocumentKind>('NOTICE');
|
||||||
|
const [assetId, setAssetId] = useState('');
|
||||||
|
const [fichier, setFichier] = useState<File | null>(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 (
|
||||||
|
<Modale titre="Téléverser un document" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="tv-appareil">Rattacher à l'appareil *</label>
|
||||||
|
<select id="tv-appareil" value={assetId} onChange={(e) => setAssetId(e.target.value)}>
|
||||||
|
<option value="">Sélectionner…</option>
|
||||||
|
{(options ?? []).map((a) => (
|
||||||
|
<option key={a.id} value={a.id}>
|
||||||
|
{a.reference} — {a.siteName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="tv-type">Type *</label>
|
||||||
|
<select id="tv-type" value={kind} onChange={(e) => setKind(e.target.value as DocumentKind)}>
|
||||||
|
{DOCUMENT_KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fichierRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/jpeg,image/png"
|
||||||
|
aria-label="Choisir un fichier"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => setFichier(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
<button type="button" className="zone-photo" onClick={() => fichierRef.current?.click()}>
|
||||||
|
{fichier
|
||||||
|
? `📄 ${fichier.name} (${tailleLisible(fichier.size)})`
|
||||||
|
: '📁 Choisir un fichier — PDF, JPG, PNG · 20 Mo max'}
|
||||||
|
</button>
|
||||||
|
<p className="note-douce">
|
||||||
|
Les photos prises sur une intervention se téléversent plutôt depuis la fiche OT —
|
||||||
|
elles restent rattachées à l'intervention.
|
||||||
|
</p>
|
||||||
|
{upload.isError ? <p className="erreur-form">{upload.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||||
|
<Button variant="prim" disabled={!fichier || !assetId || upload.isPending} onClick={envoyer}>
|
||||||
|
Téléverser
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
useUpdateAsset,
|
useUpdateAsset,
|
||||||
} from '@/api/referentiel';
|
} from '@/api/referentiel';
|
||||||
import { usePermissions } from '@/auth/use-permissions';
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
|
import { CarteDocuments } from '@/components/carte-documents';
|
||||||
import { StatutOT } from '@/components/chips-ot';
|
import { StatutOT } from '@/components/chips-ot';
|
||||||
import { StatutEquipement } from '@/components/statut-equipement';
|
import { StatutEquipement } from '@/components/statut-equipement';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -18,7 +19,7 @@ import { Modale } from '@/components/ui/modale';
|
|||||||
import { formatDateFr } from '@/lib/lieux';
|
import { formatDateFr } from '@/lib/lieux';
|
||||||
|
|
||||||
/** Fiche appareil — écran validé en R0 (maquette-web, écran 5) ; historique
|
/** 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() {
|
export default function PageFicheAscenseur() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -135,12 +136,7 @@ export default function PageFicheAscenseur() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<CarteHistorique assetId={asset.id} />
|
<CarteHistorique assetId={asset.id} />
|
||||||
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
<CarteDocuments cible={{ assetId: asset.id }} peutEditer={can('ASSETS', 'edit')} />
|
||||||
<h2>Documents</h2>
|
|
||||||
<p className="text-encre-3">
|
|
||||||
Notices et certificats rejoindront la bibliothèque en R3.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NouvelOrgane assetId={asset.id} ouverte={modaleOrgane} surFermeture={setModaleOrgane} />
|
<NouvelOrgane assetId={asset.id} ouverte={modaleOrgane} surFermeture={setModaleOrgane} />
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ import {
|
|||||||
useUpsertReport,
|
useUpsertReport,
|
||||||
useWorkOrder,
|
useWorkOrder,
|
||||||
} from '@/api/exploitation';
|
} from '@/api/exploitation';
|
||||||
|
import { useAddLaborTime, useConsumePart, useParts } from '@/api/gestion';
|
||||||
import { useUsers } from '@/api/referentiel';
|
import { useUsers } from '@/api/referentiel';
|
||||||
import { usePermissions } from '@/auth/use-permissions';
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
import { Avatar } from '@/components/avatar';
|
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 { PrioriteOT, StatutOT } from '@/components/chips-ot';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { formatDateFr } from '@/lib/lieux';
|
import { formatDateFr } from '@/lib/lieux';
|
||||||
@@ -91,14 +94,8 @@ export default function PageFicheOT() {
|
|||||||
<CarteBilan ot={ot} peutEditer={peutEditer} />
|
<CarteBilan ot={ot} peutEditer={peutEditer} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
<CarteCouts ot={ot} peutEditer={peutEditer} />
|
||||||
<h2>Pièces & main-d'œuvre</h2>
|
<CarteDocuments cible={{ workOrderId: ot.id }} peutEditer={peutEditer} />
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -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 (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Pièces & main-d'œuvre</h2>
|
||||||
|
{parts.length + labor.length === 0 ? (
|
||||||
|
<p className="text-encre-3">Aucune consommation ni temps saisi.</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{parts.map((p) => (
|
||||||
|
<div className="item-liste" key={p.id}>
|
||||||
|
<span className="ico">▣</span>
|
||||||
|
{p.designation} × {p.quantity}
|
||||||
|
<span className="fin num">{fmtMad.format(p.total)} MAD</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{labor.map((l) => (
|
||||||
|
<div className="item-liste" key={l.id}>
|
||||||
|
<span className="ico">◔</span>
|
||||||
|
{l.displayName} · {minutesLisibles(l.minutes)} × {fmtMad.format(l.hourlyRate)} MAD/h
|
||||||
|
{l.note ? <span className="text-encre-3"> — {l.note}</span> : null}
|
||||||
|
<span className="fin num">{fmtMad.format(l.total)} MAD</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="total">
|
||||||
|
<span>Coût total de l'OT</span>
|
||||||
|
<span className="num">{fmtMad.format(total)} MAD</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{peutEditer && !cloture ? (
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
||||||
|
<Button onClick={() => setModale('piece')}>+ Consommer une pièce</Button>
|
||||||
|
<Button onClick={() => setModale('temps')}>+ Saisir du temps</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{modale === 'piece' ? (
|
||||||
|
<ModaleConsommation otId={ot.id} surFermeture={() => setModale(null)} />
|
||||||
|
) : null}
|
||||||
|
{modale === 'temps' ? (
|
||||||
|
<ModaleTemps otId={ot.id} surFermeture={() => setModale(null)} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modale titre="Consommer une pièce" ouverte surFermeture={surFermeture}>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="cp-piece">Pièce *</label>
|
||||||
|
<select id="cp-piece" value={partId} onChange={(e) => setPartId(e.target.value)}>
|
||||||
|
<option value="">Sélectionner…</option>
|
||||||
|
{(pieces ?? [])
|
||||||
|
.filter((p) => p.isActive)
|
||||||
|
.map((p) => (
|
||||||
|
<option key={p.id} value={p.id} disabled={p.stock <= 0}>
|
||||||
|
{p.designation} — stock : {p.stock}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="cp-qte">Quantité *</label>
|
||||||
|
<input
|
||||||
|
id="cp-qte"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={piece?.stock}
|
||||||
|
className="num"
|
||||||
|
style={{ width: 90 }}
|
||||||
|
value={quantite || ''}
|
||||||
|
onChange={(e) => setQuantite(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{piece ? (
|
||||||
|
<p className="note-douce">
|
||||||
|
Prix figé à la consommation :{' '}
|
||||||
|
<b className="num">
|
||||||
|
{piece.lastUnitPrice != null ? `${fmtMad.format(piece.lastUnitPrice)} MAD/u` : 'aucun prix connu'}
|
||||||
|
</b>{' '}
|
||||||
|
(dernier prix d'achat). Le stock passera à <b className="num">{stockApres}</b>
|
||||||
|
{stockApres != null && stockApres <= piece.threshold
|
||||||
|
? ' — sous le seuil, l’alerte s’allumera.'
|
||||||
|
: '.'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{consommation.isError ? <p className="erreur-form">{consommation.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={surFermeture}>Annuler</Button>
|
||||||
|
<Button
|
||||||
|
variant="prim"
|
||||||
|
disabled={!piece || quantite < 1 || (piece && quantite > piece.stock) || consommation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
consommation.mutate({ partId, quantity: quantite }, { onSuccess: surFermeture })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Consommer (− stock, prix figé)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModaleTemps({ otId, surFermeture }: { otId: string; surFermeture: () => void }) {
|
||||||
|
const saisie = useAddLaborTime(otId);
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<Modale titre="Saisir du temps" ouverte surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="st-h">Heures</label>
|
||||||
|
<input id="st-h" name="heures" type="number" min={0} max={24} defaultValue={1} className="num" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="st-m">Minutes</label>
|
||||||
|
<input id="st-m" name="minutes" type="number" min={0} max={59} defaultValue={0} className="num" />
|
||||||
|
</div>
|
||||||
|
<div className="champ pleine-l">
|
||||||
|
<label htmlFor="st-note">Note (optionnelle)</label>
|
||||||
|
<input id="st-note" name="note" placeholder="Ex. : réglage des coulisseaux" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="note-douce">
|
||||||
|
Le temps est valorisé au <b>taux horaire figé</b> de la personne connectée
|
||||||
|
(géré dans Personnes) — il ne bougera plus, même si le taux change.
|
||||||
|
</p>
|
||||||
|
{saisie.isError ? <p className="erreur-form">{saisie.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={surFermeture}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={saisie.isPending}>Enregistrer</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail['report']> & string; dto: string }[] = [
|
const CHAMPS_BILAN: { champ: BilanField; cle: keyof NonNullable<WorkOrderDetail['report']> & string; dto: string }[] = [
|
||||||
{ champ: 'DOOR_STATE', cle: 'doorState', dto: 'doorStateId' },
|
{ champ: 'DOOR_STATE', cle: 'doorState', dto: 'doorStateId' },
|
||||||
{ champ: 'CABIN_POSITION', cle: 'cabinPosition', dto: 'cabinPositionId' },
|
{ champ: 'CABIN_POSITION', cle: 'cabinPosition', dto: 'cabinPositionId' },
|
||||||
|
|||||||
180
apps/web/src/pages/fiche-piece.tsx
Normal file
180
apps/web/src/pages/fiche-piece.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/stock">Stock</Link> / {piece.reference}
|
||||||
|
</div>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>{piece.designation}</h1>
|
||||||
|
{piece.belowThreshold ? <span className="stock-bas">Sous le seuil</span> : null}
|
||||||
|
<div className="actions">
|
||||||
|
{can('PARTS', 'edit') ? (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setModale('ENTRY')}>Entrée de stock</Button>
|
||||||
|
<Button onClick={() => setModale('ADJUSTMENT')}>Ajustement d'inventaire</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{piece.belowThreshold && can('PURCHASE_ORDERS', 'create') ? (
|
||||||
|
<Link to={`/achats/nouveau?part=${piece.id}`}>
|
||||||
|
<Button variant="prim">Préparer le BC</Button>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Identité</h2>
|
||||||
|
<dl className="infos">
|
||||||
|
<dt>Référence</dt>
|
||||||
|
<dd className="num">{piece.reference}</dd>
|
||||||
|
<dt>Stock actuel</dt>
|
||||||
|
<dd className="num"><b>{piece.stock}</b> (seuil : {piece.threshold})</dd>
|
||||||
|
<dt>Dernier PU</dt>
|
||||||
|
<dd className="num">
|
||||||
|
{piece.lastUnitPrice != null ? `${fmt.format(piece.lastUnitPrice)} MAD` : 'aucun prix connu'}
|
||||||
|
</dd>
|
||||||
|
<dt>Fournisseur</dt>
|
||||||
|
<dd>{piece.supplierName ?? '—'}</dd>
|
||||||
|
<dt>Compatible</dt>
|
||||||
|
<dd>{piece.compatible ?? '—'}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div className="carte">
|
||||||
|
<h2>
|
||||||
|
Mouvements{' '}
|
||||||
|
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||||
|
— le stock EST cette liste
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Mouvement</th><th>Qté</th><th>Origine</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{piece.movements.map((m) => (
|
||||||
|
<tr key={m.id}>
|
||||||
|
<td className="num">
|
||||||
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' }).format(new Date(m.createdAt))}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={m.quantity > 0 ? 'mvt entree' : 'mvt sortie'}>
|
||||||
|
{m.quantity > 0 ? '+ ' : '− '}
|
||||||
|
{STOCK_MOVEMENT_KIND_LABELS[m.kind]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="num">{Math.abs(m.quantity)}</td>
|
||||||
|
<td>
|
||||||
|
{m.workOrderReference ? (
|
||||||
|
<span className="ref num">{m.workOrderReference}</span>
|
||||||
|
) : m.purchaseOrderReference ? (
|
||||||
|
<>
|
||||||
|
<span className="ref num">{m.purchaseOrderReference}</span>
|
||||||
|
{m.unitPrice != null ? ` · ${fmt.format(m.unitPrice)} MAD/u` : ''}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
m.reason ?? '—'
|
||||||
|
)}
|
||||||
|
{m.byName ? ` · ${m.byName}` : ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ModaleMouvement
|
||||||
|
pieceId={piece.id}
|
||||||
|
kind={modale}
|
||||||
|
surFermeture={() => 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<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<Modale
|
||||||
|
titre={kind === 'ENTRY' ? 'Entrée de stock' : "Ajustement d'inventaire"}
|
||||||
|
ouverte
|
||||||
|
surFermeture={surFermeture}
|
||||||
|
>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="mv-qte">
|
||||||
|
Quantité {kind === 'ADJUSTMENT' ? '(± signée)' : ''} *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="mv-qte"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
required
|
||||||
|
min={kind === 'ENTRY' ? 1 : undefined}
|
||||||
|
placeholder={kind === 'ENTRY' ? '5' : '-1'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="mv-motif">
|
||||||
|
Motif {kind === 'ADJUSTMENT' ? '(requis)' : '(optionnel)'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="mv-motif"
|
||||||
|
name="reason"
|
||||||
|
required={kind === 'ADJUSTMENT'}
|
||||||
|
placeholder={kind === 'ADJUSTMENT' ? 'Ex. : pièce endommagée à l’inventaire' : 'Ex. : reprise de stock atelier'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{mouvement.isError ? <p className="erreur-form">{mouvement.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Button onClick={surFermeture}>Annuler</Button>
|
||||||
|
<Button type="submit" variant="prim" disabled={mouvement.isPending}>
|
||||||
|
Tracer le mouvement
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
apps/web/src/pages/nouvel-achat.tsx
Normal file
166
apps/web/src/pages/nouvel-achat.tsx
Normal file
@@ -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<Ligne[]>([]);
|
||||||
|
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<Ligne>) =>
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<div className="filajout">
|
||||||
|
<Link to="/achats">Bons de commande</Link> / nouveau
|
||||||
|
</div>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Nouveau bon de commande</h1>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ maxWidth: 760 }}>
|
||||||
|
<div className="champ" style={{ marginBottom: 14 }}>
|
||||||
|
<label htmlFor="bc-fournisseur">Fournisseur *</label>
|
||||||
|
<select
|
||||||
|
id="bc-fournisseur"
|
||||||
|
value={supplierId}
|
||||||
|
onChange={(e) => setSupplierId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner…</option>
|
||||||
|
{fournisseurs.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>{f.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Pièce</th><th style={{ width: 90 }}>Qté</th><th style={{ width: 120 }}>PU (MAD)</th><th style={{ width: 100 }}>Total</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{lignes.map((ligne, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
aria-label={`Pièce de la ligne ${i + 1}`}
|
||||||
|
value={ligne.partId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const piece = pieces.find((p) => p.id === e.target.value);
|
||||||
|
majLigne(i, {
|
||||||
|
partId: e.target.value,
|
||||||
|
unitPrice: piece?.lastUnitPrice ?? ligne.unitPrice,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Sélectionner…</option>
|
||||||
|
{pieces.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.reference} — {p.designation} (stock : {p.stock})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
aria-label={`Quantité de la ligne ${i + 1}`}
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className="num"
|
||||||
|
value={ligne.quantity || ''}
|
||||||
|
onChange={(e) => majLigne(i, { quantity: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
aria-label={`Prix unitaire de la ligne ${i + 1}`}
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
step="0.01"
|
||||||
|
className="num"
|
||||||
|
value={ligne.unitPrice || ''}
|
||||||
|
onChange={(e) => majLigne(i, { unitPrice: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="num">{fmt.format(ligne.quantity * ligne.unitPrice)}</td>
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-[12px]"
|
||||||
|
onClick={() => setLignes((l) => l.filter((_, j) => j !== i))}
|
||||||
|
>
|
||||||
|
Retirer
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10 }}>
|
||||||
|
<Button onClick={() => setLignes((l) => [...l, { partId: '', quantity: 1, unitPrice: 0 }])}>
|
||||||
|
+ Ajouter une ligne
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="total">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="num">{fmt.format(total)} MAD</span>
|
||||||
|
</div>
|
||||||
|
{creation.isError ? <p className="erreur-form">{creation.error.message}</p> : null}
|
||||||
|
<div className="pied">
|
||||||
|
<Link to="/achats"><Button>Annuler</Button></Link>
|
||||||
|
<Button
|
||||||
|
variant="prim"
|
||||||
|
disabled={!valide || creation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
creation.mutate(
|
||||||
|
{ supplierId, lines: lignes },
|
||||||
|
{ onSuccess: () => navigate('/achats') },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Créer le BC (brouillon)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
useResendInvitation,
|
useResendInvitation,
|
||||||
useRoles,
|
useRoles,
|
||||||
useTeams,
|
useTeams,
|
||||||
|
useUpdateUser,
|
||||||
useUsers,
|
useUsers,
|
||||||
} from '@/api/referentiel';
|
} from '@/api/referentiel';
|
||||||
import { usePermissions } from '@/auth/use-permissions';
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
@@ -44,6 +45,7 @@ export default function PagePersonnes() {
|
|||||||
<th>Personne</th>
|
<th>Personne</th>
|
||||||
<th>Rôle</th>
|
<th>Rôle</th>
|
||||||
<th>Équipe</th>
|
<th>Équipe</th>
|
||||||
|
<th>Taux horaire</th>
|
||||||
<th>Statut</th>
|
<th>Statut</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -66,6 +68,13 @@ export default function PagePersonnes() {
|
|||||||
</td>
|
</td>
|
||||||
<td>{u.role.name}</td>
|
<td>{u.role.name}</td>
|
||||||
<td>{u.teams.map((t) => t.name).join(', ') || '—'}</td>
|
<td>{u.teams.map((t) => t.name).join(', ') || '—'}</td>
|
||||||
|
<td>
|
||||||
|
<CelluleTaux
|
||||||
|
userId={u.id}
|
||||||
|
taux={u.hourlyRate}
|
||||||
|
editable={can('PEOPLE_TEAMS', 'edit')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{u.status === 'active' ? (
|
{u.status === 'active' ? (
|
||||||
<span className="st termine">Actif</span>
|
<span className="st termine">Actif</span>
|
||||||
@@ -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 (
|
||||||
|
<span className="num">
|
||||||
|
{taux != null ? `${new Intl.NumberFormat('fr-FR').format(taux)} MAD/h` : '—'}
|
||||||
|
{editable ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-1 px-2 py-0.5 text-[12px]"
|
||||||
|
aria-label="Modifier le taux horaire"
|
||||||
|
onClick={() => setEdition(true)}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
autoFocus
|
||||||
|
defaultValue={taux ?? ''}
|
||||||
|
aria-label="Nouveau taux horaire (MAD/h)"
|
||||||
|
className="num w-24 rounded-lg border border-bordure-forte bg-surface px-2 py-1 text-encre"
|
||||||
|
disabled={maj.isPending}
|
||||||
|
onBlur={(e) => {
|
||||||
|
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({
|
function ModaleInvitation({
|
||||||
ouverte,
|
ouverte,
|
||||||
surFermeture,
|
surFermeture,
|
||||||
|
|||||||
145
apps/web/src/pages/statistiques.tsx
Normal file
145
apps/web/src/pages/statistiques.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Statistiques</h1>
|
||||||
|
<span className="filajout">12 derniers mois</span>
|
||||||
|
</div>
|
||||||
|
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">
|
||||||
|
Coût · {new Intl.DateTimeFormat('fr-FR', { month: 'long' }).format(new Date())}
|
||||||
|
</div>
|
||||||
|
<div className="val num">
|
||||||
|
{fmt.format(data.monthCost)}{' '}
|
||||||
|
<span style={{ fontSize: 14, color: 'var(--encre-2)' }}>MAD</span>
|
||||||
|
</div>
|
||||||
|
{delta != null ? (
|
||||||
|
<div className={`delta ${delta <= 0 ? 'bon' : 'mauvais'}`}>
|
||||||
|
{delta > 0 ? '+' : '−'}{Math.abs(delta)} % vs mois précédent
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="delta neutre">pas de mois précédent</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">OT clôturés · 12 mois</div>
|
||||||
|
<div className="val num">{fmt.format(data.closed12m.total)}</div>
|
||||||
|
<div className="delta neutre">dont {fmt.format(data.closed12m.preventive)} préventifs</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Taux de préventif</div>
|
||||||
|
<div className="val num">
|
||||||
|
{data.preventiveRate != null ? `${Math.round(data.preventiveRate * 100)} %` : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="delta bon">grilles faites / dues</div>
|
||||||
|
</div>
|
||||||
|
<div className="kpi">
|
||||||
|
<div className="lib">Durée moyenne de résolution</div>
|
||||||
|
<div className="val num">
|
||||||
|
{data.avgResolutionDays != null ? `${fmtCompact.format(data.avgResolutionDays)} j` : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="delta neutre">dépannages, 12 mois</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grille-2">
|
||||||
|
<div className="carte graf">
|
||||||
|
<h2>
|
||||||
|
Pannes par organe · 12 mois{' '}
|
||||||
|
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||||
|
(bilans codés)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="barres-h">
|
||||||
|
{data.failuresByComponent.map((f) => (
|
||||||
|
<div className="ligne-b" key={f.label}>
|
||||||
|
<span>{f.label}</span>
|
||||||
|
<div className="fond-b">
|
||||||
|
<div
|
||||||
|
className="rempli"
|
||||||
|
style={{ width: `${(f.count / maxPannes) * 100}%`, background: 'var(--graf-1)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="valeur num">{f.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!data.failuresByComponent.length ? (
|
||||||
|
<p className="text-encre-3">Aucun bilan codé sur la période.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte graf">
|
||||||
|
<h2>
|
||||||
|
Coûts par mois{' '}
|
||||||
|
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||||
|
(pièces + main-d'œuvre, MAD)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<div className="barres-h">
|
||||||
|
{data.costsByMonth.map((c) => (
|
||||||
|
<div className="ligne-b" key={c.month}>
|
||||||
|
<span style={{ textTransform: 'capitalize' }}>{moisLisible(c.month)}</span>
|
||||||
|
<div className="fond-b">
|
||||||
|
<div
|
||||||
|
className="rempli"
|
||||||
|
style={{ width: `${(c.total / maxCout) * 100}%`, background: 'var(--graf-2)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="valeur num">{enMilliers(c.total)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Top équipements en coût · 12 mois</h2>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Appareil</th><th>Site</th><th>Dépannages</th><th>Pièces</th><th>Main-d'œuvre</th><th>Total</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.topAssets.map((a) => (
|
||||||
|
<tr key={a.reference}>
|
||||||
|
<td className="ref num">{a.reference}</td>
|
||||||
|
<td>{a.siteName}</td>
|
||||||
|
<td className="num">{a.correctives}</td>
|
||||||
|
<td className="num">{fmt.format(a.partsCost)}</td>
|
||||||
|
<td className="num">{fmt.format(a.laborCost)}</td>
|
||||||
|
<td className="num"><b>{fmt.format(a.total)} MAD</b></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
apps/web/src/pages/stock.tsx
Normal file
183
apps/web/src/pages/stock.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Stock de pièces</h1>
|
||||||
|
<span className="filajout">
|
||||||
|
{toutes.length} références
|
||||||
|
{sousSeuil.length ? (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<span className="stock-bas">{sousSeuil.length} sous le seuil</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<div className="actions">
|
||||||
|
{can('PURCHASE_ORDERS', 'view') ? (
|
||||||
|
<Link to="/achats"><Button>Bons de commande</Button></Link>
|
||||||
|
) : null}
|
||||||
|
{can('PARTS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => setModale(true)}>
|
||||||
|
+ Nouvelle pièce
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="filtres">
|
||||||
|
<select
|
||||||
|
aria-label="Filtrer par alerte"
|
||||||
|
value={alerte ? 'seuil' : ''}
|
||||||
|
onChange={(e) => setAlerte(e.target.value === 'seuil')}
|
||||||
|
>
|
||||||
|
<option value="">Alerte : toutes</option>
|
||||||
|
<option value="seuil">Sous le seuil</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Réf.</th>
|
||||||
|
<th>Désignation</th>
|
||||||
|
<th>Stock</th>
|
||||||
|
<th>Seuil</th>
|
||||||
|
<th>Dernier PU</th>
|
||||||
|
<th>Fournisseur</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibles.map((p) => (
|
||||||
|
<tr
|
||||||
|
key={p.id}
|
||||||
|
className={p.belowThreshold ? 'ligne-seuil' : undefined}
|
||||||
|
onClick={() => navigate(`/stock/${p.id}`)}
|
||||||
|
style={{ cursor: 'pointer', color: !p.isActive ? 'var(--encre-3)' : undefined }}
|
||||||
|
>
|
||||||
|
<td className="ref num">{p.reference}</td>
|
||||||
|
<td>{p.designation}{!p.isActive ? ' (désactivée)' : ''}</td>
|
||||||
|
<td className="num">{p.belowThreshold ? <b>{p.stock}</b> : p.stock}</td>
|
||||||
|
<td className="num">{p.threshold}</td>
|
||||||
|
<td className="num">{p.lastUnitPrice != null ? `${fmt.format(p.lastUnitPrice)} MAD` : '—'}</td>
|
||||||
|
<td>{p.supplierName ?? '—'}</td>
|
||||||
|
<td onClick={(e) => e.stopPropagation()}>
|
||||||
|
{p.belowThreshold && can('PURCHASE_ORDERS', 'create') ? (
|
||||||
|
<>
|
||||||
|
<span className="stock-bas">Sous le seuil</span>{' '}
|
||||||
|
<Link
|
||||||
|
to={`/achats/nouveau?part=${p.id}`}
|
||||||
|
style={{ color: 'var(--primaire)', fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
Préparer le BC
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||||
|
Le stock affiché = somme des mouvements (réceptions de BC, entrées manuelles,
|
||||||
|
consommations d'OT, ajustements motivés). <b>Aucune saisie directe de quantité.</b>
|
||||||
|
</div>
|
||||||
|
{can('PARTS', 'create') ? <NouvellePiece ouverte={modale} surFermeture={setModale} /> : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<Modale titre="Nouvelle pièce" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="np-designation">Désignation *</label>
|
||||||
|
<input id="np-designation" name="designation" required placeholder="Ex. : Contact de porte NC-31" />
|
||||||
|
</div>
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="np-seuil">Seuil d'alerte</label>
|
||||||
|
<input id="np-seuil" name="threshold" type="number" min="0" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="np-prix">Prix initial (MAD)</label>
|
||||||
|
<input id="np-prix" name="initialPrice" type="number" min="0" step="0.01" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="np-fournisseur">Fournisseur</label>
|
||||||
|
<select id="np-fournisseur" name="supplierId" defaultValue="">
|
||||||
|
<option value="">—</option>
|
||||||
|
{fournisseurs.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>{f.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="np-compatible">Compatible avec</label>
|
||||||
|
<input id="np-compatible" name="compatible" placeholder="Ex. : Otis Gen2" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="note-douce">
|
||||||
|
La référence P-#### est générée. Le stock initial s'ajoute ensuite par
|
||||||
|
une <b>entrée</b> — jamais par un champ de quantité.
|
||||||
|
</p>
|
||||||
|
{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}>Créer la pièce</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type WorkOrderStatus,
|
type WorkOrderStatus,
|
||||||
} from '@siop/shared';
|
} from '@siop/shared';
|
||||||
import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation';
|
import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation';
|
||||||
|
import { useAnalyticsSummary } from '@/api/gestion';
|
||||||
import { useMe } from '@/auth/use-auth';
|
import { useMe } from '@/auth/use-auth';
|
||||||
import { usePermissions } from '@/auth/use-permissions';
|
import { usePermissions } from '@/auth/use-permissions';
|
||||||
import { Avatar } from '@/components/avatar';
|
import { Avatar } from '@/components/avatar';
|
||||||
@@ -18,15 +19,16 @@ const ST_COULEURS: Record<string, string> = {
|
|||||||
DONE: 'var(--st-termine)',
|
DONE: 'var(--st-termine)',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Écran 2 validé R0 — alimenté par les données réelles de R2.
|
/** Écran 2 validé R0 — données réelles R2, pannes par organe réelles R3. */
|
||||||
* (Coûts → R3 ; pannes par organe → analytics R3.) */
|
|
||||||
export default function PageTableauDeBord() {
|
export default function PageTableauDeBord() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { can, chargé } = usePermissions();
|
const { can, chargé } = usePermissions();
|
||||||
const peutVoirOT = can('WORK_ORDERS', 'view');
|
const peutVoirOT = can('WORK_ORDERS', 'view');
|
||||||
|
const peutVoirStats = can('ANALYTICS', 'view');
|
||||||
const { data: workOrders } = useWorkOrders(peutVoirOT);
|
const { data: workOrders } = useWorkOrders(peutVoirOT);
|
||||||
const { data: requests } = useRequests();
|
const { data: requests } = useRequests();
|
||||||
const { data: preventif } = usePreventiveStatus(peutVoirOT);
|
const { data: preventif } = usePreventiveStatus(peutVoirOT);
|
||||||
|
const { data: stats } = useAnalyticsSummary(peutVoirStats);
|
||||||
|
|
||||||
if (!chargé) return null;
|
if (!chargé) return null;
|
||||||
if (!peutVoirOT) return <AccueilSansExploitation />;
|
if (!peutVoirOT) return <AccueilSansExploitation />;
|
||||||
@@ -120,12 +122,47 @@ export default function PageTableauDeBord() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="carte" style={{ borderStyle: 'dashed' }}>
|
{peutVoirStats ? (
|
||||||
<h2>Pannes par organe · 12 mois</h2>
|
<div className="carte graf">
|
||||||
<p className="text-encre-3">
|
<h2>
|
||||||
Ce graphique s'alimentera des bilans codés — il arrive avec les analytics (R3).
|
Pannes par organe · 12 mois{' '}
|
||||||
</p>
|
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||||
</div>
|
(bilans codés)
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
{stats?.failuresByComponent.length ? (
|
||||||
|
<div className="barres-h">
|
||||||
|
{stats.failuresByComponent.map((f) => (
|
||||||
|
<div className="ligne-b" key={f.label}>
|
||||||
|
<span>{f.label}</span>
|
||||||
|
<div className="fond-b">
|
||||||
|
<div
|
||||||
|
className="rempli"
|
||||||
|
style={{
|
||||||
|
width: `${(f.count / Math.max(...stats.failuresByComponent.map((x) => x.count), 1)) * 100}%`,
|
||||||
|
background: 'var(--graf-1)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="valeur num">{f.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-encre-3">Aucun bilan codé sur la période.</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2">
|
||||||
|
<Link to="/statistiques" className="text-[12.5px] font-semibold text-primaire">
|
||||||
|
Toutes les statistiques →
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="carte">
|
||||||
|
<h2>Pannes par organe · 12 mois</h2>
|
||||||
|
<p className="text-encre-3">Réservé aux rôles disposant du pilotage.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="carte">
|
<div className="carte">
|
||||||
<h2>Interventions récentes</h2>
|
<h2>Interventions récentes</h2>
|
||||||
|
|||||||
131
apps/web/src/pages/tiers.tsx
Normal file
131
apps/web/src/pages/tiers.tsx
Normal file
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="entete-page">
|
||||||
|
<h1>Tiers</h1>
|
||||||
|
<div className="actions">
|
||||||
|
{can('PURCHASE_ORDERS', 'create') ? (
|
||||||
|
<Button variant="prim" onClick={() => setModale(true)}>
|
||||||
|
+ Nouveau tiers
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="carte" style={{ padding: 0 }}>
|
||||||
|
<div className="table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Nom</th><th>Type</th><th>Contact</th><th>Rattachements</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(partners ?? []).map((p) => (
|
||||||
|
<tr key={p.id} style={!p.isActive ? { color: 'var(--encre-3)' } : undefined}>
|
||||||
|
<td>
|
||||||
|
<b>{p.name}</b>
|
||||||
|
{p.city ? <span className="sous">{p.city}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td>{PARTNER_KIND_LABELS[p.kind]}</td>
|
||||||
|
<td>
|
||||||
|
{p.contactName}
|
||||||
|
{p.contactName && p.phone ? ' · ' : null}
|
||||||
|
{p.phone ? <span className="num">{p.phone}</span> : null}
|
||||||
|
{!p.contactName && !p.phone && !p.email ? '—' : null}
|
||||||
|
{p.email ? <span className="sous">{p.email}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
{p.kind === 'SUPPLIER'
|
||||||
|
? p.openOrders
|
||||||
|
? `${p.openOrders} BC en cours`
|
||||||
|
: '—'
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NouveauTiers ouverte={modale} surFermeture={setModale} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NouveauTiers({
|
||||||
|
ouverte,
|
||||||
|
surFermeture,
|
||||||
|
}: {
|
||||||
|
ouverte: boolean;
|
||||||
|
surFermeture: (o: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const creation = useCreatePartner();
|
||||||
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<Modale titre="Nouveau tiers" ouverte={ouverte} surFermeture={surFermeture}>
|
||||||
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="nt-nom">Nom *</label>
|
||||||
|
<input id="nt-nom" name="name" required placeholder="Ex. : Ascentech Maroc" />
|
||||||
|
</div>
|
||||||
|
<div className="form-grille">
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="nt-type">Type *</label>
|
||||||
|
<select id="nt-type" name="kind" required defaultValue="SUPPLIER">
|
||||||
|
{PARTNER_KINDS.map((k) => (
|
||||||
|
<option key={k} value={k}>{PARTNER_KIND_LABELS[k]}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="nt-ville">Ville</label>
|
||||||
|
<input id="nt-ville" name="city" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="nt-contact">Contact</label>
|
||||||
|
<input id="nt-contact" name="contactName" />
|
||||||
|
</div>
|
||||||
|
<div className="champ">
|
||||||
|
<label htmlFor="nt-tel">Téléphone</label>
|
||||||
|
<input id="nt-tel" name="phone" />
|
||||||
|
</div>
|
||||||
|
<div className="champ pleine-l">
|
||||||
|
<label htmlFor="nt-email">Email</label>
|
||||||
|
<input id="nt-email" name="email" type="email" />
|
||||||
|
</div>
|
||||||
|
</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}>Créer</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modale>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -873,6 +873,50 @@ table {
|
|||||||
color: var(--encre); text-align: right;
|
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 demandeur (écran 6 validé R0 — aucun compte, aucun jargon) ═══ */
|
||||||
.portail {
|
.portail {
|
||||||
max-width: 430px;
|
max-width: 430px;
|
||||||
|
|||||||
@@ -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
|
## 2026-07-16 — Pr. Daaif (+ Claude) — R3.2 : bibliothèque de documents + analytics
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
Reference in New Issue
Block a user