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>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, Clock, User, TrendingUp, Anchor } from 'lucide-react';
|
||||
import { Search, Clock, User, TrendingUp, Anchor, Ship, Building2 } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSearch } from '@/hooks/use-search';
|
||||
@@ -22,7 +22,11 @@ export function CommandSearch() {
|
||||
const hasQuery = query.length >= 2;
|
||||
const hasResults =
|
||||
results &&
|
||||
(results.clients.length > 0 || results.interests.length > 0 || results.berths.length > 0);
|
||||
(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(() => {
|
||||
@@ -67,7 +71,13 @@ export function CommandSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
const iconMap = { client: User, interest: TrendingUp, berth: Anchor } as const;
|
||||
const iconMap = {
|
||||
client: User,
|
||||
interest: TrendingUp,
|
||||
berth: Anchor,
|
||||
yacht: Ship,
|
||||
company: Building2,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
@@ -148,6 +158,32 @@ export function CommandSearch() {
|
||||
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"
|
||||
@@ -190,7 +226,12 @@ function ResultGroup({
|
||||
onSelect,
|
||||
}: {
|
||||
heading: string;
|
||||
items: Array<{ id: string; icon: 'client' | 'interest' | 'berth'; label: string; sub?: string | null }>;
|
||||
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;
|
||||
}) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { User, Anchor, TrendingUp } from 'lucide-react';
|
||||
import { User, Anchor, TrendingUp, Ship, Building2 } from 'lucide-react';
|
||||
|
||||
import { CommandItem } from '@/components/ui/command';
|
||||
|
||||
@@ -26,10 +26,26 @@ interface BerthItem {
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface YachtItem {
|
||||
id: string;
|
||||
name: string;
|
||||
hullNumber: string | null;
|
||||
registration: string | null;
|
||||
}
|
||||
|
||||
interface CompanyItem {
|
||||
id: string;
|
||||
name: string;
|
||||
legalName: string | null;
|
||||
taxId: string | null;
|
||||
}
|
||||
|
||||
type SearchResultItemProps =
|
||||
| { type: 'client'; item: ClientItem; onSelect: () => void }
|
||||
| { type: 'interest'; item: InterestItem; onSelect: () => void }
|
||||
| { type: 'berth'; item: BerthItem; onSelect: () => void };
|
||||
| { type: 'berth'; item: BerthItem; onSelect: () => void }
|
||||
| { type: 'yacht'; item: YachtItem; onSelect: () => void }
|
||||
| { type: 'company'; item: CompanyItem; onSelect: () => void };
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -63,6 +79,38 @@ export function SearchResultItem({ type, item, onSelect }: SearchResultItemProps
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'yacht') {
|
||||
return (
|
||||
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
|
||||
<Ship className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{item.name}</span>
|
||||
{(item.hullNumber || item.registration) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{[item.hullNumber, item.registration].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'company') {
|
||||
return (
|
||||
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
|
||||
<Building2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{item.name}</span>
|
||||
{(item.legalName || item.taxId) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{[item.legalName, item.taxId].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
// berth
|
||||
return (
|
||||
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user