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:
179
apps/web/src/components/recherche-globale.tsx
Normal file
179
apps/web/src/components/recherche-globale.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user