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

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