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:
pr-daaif
2026-07-17 02:14:43 +01:00
parent c46a97ed08
commit 460ef4a80e
38 changed files with 1318 additions and 65 deletions

View File

@@ -0,0 +1,179 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { SEARCH_MIN_CHARS } from '@siop/shared';
import { useGlobalSearch } from '@/api/recherche';
import { StatutOT } from '@/components/chips-ot';
import { IcoRecherche } from '@/components/nav-icons';
interface Resultat {
cle: string;
route: string;
contenu: React.ReactNode;
}
/** La recherche de la topbar, enfin active (recette R3) : ⌘K pour saisir,
* résultats groupés (OT / ascenseurs / sites), déjà filtrés par la matrice
* côté API — flèches + Entrée pour naviguer. */
export function RechercheGlobale() {
const navigate = useNavigate();
const [saisie, setSaisie] = useState('');
const [q, setQ] = useState('');
const [ouverte, setOuverte] = useState(false);
const [actif, setActif] = useState(0);
const champ = useRef<HTMLInputElement>(null);
// Débounce : on n'interroge l'API que 200 ms après la dernière frappe.
useEffect(() => {
const t = setTimeout(() => setQ(saisie), 200);
return () => clearTimeout(t);
}, [saisie]);
// ⌘K / Ctrl+K : focus depuis n'importe où.
useEffect(() => {
const surTouche = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
champ.current?.focus();
champ.current?.select();
}
};
window.addEventListener('keydown', surTouche);
return () => window.removeEventListener('keydown', surTouche);
}, []);
const { data } = useGlobalSearch(q);
const resultats = useMemo<Resultat[]>(() => {
if (!data) return [];
return [
...data.workOrders.map((w) => ({
cle: `ot-${w.id}`,
route: `/ot/${w.id}`,
contenu: (
<>
<span className="ref num">{w.reference}</span>
<span className="titre">{w.title}</span>
<span className="fin"><StatutOT statut={w.status} /></span>
</>
),
})),
...data.assets.map((a) => ({
cle: `asc-${a.id}`,
route: `/ascenseurs/${a.id}`,
contenu: (
<>
<span className="ref num">{a.reference}</span>
<span className="titre">
{a.brand}
{a.model ? ` ${a.model}` : ''}
</span>
<span className="fin">{a.siteName}</span>
</>
),
})),
...data.sites.map((s) => ({
cle: `site-${s.id}`,
route: `/sites/${s.id}`,
contenu: (
<>
<span className="titre"><b>{s.name}</b></span>
<span className="fin">{s.city ?? ''}</span>
</>
),
})),
];
}, [data]);
const groupes = useMemo(() => {
if (!data) return [];
const g: { libelle: string; debut: number; nombre: number }[] = [];
if (data.workOrders.length) g.push({ libelle: 'Ordres de travail', debut: 0, nombre: data.workOrders.length });
if (data.assets.length) g.push({ libelle: 'Ascenseurs', debut: data.workOrders.length, nombre: data.assets.length });
if (data.sites.length) {
g.push({
libelle: 'Sites',
debut: data.workOrders.length + data.assets.length,
nombre: data.sites.length,
});
}
return g;
}, [data]);
const visible = ouverte && q.trim().length >= SEARCH_MIN_CHARS;
const aller = (r: Resultat | undefined) => {
if (!r) return;
setOuverte(false);
setSaisie('');
navigate(r.route);
};
return (
<div className="recherche" data-active>
<IcoRecherche />
<input
ref={champ}
type="search"
placeholder="Rechercher un OT, un ascenseur, un site…"
aria-label="Recherche globale"
value={saisie}
onChange={(e) => {
setSaisie(e.target.value);
setOuverte(true);
setActif(0);
}}
onFocus={() => setOuverte(true)}
onBlur={() => setOuverte(false)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setOuverte(false);
champ.current?.blur();
}
if (!visible || !resultats.length) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActif((a) => Math.min(a + 1, resultats.length - 1));
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActif((a) => Math.max(a - 1, 0));
}
if (e.key === 'Enter') {
e.preventDefault();
aller(resultats[actif]);
}
}}
/>
<kbd>K</kbd>
{visible ? (
<div className="resultats" role="listbox" aria-label="Résultats">
{groupes.map((g) => (
<div key={g.libelle}>
<div className="groupe-resultats">{g.libelle}</div>
{resultats.slice(g.debut, g.debut + g.nombre).map((r, i) => (
<button
key={r.cle}
type="button"
role="option"
aria-selected={g.debut + i === actif}
className="resultat"
data-actif={g.debut + i === actif || undefined}
onMouseEnter={() => setActif(g.debut + i)}
onMouseDown={(e) => {
e.preventDefault(); // avant le blur du champ
aller(r);
}}
>
{r.contenu}
</button>
))}
</div>
))}
{!resultats.length ? (
<div className="groupe-resultats">Aucun résultat pour « {q.trim()} »</div>
) : null}
</div>
) : null}
</div>
);
}