mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r3.4): corrections de recette (8 écarts) + recherche globale ⌘K
Arbitrage du référent sur la revue pixel : tout corriger, activer la recherche. - Stock : filtre fournisseur, sous-seuil en tête, « Entrée de stock » depuis la liste ; fiche pièce : fournisseur → lien Tiers. - Statistiques : période 3/6/12 mois (paramètre months au contrat). - Tiers : rattachements syndic→site (migration r3_recette_fixes, Location.partnerId gardé CLIENT), éditable sur la fiche site, seedé. - Bibliothèque : filtre « Rattaché à » + glisser-déposer (modale préremplie, rattachement toujours requis). - Recherche globale : GET /search (73 opérations) — familles OT/ ascenseurs/sites filtrées par la matrice, « voir autre » respecté ; topbar ⌘K, debounce, résultats groupés, navigation clavier. 74 tests API (8 nouveaux sur le scoping de la recherche), 14/14 Playwright dont un parcours « recette corrigée », 18/18 contrôles en navigateur réel, zéro erreur console. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
import {
|
||||
DOCUMENT_KIND_LABELS,
|
||||
DOCUMENT_KINDS,
|
||||
@@ -17,13 +17,29 @@ import { Modale } from '@/components/ui/modale';
|
||||
export default function PageBibliotheque() {
|
||||
const { can } = usePermissions();
|
||||
const [kind, setKind] = useState<DocumentKind | ''>('');
|
||||
const { data: documents } = useDocuments(kind ? { kind } : {});
|
||||
const [assetId, setAssetId] = useState('');
|
||||
const { data: options } = useAssetOptions();
|
||||
const { data: documents } = useDocuments({
|
||||
...(kind ? { kind } : {}),
|
||||
...(assetId ? { assetId } : {}),
|
||||
});
|
||||
const suppression = useDeleteDocument();
|
||||
const [modale, setModale] = useState(false);
|
||||
const [fichierDepose, setFichierDepose] = useState<File | null>(null);
|
||||
const [survol, setSurvol] = useState(false);
|
||||
|
||||
const totalOctets = (documents ?? []).reduce((s, d) => s + d.size, 0);
|
||||
const peutEditer = can('WORK_ORDERS', 'edit') || can('ASSETS', 'edit');
|
||||
|
||||
const surDepot = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
setSurvol(false);
|
||||
const fichier = e.dataTransfer.files?.[0];
|
||||
if (!fichier) return;
|
||||
setFichierDepose(fichier);
|
||||
setModale(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
@@ -48,7 +64,35 @@ export default function PageBibliotheque() {
|
||||
<option key={k} value={k}>{DOCUMENT_KIND_LABELS[k]}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filtrer par appareil"
|
||||
value={assetId}
|
||||
onChange={(e) => setAssetId(e.target.value)}
|
||||
>
|
||||
<option value="">Rattaché à : tout le parc</option>
|
||||
{(options ?? []).map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.reference} — {a.siteName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{peutEditer ? (
|
||||
<button
|
||||
type="button"
|
||||
className="zone-depot"
|
||||
data-survol={survol || undefined}
|
||||
onClick={() => setModale(true)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setSurvol(true);
|
||||
}}
|
||||
onDragLeave={() => setSurvol(false)}
|
||||
onDrop={surDepot}
|
||||
>
|
||||
📂 Glissez un fichier ici — PDF, JPG, PNG · 20 Mo max · rattachez-le à un appareil ou un OT
|
||||
</button>
|
||||
) : null}
|
||||
{documents?.length ? (
|
||||
<div className="docs">
|
||||
{documents.map((d) => (
|
||||
@@ -66,16 +110,27 @@ export default function PageBibliotheque() {
|
||||
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}
|
||||
{peutEditer ? (
|
||||
<ModaleTeleversement
|
||||
ouverte={modale}
|
||||
fichierInitial={fichierDepose}
|
||||
surFermeture={(o) => {
|
||||
setModale(o);
|
||||
if (!o) setFichierDepose(null);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleTeleversement({
|
||||
ouverte,
|
||||
fichierInitial,
|
||||
surFermeture,
|
||||
}: {
|
||||
ouverte: boolean;
|
||||
fichierInitial?: File | null;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const { data: options } = useAssetOptions();
|
||||
@@ -85,6 +140,11 @@ function ModaleTeleversement({
|
||||
const [assetId, setAssetId] = useState('');
|
||||
const [fichier, setFichier] = useState<File | null>(null);
|
||||
|
||||
// Fichier arrivé par glisser-déposer : la modale s'ouvre déjà remplie.
|
||||
useEffect(() => {
|
||||
if (fichierInitial) setFichier(fichierInitial);
|
||||
}, [fichierInitial]);
|
||||
|
||||
const envoyer = () => {
|
||||
if (!fichier || !assetId) return;
|
||||
if (fichier.size > DOCUMENT_MAX_BYTES) {
|
||||
|
||||
@@ -52,7 +52,15 @@ export default function PageFichePiece() {
|
||||
{piece.lastUnitPrice != null ? `${fmt.format(piece.lastUnitPrice)} MAD` : 'aucun prix connu'}
|
||||
</dd>
|
||||
<dt>Fournisseur</dt>
|
||||
<dd>{piece.supplierName ?? '—'}</dd>
|
||||
<dd>
|
||||
{piece.supplierName ? (
|
||||
<Link to="/tiers" style={{ color: 'var(--primaire)', fontWeight: 600 }}>
|
||||
{piece.supplierName}
|
||||
</Link>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</dd>
|
||||
<dt>Compatible</dt>
|
||||
<dd>{piece.compatible ?? '—'}</dd>
|
||||
</dl>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useAssets, useCreateLocation, useLocations } from '@/api/referentiel';
|
||||
import { usePartners } from '@/api/gestion';
|
||||
import { useAssets, useCreateLocation, useLocations, useUpdateLocation } from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { CarteGeo } from '@/components/carte-geo';
|
||||
import { StatutEquipement } from '@/components/statut-equipement';
|
||||
@@ -56,6 +57,14 @@ export default function PageFicheSite() {
|
||||
<span className="num"> · {site.guardianPhone}</span>
|
||||
) : null}
|
||||
</dd>
|
||||
<dt>Client / syndic</dt>
|
||||
<dd>
|
||||
<ChampSyndic
|
||||
siteId={site.id}
|
||||
partnerId={site.partnerId}
|
||||
partnerName={site.partnerName}
|
||||
/>
|
||||
</dd>
|
||||
<dt>Appareils</dt>
|
||||
<dd className="num">{site.assetCount}</dd>
|
||||
</dl>
|
||||
@@ -121,6 +130,40 @@ export default function PageFicheSite() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Rattachement du site à son client/syndic (colonne « Rattachements » des
|
||||
* Tiers). Éditable si LOCATIONS.edit ET si la matrice donne accès aux tiers
|
||||
* (la liste des tiers est sous PURCHASE_ORDERS.view) — sinon, lecture seule. */
|
||||
function ChampSyndic({
|
||||
siteId,
|
||||
partnerId,
|
||||
partnerName,
|
||||
}: {
|
||||
siteId: string;
|
||||
partnerId: string | null;
|
||||
partnerName: string | null;
|
||||
}) {
|
||||
const { can } = usePermissions();
|
||||
const editable = can('LOCATIONS', 'edit') && can('PURCHASE_ORDERS', 'view');
|
||||
const { data: partners } = usePartners(editable);
|
||||
const maj = useUpdateLocation(siteId);
|
||||
|
||||
if (!editable) return <>{partnerName ?? '—'}</>;
|
||||
const clients = (partners ?? []).filter((p) => p.kind === 'CLIENT' && p.isActive);
|
||||
return (
|
||||
<select
|
||||
aria-label="Client / syndic du site"
|
||||
value={partnerId ?? ''}
|
||||
onChange={(e) => maj.mutate({ partnerId: e.target.value || null })}
|
||||
disabled={maj.isPending}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function NouvelleZone({
|
||||
siteId,
|
||||
ouverte,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { ANALYTICS_PERIODS, type AnalyticsPeriod } from '@siop/shared';
|
||||
import { useAnalyticsSummary } from '@/api/gestion';
|
||||
|
||||
const fmt = new Intl.NumberFormat('fr-FR');
|
||||
@@ -16,7 +18,8 @@ function moisLisible(period: string): string {
|
||||
/** É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();
|
||||
const [periode, setPeriode] = useState<AnalyticsPeriod>(12);
|
||||
const { data } = useAnalyticsSummary(periode);
|
||||
if (!data) return null;
|
||||
|
||||
const delta =
|
||||
@@ -30,7 +33,17 @@ export default function PageStatistiques() {
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Statistiques</h1>
|
||||
<span className="filajout">12 derniers mois</span>
|
||||
<div className="actions">
|
||||
<select
|
||||
aria-label="Période"
|
||||
value={periode}
|
||||
onChange={(e) => setPeriode(Number(e.target.value) as AnalyticsPeriod)}
|
||||
>
|
||||
{ANALYTICS_PERIODS.map((m) => (
|
||||
<option key={m} value={m}>Période : {m} derniers mois</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpis" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||
<div className="kpi">
|
||||
@@ -50,9 +63,9 @@ export default function PageStatistiques() {
|
||||
)}
|
||||
</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 className="lib">OT clôturés · {data.months} mois</div>
|
||||
<div className="val num">{fmt.format(data.closed.total)}</div>
|
||||
<div className="delta neutre">dont {fmt.format(data.closed.preventive)} préventifs</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
<div className="lib">Taux de préventif</div>
|
||||
@@ -66,13 +79,13 @@ export default function PageStatistiques() {
|
||||
<div className="val num">
|
||||
{data.avgResolutionDays != null ? `${fmtCompact.format(data.avgResolutionDays)} j` : '—'}
|
||||
</div>
|
||||
<div className="delta neutre">dépannages, 12 mois</div>
|
||||
<div className="delta neutre">dépannages, {data.months} mois</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grille-2">
|
||||
<div className="carte graf">
|
||||
<h2>
|
||||
Pannes par organe · 12 mois{' '}
|
||||
Pannes par organe · {data.months} mois{' '}
|
||||
<span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--encre-3)' }}>
|
||||
(bilans codés)
|
||||
</span>
|
||||
@@ -119,7 +132,7 @@ export default function PageStatistiques() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="carte">
|
||||
<h2>Top équipements en coût · 12 mois</h2>
|
||||
<h2>Top équipements en coût · {data.months} mois</h2>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCreatePart, usePartners, useParts } from '@/api/gestion';
|
||||
import { useAddStockMovement, useCreatePart, usePartners, useParts } from '@/api/gestion';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
@@ -8,17 +8,26 @@ 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 ». */
|
||||
* quantité saisie. Sous le seuil : strie ambre + « Préparer le BC »,
|
||||
* et les pièces en alerte remontent en tête de liste (maquette). */
|
||||
export default function PageStock() {
|
||||
const navigate = useNavigate();
|
||||
const { data: parts } = useParts();
|
||||
const { can } = usePermissions();
|
||||
const [modale, setModale] = useState(false);
|
||||
const [modale, setModale] = useState<'piece' | 'entree' | null>(null);
|
||||
const [alerte, setAlerte] = useState(false);
|
||||
const [fournisseur, setFournisseur] = useState('');
|
||||
|
||||
const toutes = parts ?? [];
|
||||
const sousSeuil = toutes.filter((p) => p.belowThreshold && p.isActive);
|
||||
const visibles = alerte ? sousSeuil : toutes;
|
||||
const fournisseurs = [...new Set(toutes.map((p) => p.supplierName).filter(Boolean))] as string[];
|
||||
const visibles = (alerte ? sousSeuil : toutes)
|
||||
.filter((p) => !fournisseur || p.supplierName === fournisseur)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(b.belowThreshold && b.isActive) - Number(a.belowThreshold && a.isActive) ||
|
||||
a.reference.localeCompare(b.reference),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -37,8 +46,11 @@ export default function PageStock() {
|
||||
{can('PURCHASE_ORDERS', 'view') ? (
|
||||
<Link to="/achats"><Button>Bons de commande</Button></Link>
|
||||
) : null}
|
||||
{can('PARTS', 'edit') ? (
|
||||
<Button onClick={() => setModale('entree')}>Entrée de stock</Button>
|
||||
) : null}
|
||||
{can('PARTS', 'create') ? (
|
||||
<Button variant="prim" onClick={() => setModale(true)}>
|
||||
<Button variant="prim" onClick={() => setModale('piece')}>
|
||||
+ Nouvelle pièce
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -53,6 +65,16 @@ export default function PageStock() {
|
||||
<option value="">Alerte : toutes</option>
|
||||
<option value="seuil">Sous le seuil</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="Filtrer par fournisseur"
|
||||
value={fournisseur}
|
||||
onChange={(e) => setFournisseur(e.target.value)}
|
||||
>
|
||||
<option value="">Fournisseur : tous</option>
|
||||
{fournisseurs.map((f) => (
|
||||
<option key={f} value={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="carte" style={{ padding: 0 }}>
|
||||
<div className="table">
|
||||
@@ -105,11 +127,72 @@ export default function PageStock() {
|
||||
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}
|
||||
{can('PARTS', 'create') ? (
|
||||
<NouvellePiece ouverte={modale === 'piece'} surFermeture={() => setModale(null)} />
|
||||
) : null}
|
||||
{can('PARTS', 'edit') && modale === 'entree' ? (
|
||||
<EntreeDeStock surFermeture={() => setModale(null)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Entrée de stock depuis la liste (maquette) : on choisit la pièce ici —
|
||||
* même mouvement tracé que depuis la fiche. */
|
||||
function EntreeDeStock({ surFermeture }: { surFermeture: () => void }) {
|
||||
const { data: parts } = useParts();
|
||||
const [pieceId, setPieceId] = useState('');
|
||||
const mouvement = useAddStockMovement(pieceId);
|
||||
const actives = (parts ?? []).filter((p) => p.isActive);
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!pieceId) return;
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
mouvement.mutate(
|
||||
{
|
||||
kind: 'ENTRY',
|
||||
quantity: Number(donnees.get('quantity')),
|
||||
reason: String(donnees.get('reason') ?? '').trim() || undefined,
|
||||
},
|
||||
{ onSuccess: surFermeture },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre="Entrée de stock" ouverte surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="es-piece">Pièce *</label>
|
||||
<select id="es-piece" required value={pieceId} onChange={(e) => setPieceId(e.target.value)}>
|
||||
<option value="">Sélectionner…</option>
|
||||
{actives.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.reference} — {p.designation} (stock : {p.stock})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="es-qte">Quantité *</label>
|
||||
<input id="es-qte" name="quantity" type="number" required min={1} placeholder="5" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="es-motif">Motif (optionnel)</label>
|
||||
<input id="es-motif" name="reason" placeholder="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={!pieceId || mouvement.isPending}>
|
||||
Tracer le mouvement
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
|
||||
function NouvellePiece({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function PageTableauDeBord() {
|
||||
const { data: workOrders } = useWorkOrders(peutVoirOT);
|
||||
const { data: requests } = useRequests();
|
||||
const { data: preventif } = usePreventiveStatus(peutVoirOT);
|
||||
const { data: stats } = useAnalyticsSummary(peutVoirStats);
|
||||
const { data: stats } = useAnalyticsSummary(12, peutVoirStats);
|
||||
|
||||
if (!chargé) return null;
|
||||
if (!peutVoirOT) return <AccueilSansExploitation />;
|
||||
|
||||
@@ -49,7 +49,9 @@ export default function PageTiers() {
|
||||
? p.openOrders
|
||||
? `${p.openOrders} BC en cours`
|
||||
: '—'
|
||||
: '—'}
|
||||
: p.siteNames.length
|
||||
? p.siteNames.join(' · ')
|
||||
: '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user