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: [],
+ });
+ });
+});