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:
pr-daaif
2026-07-16 22:16:56 +01:00
parent a4e98c2000
commit c5382a5538
20 changed files with 2056 additions and 28 deletions

View File

@@ -6,6 +6,39 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// R3 — d'abord les traces de gestion (les mouvements liés à un OT E2E
// seraient orphelins après sa suppression et fausseraient les stocks seedés)
await prisma.stockMovement.deleteMany({
where: {
OR: [
{ part: { designation: { contains: 'E2E' } } },
{ workOrder: { title: { contains: 'E2E' } } },
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
],
},
});
await prisma.document.deleteMany({
where: {
OR: [
{ fileName: { contains: 'e2e' } },
{ workOrder: { title: { contains: 'E2E' } } },
],
},
});
await prisma.purchaseOrderLine.deleteMany({
where: {
OR: [
{ part: { designation: { contains: 'E2E' } } },
{ purchaseOrder: { supplier: { name: { contains: 'E2E' } } } },
],
},
});
await prisma.purchaseOrder.deleteMany({
where: { supplier: { name: { contains: 'E2E' } } },
});
await prisma.part.deleteMany({ where: { designation: { contains: 'E2E' } } });
await prisma.partner.deleteMany({ where: { name: { contains: 'E2E' } } });
await prisma.request.deleteMany({ where: { description: { contains: 'E2E' } } });
await prisma.workOrder.deleteMany({ where: { title: { contains: 'E2E' } } });
await prisma.meterReading.deleteMany({ where: { value: { gte: 90_000_000 } } });

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

View File

@@ -2,8 +2,10 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import type { ReactNode } from 'react';
import { Coquille } from '@/layout/coquille';
import PageAchats from '@/pages/achats';
import PageActivation from '@/pages/activation';
import PageAscenseurs from '@/pages/ascenseurs';
import PageBibliotheque from '@/pages/bibliotheque';
import PageCategories from '@/pages/categories';
import PageCompteurs from '@/pages/compteurs';
import PageConnexion from '@/pages/connexion';
@@ -12,7 +14,9 @@ import PageDesign from '@/pages/design';
import PageEtiquette from '@/pages/etiquette';
import PageFicheAscenseur from '@/pages/fiche-ascenseur';
import PageFicheOT from '@/pages/fiche-ot';
import PageFichePiece from '@/pages/fiche-piece';
import PageFicheSite from '@/pages/fiche-site';
import PageNouvelAchat from '@/pages/nouvel-achat';
import PageNouvelAscenseur from '@/pages/nouvel-ascenseur';
import PageNouvelOT from '@/pages/nouvel-ot';
import PageOrdresTravail from '@/pages/ordres-travail';
@@ -20,7 +24,10 @@ import PagePersonnes from '@/pages/personnes';
import PagePortail from '@/pages/portail';
import PagePreventif from '@/pages/preventif';
import PageSites from '@/pages/sites';
import PageStatistiques from '@/pages/statistiques';
import PageStock from '@/pages/stock';
import PageTableauDeBord from '@/pages/tableau-de-bord';
import PageTiers from '@/pages/tiers';
const queryClient = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false } },
@@ -53,6 +60,13 @@ export default function App() {
<Route path="/ot/:id" element={dansCoquille(<PageFicheOT />)} />
<Route path="/demandes" element={dansCoquille(<PageDemandes />)} />
<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>
</BrowserRouter>
</QueryClientProvider>

243
apps/web/src/api/gestion.ts Normal file
View 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')),
});
}

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

View File

@@ -66,15 +66,15 @@ const NAVIGATION: { groupe: string; liens: LienNav[] }[] = [
{
groupe: 'Ressources',
liens: [
{ libelle: 'Stock & achats', icone: IcoStock, release: 'R3' },
{ libelle: 'Tiers', icone: IcoTiers, release: 'R3' },
{ libelle: 'Fichiers', icone: IcoFichiers, release: 'R3' },
{ libelle: 'Stock & achats', icone: IcoStock, route: '/stock', permission: ['PARTS', 'view'] },
{ libelle: 'Tiers', icone: IcoTiers, route: '/tiers', permission: ['PURCHASE_ORDERS', 'view'] },
{ libelle: 'Fichiers', icone: IcoFichiers, route: '/bibliotheque', permission: ['ASSETS', 'view'] },
],
},
{
groupe: 'Pilotage',
liens: [
{ libelle: 'Statistiques', icone: IcoStatistiques, release: 'R3' },
{ libelle: 'Statistiques', icone: IcoStatistiques, route: '/statistiques', permission: ['ANALYTICS', 'view'] },
{ libelle: 'Personnes', icone: IcoPersonnes, route: '/personnes', permission: ['PEOPLE_TEAMS', 'view'] },
],
},

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

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

View File

@@ -11,6 +11,7 @@ import {
useUpdateAsset,
} from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { CarteDocuments } from '@/components/carte-documents';
import { StatutOT } from '@/components/chips-ot';
import { StatutEquipement } from '@/components/statut-equipement';
import { Button } from '@/components/ui/button';
@@ -18,7 +19,7 @@ import { Modale } from '@/components/ui/modale';
import { formatDateFr } from '@/lib/lieux';
/** Fiche appareil — écran validé en R0 (maquette-web, écran 5) ; historique
* (R2) et documents (R3) sont annoncés, pas simulés. */
* réel depuis R2, documents réels depuis R3. */
export default function PageFicheAscenseur() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -135,12 +136,7 @@ export default function PageFicheAscenseur() {
</div>
<div className="flex flex-col gap-4">
<CarteHistorique assetId={asset.id} />
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Documents</h2>
<p className="text-encre-3">
Notices et certificats rejoindront la bibliothèque en R3.
</p>
</div>
<CarteDocuments cible={{ assetId: asset.id }} peutEditer={can('ASSETS', 'edit')} />
</div>
</div>
<NouvelOrgane assetId={asset.id} ouverte={modaleOrgane} surFermeture={setModaleOrgane} />

View File

@@ -17,9 +17,12 @@ import {
useUpsertReport,
useWorkOrder,
} from '@/api/exploitation';
import { useAddLaborTime, useConsumePart, useParts } from '@/api/gestion';
import { useUsers } from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar';
import { CarteDocuments } from '@/components/carte-documents';
import { Modale } from '@/components/ui/modale';
import { PrioriteOT, StatutOT } from '@/components/chips-ot';
import { Button } from '@/components/ui/button';
import { formatDateFr } from '@/lib/lieux';
@@ -91,14 +94,8 @@ export default function PageFicheOT() {
<CarteBilan ot={ot} peutEditer={peutEditer} />
</div>
<div className="flex flex-col gap-4">
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Pièces & main-d'œuvre</h2>
<p className="text-encre-3">Consommations et coûts arrivent avec la gestion (R3).</p>
</div>
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Documents</h2>
<p className="text-encre-3">Photos et pièces jointes arrivent avec la bibliothèque (R3).</p>
</div>
<CarteCouts ot={ot} peutEditer={peutEditer} />
<CarteDocuments cible={{ workOrderId: ot.id }} peutEditer={peutEditer} />
</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, lalerte sallumera.'
: '.'}
</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 }[] = [
{ champ: 'DOOR_STATE', cle: 'doorState', dto: 'doorStateId' },
{ champ: 'CABIN_POSITION', cle: 'cabinPosition', dto: 'cabinPositionId' },

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

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

View File

@@ -6,6 +6,7 @@ import {
useResendInvitation,
useRoles,
useTeams,
useUpdateUser,
useUsers,
} from '@/api/referentiel';
import { usePermissions } from '@/auth/use-permissions';
@@ -44,6 +45,7 @@ export default function PagePersonnes() {
<th>Personne</th>
<th>Rôle</th>
<th>Équipe</th>
<th>Taux horaire</th>
<th>Statut</th>
<th></th>
</tr>
@@ -66,6 +68,13 @@ export default function PagePersonnes() {
</td>
<td>{u.role.name}</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>
{u.status === 'active' ? (
<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({
ouverte,
surFermeture,

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

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

View File

@@ -5,6 +5,7 @@ import {
type WorkOrderStatus,
} from '@siop/shared';
import { usePreventiveStatus, useRequests, useWorkOrders } from '@/api/exploitation';
import { useAnalyticsSummary } from '@/api/gestion';
import { useMe } from '@/auth/use-auth';
import { usePermissions } from '@/auth/use-permissions';
import { Avatar } from '@/components/avatar';
@@ -18,15 +19,16 @@ const ST_COULEURS: Record<string, string> = {
DONE: 'var(--st-termine)',
};
/** Écran 2 validé R0 — alimenté par les données réelles de R2.
* (Coûts → R3 ; pannes par organe → analytics R3.) */
/** Écran 2 validé R0 — données réelles R2, pannes par organe réelles R3. */
export default function PageTableauDeBord() {
const navigate = useNavigate();
const { can, chargé } = usePermissions();
const peutVoirOT = can('WORK_ORDERS', 'view');
const peutVoirStats = can('ANALYTICS', 'view');
const { data: workOrders } = useWorkOrders(peutVoirOT);
const { data: requests } = useRequests();
const { data: preventif } = usePreventiveStatus(peutVoirOT);
const { data: stats } = useAnalyticsSummary(peutVoirStats);
if (!chargé) return null;
if (!peutVoirOT) return <AccueilSansExploitation />;
@@ -120,12 +122,47 @@ export default function PageTableauDeBord() {
))}
</div>
</div>
<div className="carte" style={{ borderStyle: 'dashed' }}>
<h2>Pannes par organe · 12 mois</h2>
<p className="text-encre-3">
Ce graphique s'alimentera des bilans codés — il arrive avec les analytics (R3).
</p>
</div>
{peutVoirStats ? (
<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>
{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 className="carte">
<h2>Interventions récentes</h2>

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

View File

@@ -873,6 +873,50 @@ table {
color: var(--encre); text-align: right;
}
/* ═══ R3 — Gestion (classes extraites de maquette-r3.html validée) ═══ */
/* Statuts de BON DE COMMANDE */
.st.brouillon { color: var(--st-annule); background: var(--st-annule-fond); }
.st.envoye { color: var(--st-ouvert); background: var(--st-ouvert-fond); }
.st.recu { color: var(--st-termine); background: var(--st-termine-fond); }
/* Alerte de stock sous seuil */
.stock-bas {
display: inline-flex; align-items: center; gap: 6px;
color: var(--alerte); font-weight: 700; font-size: 12.5px;
}
.stock-bas::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--alerte); }
.ligne-seuil td:first-child { box-shadow: inset 3px 0 0 var(--alerte); }
/* Mouvements de stock */
.mvt { display: inline-flex; align-items: center; gap: 5px; font-weight: 700; font-size: 12.5px; }
.mvt.entree { color: var(--succes); }
.mvt.sortie { color: var(--danger); }
/* Coûts */
.total { display: flex; justify-content: space-between; font-weight: 700; padding-top: 10px; font-size: 14px; }
/* Bibliothèque */
.docs { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
@media (max-width: 1100px) { .docs { grid-template-columns: repeat(2, 1fr); } }
.doc {
border: 1px solid var(--bordure); border-radius: var(--rayon); background: var(--surface);
padding: 12px; display: flex; flex-direction: column; gap: 8px; font-size: 12.5px;
}
.doc .vignette {
height: 84px; border-radius: 8px; background: var(--surface-2);
display: flex; align-items: center; justify-content: center;
color: var(--encre-3); font-size: 24px;
}
.doc b { font-size: 13px; word-break: break-all; }
.doc .meta { color: var(--encre-2); font-size: 11.5px; }
.type-doc {
display: inline-flex; font-size: 10.5px; font-weight: 700; letter-spacing: 0.05em;
text-transform: uppercase; color: var(--primaire); background: var(--primaire-doux);
padding: 2px 8px; border-radius: var(--rayon-pastille); align-self: flex-start;
}
.type-doc.certificat { color: var(--safran); background: var(--safran-doux); }
.type-doc.photo { color: var(--graf-3); background: var(--st-termine-fond); }
/* ═══ Portail demandeur (écran 6 validé R0 — aucun compte, aucun jargon) ═══ */
.portail {
max-width: 430px;