Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
212 lines
6.6 KiB
TypeScript
212 lines
6.6 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useNavigate } from "@tanstack/react-router";
|
|
import { Search, Briefcase, Users, UserPlus, FileText, Loader2 } from "lucide-react";
|
|
import { supabase } from "@/integrations/supabase/client";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type SearchResult = {
|
|
id: string;
|
|
label: string;
|
|
sublabel?: string;
|
|
kind: "case" | "client" | "contact" | "document";
|
|
to: string;
|
|
};
|
|
|
|
const KIND_ICON = {
|
|
case: Briefcase,
|
|
client: Users,
|
|
contact: UserPlus,
|
|
document: FileText,
|
|
} as const;
|
|
|
|
const KIND_LABEL = {
|
|
case: "Case",
|
|
client: "Client",
|
|
contact: "Contact",
|
|
document: "Document",
|
|
} as const;
|
|
|
|
export function GlobalSearch() {
|
|
const navigate = useNavigate();
|
|
const [query, setQuery] = useState("");
|
|
const [results, setResults] = useState<SearchResult[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [open, setOpen] = useState(false);
|
|
const [activeIdx, setActiveIdx] = useState(0);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Close on outside click
|
|
useEffect(() => {
|
|
const onClick = (e: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
document.addEventListener("mousedown", onClick);
|
|
return () => document.removeEventListener("mousedown", onClick);
|
|
}, []);
|
|
|
|
// Debounced search
|
|
useEffect(() => {
|
|
const q = query.trim();
|
|
if (q.length < 2) {
|
|
setResults([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
const handle = setTimeout(async () => {
|
|
const like = `%${q}%`;
|
|
const [casesRes, clientsRes, contactsRes, docsRes] = await Promise.all([
|
|
supabase
|
|
.from("cases")
|
|
.select("id,title,case_number")
|
|
.or(`title.ilike.${like},case_number.ilike.${like}`)
|
|
.is("archived_at", null)
|
|
.limit(6),
|
|
supabase
|
|
.from("clients")
|
|
.select("id,name")
|
|
.ilike("name", like)
|
|
.is("archived_at", null)
|
|
.limit(6),
|
|
supabase
|
|
.from("contacts")
|
|
.select("id,name,company,email")
|
|
.or(`name.ilike.${like},company.ilike.${like},email.ilike.${like}`)
|
|
.is("archived_at", null)
|
|
.limit(6),
|
|
supabase
|
|
.from("documents")
|
|
.select("id,name,case_id")
|
|
.ilike("name", like)
|
|
.limit(6),
|
|
]);
|
|
|
|
const out: SearchResult[] = [];
|
|
casesRes.data?.forEach((c) =>
|
|
out.push({
|
|
id: c.id,
|
|
label: c.title,
|
|
sublabel: c.case_number,
|
|
kind: "case",
|
|
to: `/cases/${c.id}`,
|
|
}),
|
|
);
|
|
clientsRes.data?.forEach((c) =>
|
|
out.push({ id: c.id, label: c.name, kind: "client", to: `/clients/${c.id}` }),
|
|
);
|
|
contactsRes.data?.forEach((c) =>
|
|
out.push({
|
|
id: c.id,
|
|
label: c.name,
|
|
sublabel: c.company || c.email || undefined,
|
|
kind: "contact",
|
|
to: `/contacts/${c.id}`,
|
|
}),
|
|
);
|
|
docsRes.data?.forEach((d) =>
|
|
out.push({
|
|
id: d.id,
|
|
label: d.name,
|
|
sublabel: "Document",
|
|
kind: "document",
|
|
to: d.case_id ? `/cases/${d.case_id}` : `/files`,
|
|
}),
|
|
);
|
|
|
|
setResults(out);
|
|
setActiveIdx(0);
|
|
setLoading(false);
|
|
}, 250);
|
|
return () => clearTimeout(handle);
|
|
}, [query]);
|
|
|
|
const select = (r: SearchResult) => {
|
|
setOpen(false);
|
|
setQuery("");
|
|
setResults([]);
|
|
navigate({ to: r.to });
|
|
};
|
|
|
|
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setActiveIdx((i) => Math.min(i + 1, results.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setActiveIdx((i) => Math.max(i - 1, 0));
|
|
} else if (e.key === "Enter" && results[activeIdx]) {
|
|
e.preventDefault();
|
|
select(results[activeIdx]);
|
|
} else if (e.key === "Escape") {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className="relative px-3 pt-3">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-sidebar-foreground/50" />
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
setOpen(true);
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
onKeyDown={onKeyDown}
|
|
placeholder="Search…"
|
|
className="w-full h-8 pl-8 pr-2 text-sm rounded-md bg-sidebar-accent/40 text-sidebar-foreground placeholder:text-sidebar-foreground/50 border border-sidebar-border focus:outline-none focus:ring-1 focus:ring-sidebar-primary"
|
|
/>
|
|
{loading && (
|
|
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-sidebar-foreground/50" />
|
|
)}
|
|
</div>
|
|
|
|
{open && query.trim().length >= 2 && (
|
|
<div className="absolute left-3 right-3 top-full mt-1 z-50 max-h-96 overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-lg">
|
|
{results.length === 0 && !loading ? (
|
|
<div className="px-3 py-4 text-xs text-muted-foreground text-center">
|
|
No results
|
|
</div>
|
|
) : (
|
|
<ul className="py-1">
|
|
{results.map((r, idx) => {
|
|
const Icon = KIND_ICON[r.kind];
|
|
return (
|
|
<li key={`${r.kind}-${r.id}`}>
|
|
<button
|
|
type="button"
|
|
onMouseEnter={() => setActiveIdx(idx)}
|
|
onClick={() => select(r)}
|
|
className={cn(
|
|
"w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm",
|
|
idx === activeIdx ? "bg-accent text-accent-foreground" : "",
|
|
)}
|
|
>
|
|
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate">{r.label}</div>
|
|
{r.sublabel && (
|
|
<div className="truncate text-[11px] text-muted-foreground">
|
|
{r.sublabel}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
|
{KIND_LABEL[r.kind]}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|