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:
2026-05-07 20:58:34 +02:00
parent a0e68eb060
commit 267c2b6d1f
14 changed files with 3821 additions and 378 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
import { Fragment, type ReactNode } from 'react';
/**
* Wrap occurrences of `query` inside `text` with `<mark>` so the user
* can see why a result matched. Case-insensitive, escapes regex meta-
* chars in the query so a paste of "INV-2025" doesn't blow up.
*
* Tokenized matching — splits the query on whitespace and highlights
* each token independently, so "joh smi" highlights both "Joh" and
* "Smi" in "John Smith". Mirrors the prefix-tsquery the server uses.
*/
export function HighlightMatch({
text,
query,
className,
}: {
text: string | null | undefined;
query: string;
className?: string;
}): ReactNode {
if (!text) return null;
const tokens = query
.trim()
.split(/\s+/)
.filter((t) => t.length > 0)
.map(escapeRegex);
if (tokens.length === 0) return text;
const re = new RegExp(`(${tokens.join('|')})`, 'gi');
const parts = text.split(re);
return (
<span className={className}>
{parts.map((part, i) => {
if (i % 2 === 1) {
// The capture group lands on odd indices.
return (
<mark
key={i}
className="bg-brand/15 text-foreground rounded-[2px] px-[1px] font-medium"
>
{part}
</mark>
);
}
return <Fragment key={i}>{part}</Fragment>;
})}
</span>
);
}
function escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -0,0 +1,33 @@
'use client';
import { useTrackEntityView } from '@/hooks/use-track-entity-view';
/**
* Render-only client component that records "the user opened this
* entity" via the global-search recently-viewed Redis log. Drop into a
* server-rendered detail page as `<TrackEntityView type="client" id={…} />`
* — the component renders nothing.
*
* Centralises the tracking call so future changes (debounce, schema,
* batching) only need to touch one file rather than every detail page.
*/
export function TrackEntityView({
type,
id,
}: {
type:
| 'client'
| 'residential-client'
| 'yacht'
| 'company'
| 'interest'
| 'residential-interest'
| 'berth'
| 'invoice'
| 'expense'
| 'document';
id: string | null | undefined;
}) {
useTrackEntityView(type, id);
return null;
}