Files
pn-new-crm/src/components/search/command-search.tsx
Matt Ciaccio 71d7daf1ae feat(search): index yachts and companies alongside clients
Extend the global search service to include yacht and company results
using ILIKE matching on name, hull number, registration, legal name,
and tax ID. Results are tenant-scoped and exclude archived rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:47:54 +02:00

265 lines
8.9 KiB
TypeScript

'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Search, Clock, User, TrendingUp, Anchor, Ship, Building2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSearch } from '@/hooks/use-search';
import { useUIStore } from '@/stores/ui-store';
export function CommandSearch() {
const [focused, setFocused] = useState(false);
const [query, setQuery] = useState('');
const router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { results, isLoading, recentSearches } = useSearch(query);
const showDropdown = focused && (query.length > 0 || recentSearches.length > 0);
const hasQuery = query.length >= 2;
const hasResults =
results &&
(results.clients.length > 0 ||
results.interests.length > 0 ||
results.berths.length > 0 ||
results.yachts.length > 0 ||
results.companies.length > 0);
// Cmd/Ctrl+K focuses the input
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
// Click outside closes dropdown
useEffect(() => {
if (!focused) return;
function onClick(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setFocused(false);
}
}
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [focused]);
const navigate = useCallback(
(path: string) => {
setFocused(false);
setQuery('');
inputRef.current?.blur();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
router.push(path as any);
},
[router],
);
// Keyboard nav inside dropdown
function onInputKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') {
setFocused(false);
inputRef.current?.blur();
}
}
const iconMap = {
client: User,
interest: TrendingUp,
berth: Anchor,
yacht: Ship,
company: Building2,
} as const;
return (
<div ref={wrapperRef} className="relative">
{/* ── Single persistent search bar ── */}
<div
className={cn(
'flex items-center gap-2 rounded-md border bg-background px-2.5 transition-all duration-150',
focused ? 'border-muted-foreground/40 w-64 lg:w-80' : 'w-44 lg:w-60',
)}
>
<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)}
onKeyDown={onInputKeyDown}
placeholder="Search..."
className="h-8 flex-1 min-w-0 bg-transparent text-sm outline-none ring-0 focus:outline-none focus:ring-0 placeholder:text-muted-foreground"
/>
</div>
{/* ── Results dropdown ── */}
{showDropdown && (
<div className="absolute top-[calc(100%+4px)] left-0 w-[min(420px,calc(100vw-2rem))] z-50 rounded-md border bg-popover shadow-lg overflow-hidden">
<div className="max-h-[340px] overflow-y-auto py-1">
{/* No query yet — show recent or hint */}
{!hasQuery && recentSearches.length > 0 && (
<div>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">Recent</div>
{recentSearches.map((term) => (
<button
key={term}
onClick={() => setQuery(term)}
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-accent cursor-pointer"
>
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
{term}
</button>
))}
</div>
)}
{!hasQuery && recentSearches.length === 0 && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
Type at least 2 characters to search
</div>
)}
{/* Loading */}
{hasQuery && isLoading && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
Searching...
</div>
)}
{/* No results */}
{hasQuery && !isLoading && !hasResults && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No results for &ldquo;{query}&rdquo;
</div>
)}
{/* Result groups */}
{hasQuery && !isLoading && results && (
<>
{results.clients.length > 0 && (
<ResultGroup
heading="Clients"
items={results.clients.map((c) => ({
id: c.id,
icon: 'client',
label: c.fullName,
sub: c.companyName,
}))}
iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)}
/>
)}
{results.yachts.length > 0 && (
<ResultGroup
heading="Yachts"
items={results.yachts.map((y) => ({
id: y.id,
icon: 'yacht',
label: y.name,
sub: [y.hullNumber, y.registration].filter(Boolean).join(' · ') || null,
}))}
iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/yachts/${id}`)}
/>
)}
{results.companies.length > 0 && (
<ResultGroup
heading="Companies"
items={results.companies.map((c) => ({
id: c.id,
icon: 'company',
label: c.name,
sub: [c.legalName, c.taxId].filter(Boolean).join(' · ') || null,
}))}
iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/companies/${id}`)}
/>
)}
{results.interests.length > 0 && (
<ResultGroup
heading="Interests"
items={results.interests.map((i) => ({
id: i.id,
icon: 'interest',
label: i.clientName,
sub: i.berthMooringNumber ?? i.pipelineStage,
}))}
iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/interests/${id}`)}
/>
)}
{results.berths.length > 0 && (
<ResultGroup
heading="Berths"
items={results.berths.map((b) => ({
id: b.id,
icon: 'berth',
label: b.mooringNumber,
sub: [b.area, b.status].filter(Boolean).join(' · '),
}))}
iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/berths/${id}`)}
/>
)}
</>
)}
</div>
</div>
)}
</div>
);
}
function ResultGroup({
heading,
items,
iconMap,
onSelect,
}: {
heading: string;
items: Array<{
id: string;
icon: 'client' | 'interest' | 'berth' | 'yacht' | 'company';
label: string;
sub?: string | null;
}>;
iconMap: Record<string, React.ElementType | undefined>;
onSelect: (id: string) => void;
}) {
return (
<div>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{heading}</div>
{items.map((item) => {
const Icon = iconMap[item.icon] ?? 'span';
return (
<button
key={item.id}
onClick={() => onSelect(item.id)}
className="flex w-full items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent cursor-pointer text-left"
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<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>
);
}
// Keep export for backwards compat — it's a no-op
export function SearchTrigger() {
return null;
}