mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r3.3): écrans web de la gestion — stock, achats, coûts OT, documents, statistiques
- Stock : alertes sous seuil (strie ambre), « Préparer le BC » sur la ligne ; fiche pièce où les mouvements SONT le stock (entrée / ajustement motivé) - Bons de commande : liste + panneau (envoi, annulation, réception → entrées de stock à PU figé) ; création préremplie depuis l'alerte de seuil - Fiche OT : carte « Pièces & main-d'œuvre » réelle (consommation à prix figé annoncé avant le clic, temps au taux figé) + carte Documents (upload/download) ; même carte Documents sur la fiche ascenseur - Statistiques : 4 KPI, pannes par organe (bilans codés), coûts par mois, top équipements ; le tableau de bord allume « Pannes par organe » - Tiers, bibliothèque (upload rattaché, filtre par type), taux horaire éditable dans Personnes ; nav Ressources/Pilotage activée par la matrice - Recette R3 Playwright (2 parcours autonomes) ; cleanup-e2e étendu aux entités R3 ; le formulaire BC attend la fin du refetch (stock périmé en cache) 13/13 e2e verts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
154
apps/web/src/pages/bibliotheque.tsx
Normal file
154
apps/web/src/pages/bibliotheque.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
DOCUMENT_KIND_LABELS,
|
||||
DOCUMENT_KINDS,
|
||||
DOCUMENT_MAX_BYTES,
|
||||
type DocumentKind,
|
||||
} from '@siop/shared';
|
||||
import { useDeleteDocument, useDocuments, useUploadDocument } from '@/api/gestion';
|
||||
import { useAssetOptions } from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { tailleLisible, VignetteDoc } from '@/components/carte-documents';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
|
||||
/** Écran 7 des maquettes R3 : notices, certificats, photos — toujours
|
||||
* RATTACHÉS (appareil ou OT). Ces documents nourriront le RAG en R5. */
|
||||
export default function PageBibliotheque() {
|
||||
const { can } = usePermissions();
|
||||
const [kind, setKind] = useState<DocumentKind | ''>('');
|
||||
const { data: documents } = useDocuments(kind ? { kind } : {});
|
||||
const suppression = useDeleteDocument();
|
||||
const [modale, setModale] = useState(false);
|
||||
|
||||
const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0);
|
||||
const peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Bibliothèque</h1>
|
||||
<span className="filajout">
|
||||
{documents?.length ?? 0} documents · {tailleLisible(totalOctets)}
|
||||
</span>
|
||||
<div className="actions">
|
||||
{peutEditer ? (
|
||||
<Button variant="prim" onClick={() => setModale(true)}>Téléverser</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="filtres">
|
||||
<select
|
||||
aria-label="Filtrer par type"
|
||||
value={kind}
|
||||
onChange={(e) => setKind(e.target.value as DocumentKind | '')}
|
||||
>
|
||||
<option value="">Type : tous</option>
|
||||
{DOCUMENT_KINDS.map((k) => (
|
||||
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{documents?.length ? (
|
||||
<div className="docs">
|
||||
{documents.map((d) => (
|
||||
<VignetteDoc
|
||||
key={d.id}
|
||||
doc={d}
|
||||
surSuppression={peutEditer ? (id) => suppression.mutate(id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="carte" style={{ color: 'var(--encre-2)' }}>Aucun document.</div>
|
||||
)}
|
||||
<div className="carte" style={{ borderStyle: 'dashed', color: 'var(--encre-2)', fontSize: 13 }}>
|
||||
Ces documents nourriront l'assistant RAG en R5 (réponses citant leurs sources) —
|
||||
le rattachement propre commence ici.
|
||||
</div>
|
||||
{peutEditer ? <ModaleTeleversement ouverte={modale} surFermeture={setModale} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleTeleversement({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
}: {
|
||||
ouverte: boolean;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const { data: options } = useAssetOptions();
|
||||
const upload = useUploadDocument();
|
||||
const fichierRef = useRef<HTMLInputElement>(null);
|
||||
const [kind, setKind] = useState<DocumentKind>('NOTICE');
|
||||
const [assetId, setAssetId] = useState('');
|
||||
const [fichier, setFichier] = useState<File | null>(null);
|
||||
|
||||
const envoyer = () => {
|
||||
if (!fichier || !assetId) return;
|
||||
if (fichier.size > DOCUMENT_MAX_BYTES) {
|
||||
window.alert('Fichier trop lourd (20 Mo max).');
|
||||
return;
|
||||
}
|
||||
upload.mutate(
|
||||
{ file: fichier, kind, assetId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFichier(null);
|
||||
surFermeture(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre="Téléverser un document" ouverte={ouverte} surFermeture={surFermeture}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="tv-appareil">Rattacher à l'appareil *</label>
|
||||
<select id="tv-appareil" value={assetId} onChange={(e) => setAssetId(e.target.value)}>
|
||||
<option value="">Sélectionner…</option>
|
||||
{(options ?? []).map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.reference} — {a.siteName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="tv-type">Type *</label>
|
||||
<select id="tv-type" value={kind} onChange={(e) => setKind(e.target.value as DocumentKind)}>
|
||||
{DOCUMENT_KINDS.map((k) => (
|
||||
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
ref={fichierRef}
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
aria-label="Choisir un fichier"
|
||||
className="hidden"
|
||||
onChange={(e) => setFichier(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<button type="button" className="zone-photo" onClick={() => fichierRef.current?.click()}>
|
||||
{fichier
|
||||
? `📄 ${fichier.name} (${tailleLisible(fichier.size)})`
|
||||
: '📁 Choisir un fichier — PDF, JPG, PNG · 20 Mo max'}
|
||||
</button>
|
||||
<p className="note-douce">
|
||||
Les photos prises sur une intervention se téléversent plutôt depuis la fiche OT —
|
||||
elles restent rattachées à l'intervention.
|
||||
</p>
|
||||
{upload.isError ? <p className="erreur-form">{upload.error.message}</p> : null}
|
||||
<div className="pied">
|
||||
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||
<Button variant="prim" disabled={!fichier || !assetId || upload.isPending} onClick={envoyer}>
|
||||
Téléverser
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user