'use client'; import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState, } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; import { Anchor, Bell, Briefcase, Building2, Camera, Clock, FileText, Folder, History, Home, LayoutDashboard, MessageSquare, Plus, Receipt, Search, Settings as SettingsIcon, Ship, Tag as TagIcon, TrendingUp, User, } from 'lucide-react'; import { apiFetch } from '@/lib/api/client'; import { cn } from '@/lib/utils'; import { formatCurrency } from '@/lib/utils/currency'; import { STAGE_LABELS, formatOutcome, type PipelineStage } from '@/lib/constants'; import { useSearch, type BucketType, type RecentlyViewedItem, type SearchResults, } from '@/hooks/use-search'; import { useUIStore } from '@/stores/ui-store'; import { HighlightMatch } from '@/components/search/highlight-match'; // ─── Bucket configuration ──────────────────────────────────────────────────── interface BucketConfig { type: BucketType; label: string; icon: typeof User; } const BUCKETS: BucketConfig[] = [ { type: 'clients', label: 'Clients', icon: User }, { type: 'residentialClients', label: 'Residential', icon: Home }, { type: 'yachts', label: 'Yachts', icon: Ship }, { type: 'companies', label: 'Companies', icon: Building2 }, { type: 'interests', label: 'Interests', icon: TrendingUp }, { type: 'residentialInterests', label: 'Res. interests', icon: TrendingUp }, { type: 'berths', label: 'Berths', icon: Anchor }, { type: 'invoices', label: 'Invoices', icon: FileText }, { type: 'expenses', label: 'Expenses', icon: Receipt }, { type: 'documents', label: 'Documents', icon: Briefcase }, { type: 'files', label: 'Files', icon: Folder }, { type: 'reminders', label: 'Reminders', icon: Bell }, { type: 'brochures', label: 'Brochures', icon: Camera }, { type: 'tags', label: 'Tags', icon: TagIcon }, // Notes are noisy content search. { type: 'notes', label: 'Notes', icon: MessageSquare }, // Navigation (settings pages + admin sub-cards) lives at the very bottom — // users open the search to find entity records first; pages/settings are // the long-tail jump targets. { type: 'navigation', label: 'Settings', icon: SettingsIcon }, ]; const NAV_ICON: Record = { dashboard: LayoutDashboard, settings: SettingsIcon, admin: SettingsIcon, }; // ─── Paste detection ───────────────────────────────────────────────────────── const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const INVOICE_RE = /^INV-\d{6}-\d+$/i; function looksLikePastedId(input: string): boolean { const trimmed = input.trim(); return UUID_RE.test(trimmed) || INVOICE_RE.test(trimmed); } // ─── Component ─────────────────────────────────────────────────────────────── export function CommandSearch() { const [query, setQuery] = useState(''); const [focused, setFocused] = useState(false); const [activeBucket, setActiveBucket] = useState('all'); const [focusIndex, setFocusIndex] = useState(-1); const router = useRouter(); const queryClient = useQueryClient(); const portSlug = useUIStore((s) => s.currentPortSlug); const wrapperRef = useRef(null); const inputRef = useRef(null); const listboxId = useId(); const { results, isFetching, recentSearches, recentlyViewed } = useSearch(query, { type: activeBucket === 'all' ? undefined : activeBucket, // Slightly higher cap when narrowed to one bucket — gives the user // room to scan more matches without paging out to /search. limit: activeBucket === 'all' ? 5 : 15, }); // Persist the totals from the last "all" query so the filter chips stay // populated when the user narrows to a single bucket. Without this, the // narrowed query only returns counts for the active bucket and every // other chip would vanish — making it impossible to swap between // filters without clearing back to "All" first. const lastAllTotalsRef = useRef(null); useEffect(() => { if (activeBucket === 'all' && results?.totals) { lastAllTotalsRef.current = results.totals; } }, [activeBucket, results]); const chipTotals: SearchResults['totals'] | undefined = activeBucket === 'all' ? results?.totals : (lastAllTotalsRef.current ?? results?.totals); const showDropdown = focused; // CommandSearch lives in the header and persists across navigations, // so its React Query cache never sees a remount. Invalidate the // recently-viewed + recent-terms queries whenever the dropdown opens // so the user sees fresh data after navigating around the app. useEffect(() => { if (!showDropdown) return; queryClient.invalidateQueries({ queryKey: ['search', 'recently-viewed'] }); queryClient.invalidateQueries({ queryKey: ['search', 'recent-terms'] }); }, [showDropdown, queryClient]); // Cmd/Ctrl+K focuses the input from anywhere on the page. useEffect(() => { function onKeyDown(e: globalThis.KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { e.preventDefault(); inputRef.current?.focus(); inputRef.current?.select(); } } document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, []); // Click outside closes the dropdown. useEffect(() => { if (!focused) return; function onClick(e: MouseEvent) { if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { setFocused(false); setFocusIndex(-1); } } document.addEventListener('mousedown', onClick); return () => document.removeEventListener('mousedown', onClick); }, [focused]); const closeAndNavigate = useCallback( (path: string) => { setFocused(false); setQuery(''); setFocusIndex(-1); inputRef.current?.blur(); // eslint-disable-next-line @typescript-eslint/no-explicit-any router.push(path as any); }, [router], ); // ── Paste detection: if the user pastes a UUID/INV-… into the input, // fire the resolve-id endpoint and jump straight to the entity if // it exists. Falls through to normal search otherwise. const onPaste = useCallback( async (e: React.ClipboardEvent) => { const pasted = e.clipboardData.getData('text').trim(); if (!looksLikePastedId(pasted)) return; try { const res = await apiFetch<{ found: boolean; href: string | null }>( `/api/v1/search/resolve-id?id=${encodeURIComponent(pasted)}`, ); if (res.found && res.href) { e.preventDefault(); closeAndNavigate(res.href); } } catch { // Best-effort — fall through to normal search. } }, [closeAndNavigate], ); // Build the flat list of focusable rows in render order, so arrow-key // navigation walks them in the same order they appear visually. const flatRows = useMemo(() => { if (!showDropdown) return []; return buildFlatRows({ query, results, recentlyViewed, recentSearches, activeBucket, portSlug: portSlug ?? null, }); }, [showDropdown, query, results, recentlyViewed, recentSearches, activeBucket, portSlug]); // Reset focus index when the visible row set changes. useEffect(() => { setFocusIndex(-1); }, [activeBucket, query]); function onInputKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') { e.preventDefault(); setFocused(false); setFocusIndex(-1); inputRef.current?.blur(); return; } if (e.key === 'ArrowDown') { e.preventDefault(); setFocusIndex((i) => Math.min(i + 1, flatRows.length - 1)); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setFocusIndex((i) => Math.max(i - 1, -1)); return; } if (e.key === 'Enter') { const row = focusIndex >= 0 ? flatRows[focusIndex] : null; if (row) { e.preventDefault(); if (row.kind === 'recent-term') { setQuery(row.term); return; } closeAndNavigate(row.href); } // Enter without a focused row is a no-op — the dropdown is already // showing every relevant match (filter chips raise the cap when the // user wants to see more of one bucket). No standalone /search // page; refining the query is faster than scrolling further. } } const activeOptionId = focusIndex >= 0 && flatRows[focusIndex] ? `${listboxId}-${flatRows[focusIndex].key}` : undefined; return (
setQuery(e.target.value)} onFocus={() => setFocused(true)} onPaste={onPaste} onKeyDown={onInputKeyDown} placeholder="Search clients, yachts, berths, invoices… (⌘K)" aria-label="Search" role="combobox" aria-expanded={showDropdown} aria-controls={listboxId} aria-autocomplete="list" aria-activedescendant={activeOptionId} // Wrapper border swap is the focus indicator; suppress the // global *:focus-visible ring that would otherwise paint a // rectangular box clashing with the rounded wrapper. className="h-9 flex-1 min-w-0 bg-transparent text-sm outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground" /> {isFetching && query.length >= 2 && ( )}
{showDropdown && (
{/* Filter chip row — always visible while the dropdown is open. */}
{/* No query yet — recently viewed + recent terms. */} {query.length < 2 && ( )} {/* Active query — results or zero-state. */} {query.length >= 2 && ( )}
{/* Footer — keyboard hint only. */} {query.length >= 2 && (
↑↓ navigate · ↵ open · esc close
)}
)}
); } // ─── Filter chips ──────────────────────────────────────────────────────────── function FilterChipRow({ totals, active, onChange, disabled, }: { /** Counts from the last "all" query, persisted so chips stay visible * when the user narrows to a single bucket. Falls back to the current * results.totals when no "all" snapshot exists yet. */ totals: SearchResults['totals'] | undefined; active: BucketType | 'all'; onChange: (b: BucketType | 'all') => void; disabled: boolean; }) { return (
onChange('all')} count={undefined} > All {BUCKETS.map((b) => { const count = totals?.[b.type] ?? 0; // Hide chips for buckets with zero matches in the last "all" // snapshot — keeps the row tight and avoids dead-end clicks. // Always show the active chip + every chip before a query has run. if (!disabled && count === 0 && active !== b.type) return null; return ( onChange(b.type)} count={count > 0 ? count : undefined} > {b.label} ); })}
); } function ChipButton({ active, disabled, count, onClick, children, }: { active: boolean; disabled: boolean; count?: number; onClick: () => void; children: ReactNode; }) { return ( ); } // ─── Empty state (no query) ────────────────────────────────────────────────── function EmptyStateBeforeSearch({ listboxId, recentlyViewed, recentSearches, flatRows, focusIndex, onSelect, onSelectTerm, }: { listboxId: string; recentlyViewed: RecentlyViewedItem[]; recentSearches: string[]; flatRows: FlatRow[]; focusIndex: number; onSelect: (href: string) => void; onSelectTerm: (term: string) => void; }) { if (recentlyViewed.length === 0 && recentSearches.length === 0) { return (
Type at least 2 characters to search clients, yachts, berths, invoices, and more.
); } return ( <> {recentlyViewed.length > 0 && (
Recently viewed {recentlyViewed.map((item) => { const row = flatRows.find( (r) => r.kind === 'recent-view' && r.item.id === item.id && r.item.type === item.type, ); const isFocused = !!row && focusIndex >= 0 && flatRows[focusIndex] === row; return ( ); })}
)} {recentSearches.length > 0 && (
Recent searches {recentSearches.map((term) => { const row = flatRows.find((r) => r.kind === 'recent-term' && r.term === term); const isFocused = !!row && focusIndex >= 0 && flatRows[focusIndex] === row; return ( ); })}
)} ); } // ─── Results region (active query) ─────────────────────────────────────────── function ResultsRegion({ listboxId, query, results, portSlug, activeBucket, flatRows, focusIndex, onSelect, }: { listboxId: string; query: string; results: SearchResults | undefined; portSlug: string | null; activeBucket: BucketType | 'all'; flatRows: FlatRow[]; focusIndex: number; onSelect: (href: string) => void; }) { if (!results) { return
Searching…
; } const totalHits = Object.values(results.totals).reduce((acc, n) => acc + n, 0); if (totalHits === 0) { return ; } // Render every bucket the active filter allows. return ( <> {BUCKETS.map((b) => { if (activeBucket !== 'all' && activeBucket !== b.type) return null; const rowsForBucket = flatRows.filter((r) => r.kind === 'result' && r.bucket === b.type); if (rowsForBucket.length === 0) return null; return ( {rowsForBucket.map((row) => { if (row.kind !== 'result') return null; const isFocused = focusIndex >= 0 && flatRows[focusIndex] === row; return ( ); })} ); })} {results.otherPorts && results.otherPorts.length > 0 && activeBucket === 'all' && ( {results.otherPorts.map((row) => { const flatRow = flatRows.find((r) => r.kind === 'other-port' && r.item === row); const isFocused = !!flatRow && focusIndex >= 0 && flatRows[focusIndex] === flatRow; return ( ); })} )} ); } function ZeroState({ query, portSlug }: { query: string; portSlug: string | null }) { if (!portSlug) { return (
No results for “{query}”
); } return (

No results for “{query}”

Quick create

); } function QuickCreateButton({ icon: Icon, label, href, }: { icon: typeof User; label: string; href: string; }) { return ( {label} ); } // ─── Result row ────────────────────────────────────────────────────────────── function ResultRow({ id, row, query, isFocused, onSelect, }: { id: string; row: Extract; query: string; isFocused: boolean; onSelect: (href: string) => void; }) { const Icon = row.icon; return ( ); } function Badge({ label, tone, }: { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger'; }) { const cls = { neutral: 'bg-muted text-muted-foreground', warning: 'bg-amber-100 text-amber-800', success: 'bg-emerald-100 text-emerald-800', danger: 'bg-rose-100 text-rose-800', }[tone]; return ( {label} ); } function SectionHeading({ icon: Icon, children }: { icon: typeof User; children: ReactNode }) { return (
{children}
); } function BucketSection({ icon, label, children, }: { icon: typeof User; label: string; children: ReactNode; }) { return (
{label} {children}
); } // ─── Flat-row construction (drives keyboard nav + ARIA) ────────────────────── export type ResultBadge = { label: string; tone: 'neutral' | 'warning' | 'success' | 'danger' }; export type FlatRow = | { kind: 'recent-view'; key: string; item: RecentlyViewedItem; href: string; } | { kind: 'recent-term'; key: string; term: string; href: string; } | { kind: 'result'; key: string; bucket: BucketType; icon: typeof User; label: string; sub: string | null; href: string; badges?: ResultBadge[]; /** Provenance hint when the row was surfaced via graph expansion. * Rendered as a subtle "via Berth A10" line below the sub. */ relatedVia?: { type: string; label: string } | null; } | { kind: 'other-port'; key: string; item: SearchResults['otherPorts'] extends (infer U)[] | undefined ? U : never; href: string; }; export interface BuildFlatRowsArgs { query: string; results: SearchResults | undefined; recentlyViewed: RecentlyViewedItem[]; recentSearches: string[]; activeBucket: BucketType | 'all'; portSlug: string | null; } export function buildFlatRows(args: BuildFlatRowsArgs): FlatRow[] { const { query, results, recentlyViewed, recentSearches, activeBucket, portSlug } = args; const rows: FlatRow[] = []; if (query.length < 2) { for (const item of recentlyViewed) { rows.push({ kind: 'recent-view', key: `recent-view:${item.type}:${item.id}`, item, href: item.href, }); } for (const term of recentSearches) { rows.push({ kind: 'recent-term', key: `recent-term:${term}`, term, href: '', }); } return rows; } if (!results || !portSlug) return rows; const include = (b: BucketType) => activeBucket === 'all' || activeBucket === b; if (include('clients')) { for (const c of results.clients) { rows.push({ kind: 'result', key: `clients:${c.id}`, bucket: 'clients', icon: User, label: c.fullName, sub: c.matchedContact ?? null, href: `/${portSlug}/clients/${c.id}`, badges: c.archivedAt ? [{ label: 'Archived', tone: 'neutral' }] : undefined, relatedVia: c.relatedVia ?? null, }); } } if (include('residentialClients')) { for (const c of results.residentialClients) { rows.push({ kind: 'result', key: `residentialClients:${c.id}`, bucket: 'residentialClients', icon: Home, label: c.fullName, sub: c.email ?? c.phone ?? null, href: `/${portSlug}/residential/clients/${c.id}`, }); } } if (include('yachts')) { for (const y of results.yachts) { const sub = [y.hullNumber, y.registration].filter(Boolean).join(' · ') || null; rows.push({ kind: 'result', key: `yachts:${y.id}`, bucket: 'yachts', icon: Ship, label: y.name, sub, href: `/${portSlug}/yachts/${y.id}`, relatedVia: y.relatedVia ?? null, }); } } if (include('companies')) { for (const co of results.companies) { const sub = [co.legalName, co.taxId].filter(Boolean).join(' · ') || null; rows.push({ kind: 'result', key: `companies:${co.id}`, bucket: 'companies', icon: Building2, label: co.name, sub, href: `/${portSlug}/companies/${co.id}`, relatedVia: co.relatedVia ?? null, }); } } if (include('interests')) { for (const i of results.interests) { const badges: ResultBadge[] = []; if (i.outcome) { badges.push({ label: formatOutcome(i.outcome) ?? i.outcome, tone: i.outcome === 'won' ? 'success' : 'neutral', }); } else { badges.push({ label: STAGE_LABELS[i.pipelineStage as PipelineStage] ?? i.pipelineStage.replace(/_/g, ' '), tone: 'warning', }); } rows.push({ kind: 'result', key: `interests:${i.id}`, bucket: 'interests', icon: TrendingUp, label: i.clientName, sub: i.berthMooringNumber, href: `/${portSlug}/interests/${i.id}`, badges: badges.length > 0 ? badges : undefined, relatedVia: i.relatedVia ?? null, }); } } if (include('residentialInterests')) { for (const i of results.residentialInterests) { rows.push({ kind: 'result', key: `residentialInterests:${i.id}`, bucket: 'residentialInterests', icon: TrendingUp, label: i.clientName, sub: STAGE_LABELS[i.pipelineStage as PipelineStage] ?? i.pipelineStage.replace(/_/g, ' '), href: `/${portSlug}/residential/interests/${i.id}`, }); } } if (include('berths')) { for (const b of results.berths) { const badges: ResultBadge[] = []; if (b.status === 'sold') badges.push({ label: 'Sold', tone: 'success' }); else if (b.status === 'under_offer') badges.push({ label: 'Under offer', tone: 'warning' }); const sub = [ b.area, b.linkedInterestCount > 0 ? `${b.linkedInterestCount} interest${b.linkedInterestCount === 1 ? '' : 's'}` : null, ] .filter(Boolean) .join(' · ') || null; rows.push({ kind: 'result', key: `berths:${b.id}`, bucket: 'berths', icon: Anchor, label: b.mooringNumber, sub, href: `/${portSlug}/berths/${b.id}`, badges: badges.length > 0 ? badges : undefined, relatedVia: b.relatedVia ?? null, }); } } if (include('invoices')) { for (const inv of results.invoices) { const badges: ResultBadge[] = []; if (inv.status === 'overdue') badges.push({ label: 'Overdue', tone: 'danger' }); else if (inv.paymentStatus === 'paid') badges.push({ label: 'Paid', tone: 'success' }); else if (inv.status === 'sent') badges.push({ label: 'Sent', tone: 'neutral' }); const sub = inv.totalAmount ? `${inv.clientName} · ${formatCurrency(inv.totalAmount, inv.currency)}` : inv.clientName; rows.push({ kind: 'result', key: `invoices:${inv.id}`, bucket: 'invoices', icon: FileText, label: inv.invoiceNumber, sub, href: `/${portSlug}/invoices/${inv.id}`, badges: badges.length > 0 ? badges : undefined, }); } } if (include('expenses')) { for (const e of results.expenses) { const badges: ResultBadge[] = []; if (e.paymentStatus === 'paid') badges.push({ label: 'Paid', tone: 'success' }); const sub = [e.vendor, e.tripLabel].filter(Boolean).join(' · ') || null; rows.push({ kind: 'result', key: `expenses:${e.id}`, bucket: 'expenses', icon: Receipt, label: e.description ?? e.vendor ?? formatCurrency(e.amount, e.currency), sub, href: `/${portSlug}/expenses/${e.id}`, badges: badges.length > 0 ? badges : undefined, }); } } if (include('documents')) { for (const d of results.documents) { const badges: ResultBadge[] = []; if (d.status === 'completed') badges.push({ label: 'Signed', tone: 'success' }); else if (d.status === 'expired') badges.push({ label: 'Expired', tone: 'danger' }); else if (d.status === 'sent') badges.push({ label: 'Awaiting signature', tone: 'warning' }); rows.push({ kind: 'result', key: `documents:${d.id}`, bucket: 'documents', icon: Briefcase, label: d.title, sub: d.matchedSignerName, href: `/${portSlug}/documents/${d.id}`, badges: badges.length > 0 ? badges : undefined, }); } } if (include('files')) { for (const f of results.files) { rows.push({ kind: 'result', key: `files:${f.id}`, bucket: 'files', icon: Folder, label: f.filename, sub: f.ownerLabel, href: `/${portSlug}/documents`, }); } } if (include('reminders')) { for (const r of results.reminders) { const badges: ResultBadge[] = []; const due = new Date(r.dueAt); if (due.getTime() < Date.now()) badges.push({ label: 'Overdue', tone: 'danger' }); rows.push({ kind: 'result', key: `reminders:${r.id}`, bucket: 'reminders', icon: Bell, label: r.title, sub: due.toLocaleDateString(), href: `/${portSlug}/reminders`, badges: badges.length > 0 ? badges : undefined, }); } } if (include('brochures')) { for (const b of results.brochures) { rows.push({ kind: 'result', key: `brochures:${b.id}`, bucket: 'brochures', icon: Camera, label: b.label, sub: b.isDefault ? 'Default brochure' : null, href: `/${portSlug}/settings`, }); } } if (include('tags')) { for (const t of results.tags) { rows.push({ kind: 'result', key: `tags:${t.id}`, bucket: 'tags', icon: TagIcon, label: `Tag: ${t.name}`, sub: `${t.totalCount} tagged`, // Tag-filtered list view; until that list page exists, fall back // to the tags settings page. href: `/${portSlug}/clients?tag=${encodeURIComponent(t.name)}`, }); } } // Notes — content matches inside free-text notes are noisy by nature, so // the user sees them after the entity-specific buckets above have // surfaced their tighter matches. if (include('notes')) { for (const n of results.notes) { const sourceCollection = n.source === 'client' ? 'clients' : n.source === 'interest' ? 'interests' : n.source === 'yacht' ? 'yachts' : 'companies'; rows.push({ kind: 'result', key: `notes:${n.id}`, bucket: 'notes', icon: MessageSquare, label: `${n.source.charAt(0).toUpperCase() + n.source.slice(1)} note · ${n.sourceLabel}`, sub: n.snippet, href: `/${portSlug}/${sourceCollection}/${n.sourceId}?tab=notes`, }); } } // Navigation (settings pages + admin section cards) goes LAST — these // are jump targets, not the primary thing a user opens the search to // find. Surfacing them after entity matches keeps the top of the // dropdown focused on records. if (include('navigation')) { for (const n of results.navigation) { const Icon = NAV_ICON[n.category] ?? SettingsIcon; rows.push({ kind: 'result', key: `navigation:${n.id}`, bucket: 'navigation', icon: Icon, label: n.label, sub: n.category, // Catalog hrefs already have :portSlug substituted server-side. href: n.href, }); } } if (results.otherPorts && activeBucket === 'all') { for (const op of results.otherPorts) { rows.push({ kind: 'other-port', key: `other:${op.portId}:${op.type}:${op.id}`, item: op, href: `/${op.portSlug}/${pluralize(op.type)}/${op.id}`, }); } } return rows; } function pluralize(type: string): string { if (type === 'company') return 'companies'; return `${type}s`; } // Keep a no-op export for any legacy import sites. export function SearchTrigger() { return null; }