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">
|
||||
|
||||
@@ -16,6 +16,18 @@ interface SearchResults {
|
||||
pipelineStage: string;
|
||||
}>;
|
||||
berths: Array<{ id: string; mooringNumber: string; area: string | null; status: string }>;
|
||||
yachts: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
hullNumber: string | null;
|
||||
registration: string | null;
|
||||
}>;
|
||||
companies: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
legalName: string | null;
|
||||
taxId: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -25,16 +25,32 @@ interface BerthResult {
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface YachtResult {
|
||||
id: string;
|
||||
name: string;
|
||||
hullNumber: string | null;
|
||||
registration: string | null;
|
||||
}
|
||||
|
||||
interface CompanyResult {
|
||||
id: string;
|
||||
name: string;
|
||||
legalName: string | null;
|
||||
taxId: string | null;
|
||||
}
|
||||
|
||||
interface SearchResults {
|
||||
clients: ClientResult[];
|
||||
interests: InterestResult[];
|
||||
berths: BerthResult[];
|
||||
yachts: YachtResult[];
|
||||
companies: CompanyResult[];
|
||||
}
|
||||
|
||||
// ─── Search ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function search(portId: string, query: string): Promise<SearchResults> {
|
||||
const [clientRows, berthRows, interestRows] = await Promise.all([
|
||||
const [clientRows, berthRows, interestRows, yachtRows, companyRows] = await Promise.all([
|
||||
// Clients: full-text search via tsvector
|
||||
db.execute<{ id: string; full_name: string; company_name: string | null }>(sql`
|
||||
SELECT id, full_name, company_name
|
||||
@@ -83,6 +99,58 @@ export async function search(portId: string, query: string): Promise<SearchResul
|
||||
)
|
||||
LIMIT 10
|
||||
`),
|
||||
|
||||
// Yachts: ILIKE on name, hull_number, registration
|
||||
db.execute<{
|
||||
id: string;
|
||||
name: string;
|
||||
hull_number: string | null;
|
||||
registration: string | null;
|
||||
}>(sql`
|
||||
SELECT id, name, hull_number, registration
|
||||
FROM yachts
|
||||
WHERE port_id = ${portId}
|
||||
AND archived_at IS NULL
|
||||
AND (
|
||||
name ILIKE ${'%' + query + '%'}
|
||||
OR hull_number ILIKE ${'%' + query + '%'}
|
||||
OR registration ILIKE ${'%' + query + '%'}
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN name ILIKE ${query + '%'} THEN 1
|
||||
WHEN name ILIKE ${'%' + query + '%'} THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
name
|
||||
LIMIT 10
|
||||
`),
|
||||
|
||||
// Companies: ILIKE on name, legal_name, tax_id
|
||||
db.execute<{
|
||||
id: string;
|
||||
name: string;
|
||||
legal_name: string | null;
|
||||
tax_id: string | null;
|
||||
}>(sql`
|
||||
SELECT id, name, legal_name, tax_id
|
||||
FROM companies
|
||||
WHERE port_id = ${portId}
|
||||
AND archived_at IS NULL
|
||||
AND (
|
||||
name ILIKE ${'%' + query + '%'}
|
||||
OR legal_name ILIKE ${'%' + query + '%'}
|
||||
OR tax_id ILIKE ${'%' + query + '%'}
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN name ILIKE ${query + '%'} THEN 1
|
||||
WHEN name ILIKE ${'%' + query + '%'} THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
name
|
||||
LIMIT 10
|
||||
`),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -103,6 +171,18 @@ export async function search(portId: string, query: string): Promise<SearchResul
|
||||
berthMooringNumber: r.mooring_number ?? null,
|
||||
pipelineStage: r.pipeline_stage,
|
||||
})),
|
||||
yachts: Array.from(yachtRows).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
hullNumber: r.hull_number ?? null,
|
||||
registration: r.registration ?? null,
|
||||
})),
|
||||
companies: Array.from(companyRows).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
legalName: r.legal_name ?? null,
|
||||
taxId: r.tax_id ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user