Changes
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
co-authored by
renee-png
parent
849cc029a0
commit
b06ed684f5
@@ -0,0 +1,211 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user