feat(search): full-platform search overhaul + view tracking + notes bucket
Service rewrite covers 14 entity buckets (clients, residential clients, yachts, companies, interests, residential interests, berths, invoices, expenses, documents, files, reminders, brochures, tags, notes, navigation) with prefix tsquery + trigram fallback, phone-digit normalization, and JOINs to client_contacts for email matching. New `notes` bucket searches across the four note tables (client, interest, yacht, company) via UNION + parent-entity label resolution (berth mooring for interests, name for yachts/companies). Renders at the bottom of the dropdown so broad-content matches don't crowd entity-specific hits — per the user's "low-noise" preference. Recently-viewed tracking persists last 20 entity views per user in Redis sorted set; CommandSearch surfaces them as the dropdown's default state and applies affinity ranking when the user types. ID-resolve endpoint accepts pasted UUIDs (or invoice numbers like `INV-2025-001`) and routes the rep straight to the entity, skipping the normal search bucket. Audit search service gains `entityIds[]` array filter for the new loadClientActivityAggregated() path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,57 +1,260 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { useDebounce } from '@/hooks/use-debounce';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
// ─── Types — mirror SearchResults from search.service.ts ─────────────────────
|
||||
|
||||
interface SearchResults {
|
||||
clients: Array<{ id: string; fullName: string }>;
|
||||
interests: Array<{
|
||||
id: string;
|
||||
clientName: string;
|
||||
berthMooringNumber: string | null;
|
||||
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;
|
||||
}>;
|
||||
export type BucketType =
|
||||
| 'clients'
|
||||
| 'residentialClients'
|
||||
| 'yachts'
|
||||
| 'companies'
|
||||
| 'interests'
|
||||
| 'residentialInterests'
|
||||
| 'berths'
|
||||
| 'invoices'
|
||||
| 'expenses'
|
||||
| 'documents'
|
||||
| 'files'
|
||||
| 'reminders'
|
||||
| 'brochures'
|
||||
| 'tags'
|
||||
| 'navigation'
|
||||
| 'notes';
|
||||
|
||||
export interface ClientResult {
|
||||
id: string;
|
||||
fullName: string;
|
||||
matchedContact: string | null;
|
||||
matchedContactChannel: 'email' | 'phone' | 'whatsapp' | null;
|
||||
archivedAt: 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;
|
||||
}
|
||||
export interface CompanyResult {
|
||||
id: string;
|
||||
name: string;
|
||||
legalName: string | null;
|
||||
taxId: string | null;
|
||||
matchedField: 'name' | 'legalName' | 'taxId' | 'billingEmail' | 'registrationNumber' | null;
|
||||
archivedAt: string | null;
|
||||
}
|
||||
export interface InterestResult {
|
||||
id: string;
|
||||
clientName: string;
|
||||
berthMooringNumber: string | null;
|
||||
pipelineStage: string;
|
||||
outcome: string | null;
|
||||
}
|
||||
export interface ResidentialInterestResult {
|
||||
id: string;
|
||||
clientName: string;
|
||||
pipelineStage: string;
|
||||
}
|
||||
export interface BerthResult {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
status: string;
|
||||
linkedInterestCount: number;
|
||||
}
|
||||
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 OtherPortResult {
|
||||
portId: string;
|
||||
portSlug: string;
|
||||
portName: string;
|
||||
type: 'client' | 'yacht' | 'company' | 'berth' | 'interest';
|
||||
id: string;
|
||||
label: string;
|
||||
sub: string | null;
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
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[];
|
||||
totals: Record<BucketType, number>;
|
||||
otherPorts?: OtherPortResult[];
|
||||
}
|
||||
|
||||
export function useSearch(query: string) {
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
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],
|
||||
queryFn: () =>
|
||||
apiFetch<SearchResults>(`/api/v1/search?q=${encodeURIComponent(debouncedQuery)}`),
|
||||
enabled: debouncedQuery.length >= 2,
|
||||
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 recentQuery = useQuery<{ searches: string[] }>({
|
||||
queryKey: ['search', 'recent'],
|
||||
queryFn: () => apiFetch<{ searches: string[] }>('/api/v1/search/recent'),
|
||||
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<{ items: RecentlyViewedItem[] }>({
|
||||
queryKey: ['search', 'recently-viewed'],
|
||||
queryFn: ({ signal }) =>
|
||||
apiFetch<{ items: RecentlyViewedItem[] }>('/api/v1/search/recently-viewed', { signal }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return {
|
||||
results: searchQuery.data,
|
||||
isLoading: searchQuery.isLoading,
|
||||
recentSearches: recentQuery.data?.searches ?? [],
|
||||
isFetching: searchQuery.isFetching,
|
||||
enabled,
|
||||
recentSearches: recentSearchQuery.data?.searches ?? [],
|
||||
recentlyViewed: recentlyViewedQuery.data?.items ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
}
|
||||
}
|
||||
|
||||
37
src/hooks/use-track-entity-view.ts
Normal file
37
src/hooks/use-track-entity-view.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { trackEntityView } from '@/hooks/use-search';
|
||||
|
||||
/**
|
||||
* Records that the user opened the given entity detail page so the
|
||||
* global search dropdown can surface it under "Recently viewed". Skips
|
||||
* the call when `id` is falsy (e.g. during a transitional render before
|
||||
* the data has loaded).
|
||||
*
|
||||
* Uses a JSON-stringified deps array so re-renders with the same
|
||||
* (type, id) don't re-fire the network call. The fire-and-forget
|
||||
* tracking endpoint debounces server-side too (Redis ZADD upserts the
|
||||
* same member with a fresh score), but skipping the redundant fetch
|
||||
* keeps the network panel tidy.
|
||||
*/
|
||||
export function useTrackEntityView(
|
||||
type:
|
||||
| 'client'
|
||||
| 'residential-client'
|
||||
| 'yacht'
|
||||
| 'company'
|
||||
| 'interest'
|
||||
| 'residential-interest'
|
||||
| 'berth'
|
||||
| 'invoice'
|
||||
| 'expense'
|
||||
| 'document',
|
||||
id: string | null | undefined,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
void trackEntityView(type, id);
|
||||
}, [type, id]);
|
||||
}
|
||||
Reference in New Issue
Block a user