diff --git a/src/components/search/command-search.tsx b/src/components/search/command-search.tsx index 959e50b..a6bfe22 100644 --- a/src/components/search/command-search.tsx +++ b/src/components/search/command-search.tsx @@ -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 (
@@ -148,6 +158,32 @@ export function CommandSearch() { onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)} /> )} + {results.yachts.length > 0 && ( + ({ + 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 && ( + ({ + 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 && ( ; + items: Array<{ + id: string; + icon: 'client' | 'interest' | 'berth' | 'yacht' | 'company'; + label: string; + sub?: string | null; + }>; iconMap: Record; onSelect: (id: string) => void; }) { diff --git a/src/components/search/search-result-item.tsx b/src/components/search/search-result-item.tsx index c622113..0488886 100644 --- a/src/components/search/search-result-item.tsx +++ b/src/components/search/search-result-item.tsx @@ -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 ( + + +
+ {item.name} + {(item.hullNumber || item.registration) && ( + + {[item.hullNumber, item.registration].filter(Boolean).join(' · ')} + + )} +
+
+ ); + } + + if (type === 'company') { + return ( + + +
+ {item.name} + {(item.legalName || item.taxId) && ( + + {[item.legalName, item.taxId].filter(Boolean).join(' · ')} + + )} +
+
+ ); + } + // berth return ( diff --git a/src/hooks/use-search.ts b/src/hooks/use-search.ts index a16d729..9825452 100644 --- a/src/hooks/use-search.ts +++ b/src/hooks/use-search.ts @@ -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 ───────────────────────────────────────────────────────────────────── diff --git a/src/lib/services/search.service.ts b/src/lib/services/search.service.ts index 55d6b84..979b94b 100644 --- a/src/lib/services/search.service.ts +++ b/src/lib/services/search.service.ts @@ -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 { - 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(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 ({ + 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, + })), }; } diff --git a/tests/unit/services/search.test.ts b/tests/unit/services/search.test.ts new file mode 100644 index 0000000..0b47ec8 --- /dev/null +++ b/tests/unit/services/search.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; +import { eq } from 'drizzle-orm'; + +import { search } from '@/lib/services/search.service'; +import { db } from '@/lib/db'; +import { yachts, companies } from '@/lib/db/schema'; + +import { makePort, makeClient, makeYacht, makeCompany } from '../../helpers/factories'; + +// ─── Yachts ────────────────────────────────────────────────────────────────── + +describe('search.service — yachts', () => { + it('matches yachts by name (case-insensitive)', async () => { + const port = await makePort(); + const owner = await makeClient({ portId: port.id }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: owner.id, + name: 'Sea Breeze', + }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: owner.id, + name: 'Wind Dancer', + }); + + const results = await search(port.id, 'BREEZE'); + expect(results.yachts.some((y) => y.name === 'Sea Breeze')).toBe(true); + expect(results.yachts.some((y) => y.name === 'Wind Dancer')).toBe(false); + }); + + it('matches yachts by hull number', async () => { + const port = await makePort(); + const owner = await makeClient({ portId: port.id }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: owner.id, + name: 'Nomad', + hullNumber: 'HULL-XYZ-999', + }); + + const results = await search(port.id, 'hull-xyz'); + expect(results.yachts.some((y) => y.hullNumber === 'HULL-XYZ-999')).toBe(true); + }); + + it('matches yachts by registration', async () => { + const port = await makePort(); + const owner = await makeClient({ portId: port.id }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: owner.id, + name: 'Registered One', + registration: 'REG-ABC-123', + }); + + const results = await search(port.id, 'reg-abc'); + expect(results.yachts.some((y) => y.registration === 'REG-ABC-123')).toBe(true); + }); + + it('excludes archived yachts', async () => { + const port = await makePort(); + const owner = await makeClient({ portId: port.id }); + const yacht = await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: owner.id, + name: 'Ghost Ship', + }); + await db.update(yachts).set({ archivedAt: new Date() }).where(eq(yachts.id, yacht.id)); + + const results = await search(port.id, 'ghost ship'); + expect(results.yachts.some((y) => y.id === yacht.id)).toBe(false); + }); + + it('is tenant-scoped', async () => { + const portA = await makePort(); + const portB = await makePort(); + const ownerA = await makeClient({ portId: portA.id }); + const ownerB = await makeClient({ portId: portB.id }); + await makeYacht({ + portId: portA.id, + ownerType: 'client', + ownerId: ownerA.id, + name: 'UniqueYachtNameA', + }); + await makeYacht({ + portId: portB.id, + ownerType: 'client', + ownerId: ownerB.id, + name: 'UniqueYachtNameB', + }); + + const resultsA = await search(portA.id, 'UniqueYachtName'); + expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameA')).toBe(true); + expect(resultsA.yachts.some((y) => y.name === 'UniqueYachtNameB')).toBe(false); + }); +}); + +// ─── Companies ─────────────────────────────────────────────────────────────── + +describe('search.service — companies', () => { + it('matches companies by name', async () => { + const port = await makePort(); + await makeCompany({ portId: port.id, overrides: { name: 'Poseidon Maritime Ltd' } }); + await makeCompany({ portId: port.id, overrides: { name: 'Neptune Holdings' } }); + + const results = await search(port.id, 'poseidon'); + expect(results.companies.some((c) => c.name === 'Poseidon Maritime Ltd')).toBe(true); + expect(results.companies.some((c) => c.name === 'Neptune Holdings')).toBe(false); + }); + + it('matches companies by legal name', async () => { + const port = await makePort(); + await makeCompany({ + portId: port.id, + overrides: { name: 'AcmeShort', legalName: 'Acme Legal Holdings Inc.' }, + }); + + const results = await search(port.id, 'Acme Legal'); + expect(results.companies.some((c) => c.legalName === 'Acme Legal Holdings Inc.')).toBe(true); + }); + + it('matches companies by tax ID', async () => { + const port = await makePort(); + await makeCompany({ + portId: port.id, + overrides: { name: 'TaxyCo', taxId: 'VAT-112233445' }, + }); + + const results = await search(port.id, 'vat-112233'); + expect(results.companies.some((c) => c.taxId === 'VAT-112233445')).toBe(true); + }); + + it('excludes archived companies', async () => { + const port = await makePort(); + const company = await makeCompany({ + portId: port.id, + overrides: { name: 'ArchivedCompanyXyz' }, + }); + await db.update(companies).set({ archivedAt: new Date() }).where(eq(companies.id, company.id)); + + const results = await search(port.id, 'ArchivedCompanyXyz'); + expect(results.companies.some((c) => c.id === company.id)).toBe(false); + }); + + it('is tenant-scoped', async () => { + const portA = await makePort(); + const portB = await makePort(); + await makeCompany({ portId: portA.id, overrides: { name: 'UniqueCompanyA' } }); + await makeCompany({ portId: portB.id, overrides: { name: 'UniqueCompanyB' } }); + + const resultsA = await search(portA.id, 'UniqueCompany'); + expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyA')).toBe(true); + expect(resultsA.companies.some((c) => c.name === 'UniqueCompanyB')).toBe(false); + }); +}); + +// ─── Combined ──────────────────────────────────────────────────────────────── + +describe('search.service — combined', () => { + it('returns clients, yachts, and companies for a query that matches multiple', async () => { + const port = await makePort(); + const client = await makeClient({ + portId: port.id, + overrides: { fullName: 'Alpha Person' }, + }); + await makeYacht({ + portId: port.id, + ownerType: 'client', + ownerId: client.id, + name: 'Alpha Yacht', + }); + await makeCompany({ portId: port.id, overrides: { name: 'Alpha Holdings' } }); + + const results = await search(port.id, 'alpha'); + expect(results.clients.some((c) => c.fullName === 'Alpha Person')).toBe(true); + expect(results.yachts.some((y) => y.name === 'Alpha Yacht')).toBe(true); + expect(results.companies.some((c) => c.name === 'Alpha Holdings')).toBe(true); + }); + + it('returns all result keys even when no matches exist', async () => { + const port = await makePort(); + const results = await search(port.id, 'zzz-nothing-matches-zzz'); + expect(results).toEqual({ + clients: [], + interests: [], + berths: [], + yachts: [], + companies: [], + }); + }); +});