Files
pn-new-crm/src/hooks/use-search.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

290 lines
7.7 KiB
TypeScript

'use client';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { useDebounce } from '@/hooks/use-debounce';
// ─── Types - mirror SearchResults from search.service.ts ─────────────────────
export type BucketType =
| 'clients'
| 'residentialClients'
| 'yachts'
| 'companies'
| 'interests'
| 'residentialInterests'
| 'berths'
| 'invoices'
| 'expenses'
| 'documents'
| 'files'
| 'reminders'
| 'brochures'
| 'tags'
| 'navigation'
| 'notes'
| 'stages';
/**
* Provenance hint for a result row that surfaced via graph expansion
* rather than a direct match against the query. Rendered as a "via X"
* subtitle by the result-row UI.
*/
export interface RelatedVia {
type: 'berth' | 'interest' | 'client' | 'yacht' | 'company';
id: string;
label: string;
}
export interface ClientResult {
id: string;
fullName: string;
matchedContact: string | null;
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
matchedOn?: string | null;
}
export interface ResidentialClientResult {
id: string;
fullName: string;
email: string | null;
phone: string | null;
status: string;
archivedAt: string | null;
}
export interface YachtResult {
id: string;
name: string;
hullNumber: string | null;
registration: string | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
}
export interface CompanyResult {
id: string;
name: string;
legalName: string | null;
taxId: string | null;
matchedField: 'name' | 'legalName' | 'taxId' | 'billingEmail' | 'registrationNumber' | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
}
export interface InterestResult {
id: string;
clientName: string;
berthMooringNumber: string | null;
pipelineStage: string;
outcome: string | null;
relatedVia?: RelatedVia | null;
}
export interface ResidentialInterestResult {
id: string;
clientName: string;
pipelineStage: string;
}
export interface BerthResult {
id: string;
mooringNumber: string;
area: string | null;
status: string;
linkedInterestCount: number;
relatedVia?: RelatedVia | null;
}
export interface InvoiceResult {
id: string;
invoiceNumber: string;
clientName: string;
status: string;
paymentStatus: string | null;
totalAmount: string | null;
currency: string;
}
export interface ExpenseResult {
id: string;
description: string | null;
vendor: string | null;
tripLabel: string | null;
amount: string;
currency: string;
paymentStatus: string | null;
}
export interface DocumentResult {
id: string;
title: string;
documentType: string;
status: string;
matchedSignerName: string | null;
}
export interface FileResult {
id: string;
filename: string;
category: string | null;
ownerLabel: string | null;
}
export interface ReminderResult {
id: string;
title: string;
dueAt: string;
priority: string;
status: string;
}
export interface BrochureResult {
id: string;
label: string;
isDefault: boolean;
archivedAt: string | null;
}
export interface TagResult {
id: string;
name: string;
color: string;
totalCount: number;
}
export interface NavResult {
id: string;
href: string;
label: string;
category: 'settings' | 'admin' | 'dashboard';
}
export interface StageSuggestionResult {
/** Canonical pipeline-stage value (matches PIPELINE_STAGES). */
stage: string;
/** Human label (STAGE_LABELS[stage]). */
label: string;
/** Live count of non-archived interests in this stage. */
count: number;
/** Slug-less href. CommandSearch prefixes the portSlug at render time. */
href: string;
}
export interface OtherPortResult {
portId: string;
portSlug: string;
portName: string;
type: 'client' | 'yacht' | 'company' | 'berth' | 'interest';
id: string;
label: string;
sub: string | null;
}
export interface SearchResults {
clients: ClientResult[];
residentialClients: ResidentialClientResult[];
yachts: YachtResult[];
companies: CompanyResult[];
interests: InterestResult[];
residentialInterests: ResidentialInterestResult[];
berths: BerthResult[];
invoices: InvoiceResult[];
expenses: ExpenseResult[];
documents: DocumentResult[];
files: FileResult[];
reminders: ReminderResult[];
brochures: BrochureResult[];
tags: TagResult[];
navigation: NavResult[];
notes: NoteResult[];
stages: StageSuggestionResult[];
totals: Record<BucketType, number>;
otherPorts?: OtherPortResult[];
}
export interface NoteResult {
id: string;
snippet: string;
source: 'client' | 'interest' | 'yacht' | 'company';
sourceId: string;
sourceLabel: string;
createdAt: string;
}
export interface RecentlyViewedItem {
type: string;
id: string;
label: string;
sub: string | null;
href: string;
viewedAt: number;
}
// ─── Hooks ────────────────────────────────────────────────────────────────────
export interface UseSearchOptions {
/** When set, narrows the result set to a single bucket. */
type?: BucketType;
/** Per-bucket cap. Default 5 (dropdown); use 25 for the /search page. */
limit?: number;
/** Super-admin opt-in for cross-port matches. Silently ignored otherwise. */
includeOtherPorts?: boolean;
/** Override the 300ms input debounce. */
debounceMs?: number;
}
export function useSearch(query: string, opts: UseSearchOptions = {}) {
const debouncedQuery = useDebounce(query, opts.debounceMs ?? 300);
const enabled = debouncedQuery.length >= 2;
const params = new URLSearchParams();
params.set('q', debouncedQuery);
if (opts.type) params.set('type', opts.type);
if (opts.limit) params.set('limit', String(opts.limit));
if (opts.includeOtherPorts) params.set('includeOtherPorts', 'true');
const searchQuery = useQuery<SearchResults>({
queryKey: [
'search',
debouncedQuery,
opts.type ?? 'all',
opts.limit ?? 5,
opts.includeOtherPorts ?? false,
],
queryFn: ({ signal }) =>
apiFetch<SearchResults>(`/api/v1/search?${params.toString()}`, { signal }),
enabled,
// Keep previous results visible while the next debounced query loads
// - eliminates the dropdown flicker when the user is typing fast.
placeholderData: keepPreviousData,
staleTime: 30_000,
});
const recentSearchQuery = useQuery<{ searches: string[] }>({
queryKey: ['search', 'recent-terms'],
queryFn: ({ signal }) => apiFetch<{ searches: string[] }>('/api/v1/search/recent', { signal }),
staleTime: 60_000,
});
const recentlyViewedQuery = useQuery<{ data: RecentlyViewedItem[] }>({
queryKey: ['search', 'recently-viewed'],
queryFn: ({ signal }) =>
apiFetch<{ data: RecentlyViewedItem[] }>('/api/v1/search/recently-viewed', { signal }),
staleTime: 30_000,
});
return {
results: searchQuery.data,
isLoading: searchQuery.isLoading,
isFetching: searchQuery.isFetching,
enabled,
recentSearches: recentSearchQuery.data?.searches ?? [],
recentlyViewed: recentlyViewedQuery.data?.data ?? [],
};
}
/**
* Track that the current user just opened an entity. Call once on mount
* from each entity detail page (via `useTrackEntityView`); the resulting
* (type, id) pair is used by the search dropdown's "Recently viewed"
* section to surface the user's working set.
*/
export async function trackEntityView(type: string, id: string): Promise<void> {
try {
await apiFetch('/api/v1/search/recently-viewed', {
method: 'POST',
body: { type, id },
});
} catch {
// Tracking is non-critical - never bubble up to the user.
}
}