mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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>
180 lines
5.6 KiB
TypeScript
180 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|