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