From b06ed684f5c7456601cfe3ebe8952a9abf386dbe Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:08:58 +0000 Subject: [PATCH] Changes Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com> --- src/components/global-search.tsx | 211 +++++++++++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 src/components/global-search.tsx diff --git a/src/components/global-search.tsx b/src/components/global-search.tsx new file mode 100644 index 0000000..0f1915c --- /dev/null +++ b/src/components/global-search.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const [activeIdx, setActiveIdx] = useState(0); + const containerRef = useRef(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) => { + 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 ( +
+
+ + { + 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 && ( + + )} +
+ + {open && query.trim().length >= 2 && ( +
+ {results.length === 0 && !loading ? ( +
+ No results +
+ ) : ( +
    + {results.map((r, idx) => { + const Icon = KIND_ICON[r.kind]; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ )} +
+ ); +}