Files
pn-new-crm/src/components/search/command-search.tsx
Matt 4233aa3ac3 fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md
Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:50:07 +02:00

1183 lines
38 KiB
TypeScript

'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<string, typeof User> = {
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<BucketType | 'all'>('all');
const [focusIndex, setFocusIndex] = useState<number>(-1);
const router = useRouter();
const queryClient = useQueryClient();
const portSlug = useUIStore((s) => s.currentPortSlug);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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. Backed by state (not
// a ref) so render reads are pure — React Compiler-safe.
const [lastAllTotals, setLastAllTotals] = useState<SearchResults['totals'] | null>(null);
useEffect(() => {
if (activeBucket === 'all' && results?.totals) {
// Snapshot the totals at the moment the user is on "All" so the
// chips stay stable when they switch to a filtered bucket.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLastAllTotals(results.totals);
}
}, [activeBucket, results]);
const chipTotals: SearchResults['totals'] | undefined =
activeBucket === 'all' ? results?.totals : (lastAllTotals ?? 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<HTMLInputElement>) => {
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<FlatRow[]>(() => {
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. The set-state
// is intentional — external state (bucket / query) drives focus.
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setFocusIndex(-1);
}, [activeBucket, query]);
function onInputKeyDown(e: KeyboardEvent<HTMLInputElement>) {
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 (
<div ref={wrapperRef} className="relative w-full">
<div
className={cn(
'flex items-center gap-2 rounded-lg border bg-background px-3 shadow-xs transition-colors w-full',
focused ? 'border-brand/70' : 'border-input',
)}
>
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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-hidden ring-0 focus:outline-hidden focus:ring-0 focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 placeholder:text-muted-foreground"
/>
{isFetching && query.length >= 2 && (
<span
className="h-3 w-3 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent"
aria-hidden
/>
)}
</div>
{showDropdown && (
<div
id={listboxId}
role="listbox"
aria-label="Search results"
className={cn(
'absolute top-[calc(100%+4px)] left-0 z-50 rounded-md border bg-popover shadow-lg overflow-hidden',
// Desktop: anchored to the input width, capped on viewport
'w-full max-w-[min(720px,calc(100vw-2rem))]',
// Mobile (<lg): full-screen sheet so cramped phone widths
// still render comfortable rows
'max-lg:fixed max-lg:inset-x-2 max-lg:top-16 max-lg:bottom-2 max-lg:max-w-none',
)}
>
{/* Filter chip row — always visible while the dropdown is open. */}
<FilterChipRow
totals={chipTotals}
active={activeBucket}
onChange={setActiveBucket}
disabled={query.length < 2}
/>
<div className="max-h-[60vh] max-lg:max-h-[calc(100%-3rem)] overflow-y-auto py-1">
{/* No query yet — recently viewed + recent terms. */}
{query.length < 2 && (
<EmptyStateBeforeSearch
listboxId={listboxId}
recentlyViewed={recentlyViewed}
recentSearches={recentSearches}
flatRows={flatRows}
focusIndex={focusIndex}
onSelect={closeAndNavigate}
onSelectTerm={setQuery}
/>
)}
{/* Active query — results or zero-state. */}
{query.length >= 2 && (
<ResultsRegion
listboxId={listboxId}
query={query}
results={results}
portSlug={portSlug ?? null}
activeBucket={activeBucket}
flatRows={flatRows}
focusIndex={focusIndex}
onSelect={closeAndNavigate}
/>
)}
</div>
{/* Footer — keyboard hint only. */}
{query.length >= 2 && (
<div className="border-t bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
navigate · open · esc close
</div>
)}
</div>
)}
</div>
);
}
// ─── 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 (
<div
role="tablist"
aria-label="Filter results by type"
className="flex gap-1 overflow-x-auto border-b bg-muted/20 px-2 py-1.5"
>
<ChipButton
active={active === 'all'}
disabled={disabled}
onClick={() => onChange('all')}
count={undefined}
>
All
</ChipButton>
{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 (
<ChipButton
key={b.type}
active={active === b.type}
disabled={disabled}
onClick={() => onChange(b.type)}
count={count > 0 ? count : undefined}
>
{b.label}
</ChipButton>
);
})}
</div>
);
}
function ChipButton({
active,
disabled,
count,
onClick,
children,
}: {
active: boolean;
disabled: boolean;
count?: number;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
disabled={disabled}
onClick={onClick}
className={cn(
'shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors',
active
? 'bg-brand text-white'
: 'bg-background text-muted-foreground hover:text-foreground hover:bg-accent',
disabled && 'opacity-50 cursor-not-allowed',
)}
>
{children}
{typeof count === 'number' && <span className="ml-1 opacity-70">({count})</span>}
</button>
);
}
// ─── 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 (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
Type at least 2 characters to search clients, yachts, berths, invoices, and more.
</div>
);
}
return (
<>
{recentlyViewed.length > 0 && (
<div>
<SectionHeading icon={History}>Recently viewed</SectionHeading>
{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 (
<button
key={`${item.type}:${item.id}`}
id={row ? `${listboxId}-${row.key}` : undefined}
role="option"
aria-selected={isFocused}
onClick={() => onSelect(item.href)}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm text-left hover:bg-accent cursor-pointer',
isFocused && 'bg-accent',
)}
>
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate font-medium">{item.label}</span>
{item.sub && (
<span className="ml-auto truncate text-xs text-muted-foreground">{item.sub}</span>
)}
</button>
);
})}
</div>
)}
{recentSearches.length > 0 && (
<div>
<SectionHeading icon={Search}>Recent searches</SectionHeading>
{recentSearches.map((term) => {
const row = flatRows.find((r) => r.kind === 'recent-term' && r.term === term);
const isFocused = !!row && focusIndex >= 0 && flatRows[focusIndex] === row;
return (
<button
key={term}
id={row ? `${listboxId}-${row.key}` : undefined}
role="option"
aria-selected={isFocused}
onClick={() => onSelectTerm(term)}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-sm text-left hover:bg-accent cursor-pointer',
isFocused && 'bg-accent',
)}
>
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
{term}
</button>
);
})}
</div>
)}
</>
);
}
// ─── 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 <div className="px-3 py-6 text-center text-sm text-muted-foreground">Searching</div>;
}
const totalHits = Object.values(results.totals).reduce((acc, n) => acc + n, 0);
if (totalHits === 0) {
return <ZeroState query={query} portSlug={portSlug} />;
}
// 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 (
<BucketSection key={b.type} icon={b.icon} label={b.label}>
{rowsForBucket.map((row) => {
if (row.kind !== 'result') return null;
const isFocused = focusIndex >= 0 && flatRows[focusIndex] === row;
return (
<ResultRow
key={row.key}
id={`${listboxId}-${row.key}`}
row={row}
query={query}
isFocused={isFocused}
onSelect={onSelect}
/>
);
})}
</BucketSection>
);
})}
{results.otherPorts && results.otherPorts.length > 0 && activeBucket === 'all' && (
<BucketSection icon={Building2} label="Other ports (super-admin)">
{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 (
<button
key={`${row.portId}:${row.type}:${row.id}`}
id={flatRow ? `${listboxId}-${flatRow.key}` : undefined}
role="option"
aria-selected={isFocused}
onClick={() => onSelect(`/${row.portSlug}/${pluralize(row.type)}/${row.id}`)}
className={cn(
'flex w-full items-center gap-2.5 px-3 py-2 text-sm text-left hover:bg-accent cursor-pointer text-muted-foreground',
isFocused && 'bg-accent text-foreground',
)}
>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
{row.portName}
</span>
<span className="truncate text-foreground">
<HighlightMatch text={row.label} query={query} />
</span>
{row.sub && <span className="ml-auto truncate text-xs">{row.sub}</span>}
</button>
);
})}
</BucketSection>
)}
</>
);
}
function ZeroState({ query, portSlug }: { query: string; portSlug: string | null }) {
if (!portSlug) {
return (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No results for &ldquo;{query}&rdquo;
</div>
);
}
return (
<div className="px-3 py-5">
<p className="text-sm text-muted-foreground mb-3">
No results for <span className="font-medium text-foreground">&ldquo;{query}&rdquo;</span>
</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground/70 mb-2">Quick create</p>
<div className="flex flex-wrap gap-2">
<QuickCreateButton
icon={User}
label={`New client "${query}"`}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/clients/new?fullName=${encodeURIComponent(query)}` as any}
/>
<QuickCreateButton
icon={Ship}
label={`New yacht "${query}"`}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/yachts/new?name=${encodeURIComponent(query)}` as any}
/>
<QuickCreateButton
icon={Building2}
label={`New company "${query}"`}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={`/${portSlug}/companies/new?name=${encodeURIComponent(query)}` as any}
/>
</div>
</div>
);
}
function QuickCreateButton({
icon: Icon,
label,
href,
}: {
icon: typeof User;
label: string;
href: string;
}) {
return (
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={href as any}
className="flex items-center gap-1.5 rounded-md border bg-background px-2.5 py-1 text-xs font-medium text-foreground hover:bg-accent transition-colors"
>
<Plus className="h-3 w-3" />
<Icon className="h-3 w-3" />
<span>{label}</span>
</Link>
);
}
// ─── Result row ──────────────────────────────────────────────────────────────
function ResultRow({
id,
row,
query,
isFocused,
onSelect,
}: {
id: string;
row: Extract<FlatRow, { kind: 'result' }>;
query: string;
isFocused: boolean;
onSelect: (href: string) => void;
}) {
const Icon = row.icon;
return (
<button
type="button"
id={id}
role="option"
aria-selected={isFocused}
onClick={() => onSelect(row.href)}
className={cn(
'flex w-full items-start gap-2.5 px-3 py-2 text-sm text-left hover:bg-accent cursor-pointer',
isFocused && 'bg-accent',
)}
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="truncate font-medium text-foreground">
<HighlightMatch text={row.label} query={query} />
</span>
{row.badges?.map((badge) => (
<Badge key={badge.label} tone={badge.tone} label={badge.label} />
))}
</div>
{row.sub && (
<div className="text-xs text-muted-foreground truncate mt-0.5">
<HighlightMatch text={row.sub} query={query} />
</div>
)}
{row.relatedVia && (
<div className="text-[11px] italic text-muted-foreground/80 truncate mt-0.5">
via {row.relatedVia.label}
</div>
)}
</div>
</button>
);
}
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 (
<span
className={cn(
'inline-flex items-center rounded px-1.5 py-0 text-[10px] font-semibold uppercase tracking-wide',
cls,
)}
>
{label}
</span>
);
}
function SectionHeading({ icon: Icon, children }: { icon: typeof User; children: ReactNode }) {
return (
<div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
<Icon className="h-3 w-3" />
<span>{children}</span>
</div>
);
}
function BucketSection({
icon,
label,
children,
}: {
icon: typeof User;
label: string;
children: ReactNode;
}) {
return (
<div>
<SectionHeading icon={icon}>{label}</SectionHeading>
{children}
</div>
);
}
// ─── 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;
}