feat(search): add header global search (Phase A) — cases + precedents + docs
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 41s
Adds an always-visible debounced search input in the AppShell header that fans out to three independent sources in parallel and renders per-source result groups with their own loading/empty/error states: - /api/search/cases (NEW): SQL ILIKE on case_number, address, parties, title, subject. Returns small projections, no embeddings needed. - /api/precedent-library/search (existing): semantic over case-law halachot + passages. - /api/search (existing): semantic over case documents + past decisions. Cmd/Ctrl+K focuses the input; Esc and click-outside close the panel. This is Phase A of the header redesign — the bar layout itself is unchanged; row grouping + dynamic context follow in Phase B. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { GlobalSearch } from "@/components/global-search";
|
||||||
|
|
||||||
type AgentBoard = {
|
type AgentBoard = {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
@@ -83,7 +84,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav
|
<nav
|
||||||
className="me-auto flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
aria-label="ניווט ראשי"
|
aria-label="ניווט ראשי"
|
||||||
>
|
>
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
@@ -114,6 +115,10 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 max-w-[460px] mx-4 flex justify-center">
|
||||||
|
<GlobalSearch />
|
||||||
|
</div>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
className="
|
className="
|
||||||
|
|||||||
340
web-ui/src/components/global-search.tsx
Normal file
340
web-ui/src/components/global-search.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Search, FileText, BookOpen, FolderOpen, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useGlobalSearch,
|
||||||
|
type CaseHit,
|
||||||
|
type DocumentHit,
|
||||||
|
} from "@/lib/api/global-search";
|
||||||
|
import type { SearchHit as PrecedentHit } from "@/lib/api/precedent-library";
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Header global search — debounced input + per-source result panel.
|
||||||
|
*
|
||||||
|
* Three independent fan-out queries (cases / precedent / documents) each
|
||||||
|
* render their own section with an own loading/empty state, so the fast
|
||||||
|
* SQL source paints immediately while the slower vector sources stream in.
|
||||||
|
*/
|
||||||
|
export function GlobalSearch() {
|
||||||
|
const [raw, setRaw] = useState("");
|
||||||
|
const [debounced, setDebounced] = useState("");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listboxId = useId();
|
||||||
|
|
||||||
|
// Debounce.
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(raw), DEBOUNCE_MS);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [raw]);
|
||||||
|
|
||||||
|
// Cmd/Ctrl+K — focus + select.
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const isModK = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k";
|
||||||
|
if (isModK) {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
inputRef.current?.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Click-outside.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("mousedown", onClick);
|
||||||
|
return () => window.removeEventListener("mousedown", onClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Route changes don't need a separate listener: clicking any link
|
||||||
|
// (nav, search result, in-page) triggers mousedown outside wrapperRef,
|
||||||
|
// and the click-outside effect closes the panel.
|
||||||
|
|
||||||
|
const { cases, precedent, documents, isQueryReady, anyLoading } =
|
||||||
|
useGlobalSearch(debounced);
|
||||||
|
|
||||||
|
const showPanel = open && isQueryReady;
|
||||||
|
|
||||||
|
const hasAnyResults = useMemo(() => {
|
||||||
|
return (
|
||||||
|
(cases.data?.items.length ?? 0) > 0 ||
|
||||||
|
(precedent.data?.items.length ?? 0) > 0 ||
|
||||||
|
(documents.data?.length ?? 0) > 0
|
||||||
|
);
|
||||||
|
}, [cases.data, precedent.data, documents.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="relative w-full max-w-[460px]">
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
className="absolute end-3 top-1/2 -translate-y-1/2 size-4 text-parchment/50 pointer-events-none"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="search"
|
||||||
|
value={raw}
|
||||||
|
onChange={(e) => {
|
||||||
|
setRaw(e.target.value);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="חפש תיק, פסיקה, או מסמך…"
|
||||||
|
aria-label="חיפוש גלובלי"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className="
|
||||||
|
w-full ps-3 pe-10 py-2 rounded-md
|
||||||
|
bg-navy-soft/60 border border-parchment/15
|
||||||
|
text-sm text-parchment placeholder:text-parchment/40
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-gold/60 focus:border-transparent
|
||||||
|
transition-colors
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<kbd
|
||||||
|
className="
|
||||||
|
absolute end-9 top-1/2 -translate-y-1/2
|
||||||
|
text-[10px] font-mono text-parchment/40
|
||||||
|
border border-parchment/20 rounded px-1 py-0.5
|
||||||
|
pointer-events-none select-none
|
||||||
|
hidden md:inline-block
|
||||||
|
"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPanel && (
|
||||||
|
<div
|
||||||
|
id={listboxId}
|
||||||
|
role="listbox"
|
||||||
|
aria-label="תוצאות חיפוש"
|
||||||
|
className="
|
||||||
|
absolute top-full inset-x-0 mt-2 z-50
|
||||||
|
rounded-lg bg-popover text-popover-foreground
|
||||||
|
shadow-xl ring-1 ring-foreground/10
|
||||||
|
max-h-[70vh] overflow-y-auto
|
||||||
|
"
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
{anyLoading && !hasAnyResults && (
|
||||||
|
<div className="px-4 py-6 text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<Loader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||||
|
מחפש…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ResultGroup
|
||||||
|
title="תיקים"
|
||||||
|
icon={<FolderOpen className="size-4" aria-hidden="true" />}
|
||||||
|
isLoading={cases.isLoading}
|
||||||
|
isError={cases.isError}
|
||||||
|
count={cases.data?.count}
|
||||||
|
>
|
||||||
|
{cases.data?.items.map((hit) => (
|
||||||
|
<CaseRow key={hit.case_number} hit={hit} onSelect={() => setOpen(false)} />
|
||||||
|
))}
|
||||||
|
</ResultGroup>
|
||||||
|
|
||||||
|
<ResultGroup
|
||||||
|
title="ספריית פסיקה"
|
||||||
|
icon={<BookOpen className="size-4" aria-hidden="true" />}
|
||||||
|
isLoading={precedent.isLoading}
|
||||||
|
isError={precedent.isError}
|
||||||
|
count={precedent.data?.count}
|
||||||
|
seeMoreHref={`/precedents?q=${encodeURIComponent(debounced)}`}
|
||||||
|
>
|
||||||
|
{precedent.data?.items.map((hit, i) => (
|
||||||
|
<PrecedentRow key={`${hit.case_law_id}-${i}`} hit={hit} onSelect={() => setOpen(false)} />
|
||||||
|
))}
|
||||||
|
</ResultGroup>
|
||||||
|
|
||||||
|
<ResultGroup
|
||||||
|
title="מסמכי תיקים והחלטות"
|
||||||
|
icon={<FileText className="size-4" aria-hidden="true" />}
|
||||||
|
isLoading={documents.isLoading}
|
||||||
|
isError={documents.isError}
|
||||||
|
count={documents.data?.length}
|
||||||
|
>
|
||||||
|
{documents.data?.map((hit, i) => (
|
||||||
|
<DocumentRow key={`${hit.case_number}-${i}`} hit={hit} onSelect={() => setOpen(false)} />
|
||||||
|
))}
|
||||||
|
</ResultGroup>
|
||||||
|
|
||||||
|
{!anyLoading && !hasAnyResults && (
|
||||||
|
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
|
||||||
|
לא נמצא כלום עבור <span className="font-mono">{debounced}</span>.
|
||||||
|
<br />
|
||||||
|
נסה מילים נרדפות או חפש לפי מספר תיק.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Result groups + rows ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ResultGroup({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
count,
|
||||||
|
seeMoreHref,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
isLoading: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
count: number | undefined;
|
||||||
|
seeMoreHref?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const hasItems = (count ?? 0) > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-foreground/5 last:border-b-0">
|
||||||
|
<div className="flex items-center gap-2 px-3 pt-3 pb-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
{icon}
|
||||||
|
<span>{title}</span>
|
||||||
|
{count !== undefined && (
|
||||||
|
<span className="text-foreground/50 normal-case font-normal">({count})</span>
|
||||||
|
)}
|
||||||
|
{isLoading && <Loader2 className="size-3 animate-spin ms-auto" aria-hidden="true" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<div className="px-3 pb-2 text-xs text-destructive">שגיאה בטעינת תוצאות</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && !hasItems && (
|
||||||
|
<div className="px-3 pb-2 text-xs text-muted-foreground/70">אין תוצאות בקטגוריה זו</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col">{children}</div>
|
||||||
|
|
||||||
|
{seeMoreHref && hasItems && (
|
||||||
|
<Link
|
||||||
|
href={seeMoreHref}
|
||||||
|
className="block px-3 py-1.5 text-xs text-gold-deep hover:bg-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
הצג עוד →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CaseRow({ hit, onSelect }: { hit: CaseHit; onSelect: () => void }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/cases/${encodeURIComponent(hit.case_number)}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
role="option"
|
||||||
|
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="font-medium text-sm">ערר {hit.case_number}</span>
|
||||||
|
{hit.status && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||||
|
{hit.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{hit.title}
|
||||||
|
{hit.property_address && <span> · {hit.property_address}</span>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PrecedentRow({ hit, onSelect }: { hit: PrecedentHit; onSelect: () => void }) {
|
||||||
|
const href = `/precedents/${hit.case_law_id}`;
|
||||||
|
const pct = Math.round(hit.score * 100);
|
||||||
|
|
||||||
|
if (hit.type === "halacha") {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
onClick={onSelect}
|
||||||
|
role="option"
|
||||||
|
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="font-medium text-sm line-clamp-1">הלכה — {hit.rule_statement}</span>
|
||||||
|
<span className="text-[10px] text-gold-deep tabular-nums">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{hit.case_name} · {hit.case_number}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
onClick={onSelect}
|
||||||
|
role="option"
|
||||||
|
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="font-medium text-sm">פסקה</span>
|
||||||
|
<span className="text-[10px] text-gold-deep tabular-nums">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
“{hit.content.slice(0, 160)}…”
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground/70">
|
||||||
|
{hit.case_name} · {hit.case_number}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentRow({ hit, onSelect }: { hit: DocumentHit; onSelect: () => void }) {
|
||||||
|
const pct = Math.round(hit.score * 100);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/cases/${encodeURIComponent(hit.case_number)}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
role="option"
|
||||||
|
className="block px-3 py-2 hover:bg-accent/40 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-3">
|
||||||
|
<span className="font-medium text-sm truncate">{hit.document}</span>
|
||||||
|
<span className="text-[10px] text-gold-deep tabular-nums">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
“{hit.content.slice(0, 160)}…”
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted-foreground/70">
|
||||||
|
ערר {hit.case_number}
|
||||||
|
{hit.page != null && <span> · עמ׳ {hit.page}</span>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
web-ui/src/lib/api/global-search.ts
Normal file
113
web-ui/src/lib/api/global-search.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* Global header search — fans out to three independent sources in parallel.
|
||||||
|
*
|
||||||
|
* Each source is its own `useQuery`, so the fastest one (cases, plain SQL)
|
||||||
|
* shows up immediately while the slower vector searches stream in. A failure
|
||||||
|
* in one source does not block the others — the result panel renders per-
|
||||||
|
* source skeletons and per-source error states.
|
||||||
|
*
|
||||||
|
* Sources:
|
||||||
|
* - cases → GET /api/search/cases (SQL ILIKE on case_number/address/parties)
|
||||||
|
* - precedent → GET /api/precedent-library/search (semantic, halachot+chunks)
|
||||||
|
* - documents → GET /api/search (semantic, all case docs + past decisions)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { apiRequest } from "./client";
|
||||||
|
import type { SearchHit as PrecedentHit } from "./precedent-library";
|
||||||
|
|
||||||
|
export type CaseHit = {
|
||||||
|
case_number: string;
|
||||||
|
title: string;
|
||||||
|
property_address: string | null;
|
||||||
|
status: string | null;
|
||||||
|
practice_area: string | null;
|
||||||
|
appeal_subtype: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DocumentHit = {
|
||||||
|
score: number;
|
||||||
|
case_number: string;
|
||||||
|
document: string;
|
||||||
|
section: string;
|
||||||
|
page: number | null;
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_QUERY_LEN = 2;
|
||||||
|
const STALE_MS = 10_000;
|
||||||
|
const PER_SOURCE_LIMIT = 5;
|
||||||
|
|
||||||
|
const enabled = (q: string) => q.trim().length >= MIN_QUERY_LEN;
|
||||||
|
|
||||||
|
export function useCasesSearch(query: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["global-search", "cases", query],
|
||||||
|
queryFn: ({ signal }) =>
|
||||||
|
apiRequest<{ items: CaseHit[]; count: number }>(
|
||||||
|
`/api/search/cases?q=${encodeURIComponent(query)}&limit=${PER_SOURCE_LIMIT}`,
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
enabled: enabled(query),
|
||||||
|
staleTime: STALE_MS,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrecedentSearch(query: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["global-search", "precedent", query],
|
||||||
|
queryFn: ({ signal }) => {
|
||||||
|
const p = new URLSearchParams({
|
||||||
|
q: query,
|
||||||
|
limit: String(PER_SOURCE_LIMIT),
|
||||||
|
include_halachot: "true",
|
||||||
|
});
|
||||||
|
return apiRequest<{ items: PrecedentHit[]; count: number }>(
|
||||||
|
`/api/precedent-library/search?${p.toString()}`,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enabled: enabled(query),
|
||||||
|
staleTime: STALE_MS,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The /api/search endpoint returns either an array of DocumentHit, or
|
||||||
|
* `{message: "לא נמצאו תוצאות."}` when empty. Normalize to a plain array.
|
||||||
|
*/
|
||||||
|
export function useDocumentsSearch(query: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["global-search", "documents", query],
|
||||||
|
queryFn: async ({ signal }) => {
|
||||||
|
const raw = await apiRequest<DocumentHit[] | { message: string }>(
|
||||||
|
`/api/search?query=${encodeURIComponent(query)}&limit=${PER_SOURCE_LIMIT}`,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
return Array.isArray(raw) ? raw : [];
|
||||||
|
},
|
||||||
|
enabled: enabled(query),
|
||||||
|
staleTime: STALE_MS,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGlobalSearch(query: string) {
|
||||||
|
const cases = useCasesSearch(query);
|
||||||
|
const precedent = usePrecedentSearch(query);
|
||||||
|
const documents = useDocumentsSearch(query);
|
||||||
|
|
||||||
|
const isQueryReady = enabled(query);
|
||||||
|
const anyLoading =
|
||||||
|
isQueryReady && (cases.isLoading || precedent.isLoading || documents.isLoading);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cases,
|
||||||
|
precedent,
|
||||||
|
documents,
|
||||||
|
isQueryReady,
|
||||||
|
anyLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
49
web/app.py
49
web/app.py
@@ -1387,6 +1387,55 @@ async def api_case_search(case_number: str, query: str, limit: int = 10):
|
|||||||
return {"message": result}
|
return {"message": result}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/search/cases")
|
||||||
|
async def api_search_cases(q: str, limit: int = 10):
|
||||||
|
"""Lightweight SQL search over cases — by case number, address, parties, title.
|
||||||
|
|
||||||
|
Powers the global-search dropdown in the header. Returns small projections,
|
||||||
|
not full case objects.
|
||||||
|
"""
|
||||||
|
q = q.strip()
|
||||||
|
if len(q) < 2:
|
||||||
|
return {"items": [], "count": 0}
|
||||||
|
|
||||||
|
needle = f"%{q}%"
|
||||||
|
prefix = f"{q}%"
|
||||||
|
|
||||||
|
pool = await db.get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT case_number, title, property_address, status,
|
||||||
|
practice_area, appeal_subtype, updated_at
|
||||||
|
FROM cases
|
||||||
|
WHERE case_number ILIKE $1
|
||||||
|
OR property_address ILIKE $1
|
||||||
|
OR title ILIKE $1
|
||||||
|
OR subject ILIKE $1
|
||||||
|
OR appellants::text ILIKE $1
|
||||||
|
OR respondents::text ILIKE $1
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN case_number ILIKE $2 THEN 0 ELSE 1 END,
|
||||||
|
updated_at DESC NULLS LAST
|
||||||
|
LIMIT $3
|
||||||
|
""",
|
||||||
|
needle, prefix, limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"case_number": r["case_number"],
|
||||||
|
"title": r["title"],
|
||||||
|
"property_address": r["property_address"],
|
||||||
|
"status": r["status"],
|
||||||
|
"practice_area": r["practice_area"],
|
||||||
|
"appeal_subtype": r["appeal_subtype"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {"items": items, "count": len(items)}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cases/{case_number}/template")
|
@app.get("/api/cases/{case_number}/template")
|
||||||
async def api_case_template(case_number: str):
|
async def api_case_template(case_number: str):
|
||||||
"""Get outcome-aware decision template for a case."""
|
"""Get outcome-aware decision template for a case."""
|
||||||
|
|||||||
Reference in New Issue
Block a user