feat(ui): broad consistency sweep — sources, dates, comboboxes, milestones

Mobile + responsive
- berth-form full-width on phones (was 480px fixed → overflowed iPhone)
- currency-input switched to inputMode=decimal with live thousands separator
- client-form Country/Timezone/Source/Preferred-Contact full-width <sm
- contacts row restructured so Primary toggle + Remove get their own strip
- customize-dashboard footer stacks vertically on mobile; Done full-width
- interest-form client/berth pickers no longer cmdk-filter on UUID (typing
  "Carlos" now returns Carlos Vega instead of "No clients found")

Data + consistency
- SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces
  now resolve interest/client source from one place
- INTEREST_OUTCOMES adds lost_other (picker, badge, timeline)
- Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort
- archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles
- TableBody last-row uses border-b-0 (not border-0); colored left-accent
  on the bottom berth row now renders
- Hide Invite-to-Portal until port setting === true (was !== false default-show)
- OwnerPicker primer query resolves entity name on first paint (no more
  UUID flash before the popover opens)

Terminology
- Replaced user-facing "Documenso" with "signing service" / "Generated EOI" /
  "Manual EOI" in 8 components (admin/internal references kept)
- Plainer status-change copy on berth-detail-header

Forms + editing
- InlineEditableField gained a `date` variant (native picker); applied to
  company incorporation date and ready for other YYYY-MM-DD plaintext fields
- Inline source picker on interest-tabs detail (was free text)
- TagPicker self-hides when port has no tags AND nothing is selected
- New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom)
- Compose dialog follow-up is now a toggle that reveals datetime picker

Pipeline milestones
- changeStageSchema accepts optional milestoneDate; service stamps it on the
  matching date column instead of always using now
- MilestoneAdvanceButton popover collects a back-date before stage advance
- Applied to every "Mark X manually" surface on the interest overview

EOI / linked-berths polish
- Add-bypass row aligned inline with toggle descriptions
- Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their
  legal vs. public-map consequences

Surfaces
- Companies list now has the column picker + persisted hidden-column prefs
- NotesList aggregate flag enabled on clients, companies, residential_clients
  (yachts already aggregated)

ft/m unit toggle (interim, before drift fix)
- "Berth size desired" gets a section-level ft/m toggle; per-field hint shows
  the converted value. Storage stays canonical-ft for now; the drift-safe
  persistence migration is the next step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:50:58 +02:00
parent 638000bb58
commit 3ffee79f3f
132 changed files with 5784 additions and 997 deletions

View File

@@ -48,12 +48,25 @@ import type { RolePermissions } from '@/lib/db/schema/users';
// ─── Types ────────────────────────────────────────────────────────────────────
/**
* Provenance hint for a result row that was surfaced via graph expansion
* rather than a direct query match. The frontend renders this as a
* subtitle, e.g. "via Berth A10". `null` (or absent) means the row is
* a direct match against the user's query.
*/
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;
}
export interface ResidentialClientResult {
@@ -71,6 +84,7 @@ export interface InterestResult {
berthMooringNumber: string | null;
pipelineStage: string;
outcome: string | null;
relatedVia?: RelatedVia | null;
}
export interface ResidentialInterestResult {
@@ -85,6 +99,7 @@ export interface BerthResult {
area: string | null;
status: string;
linkedInterestCount: number;
relatedVia?: RelatedVia | null;
}
export interface YachtResult {
@@ -93,6 +108,7 @@ export interface YachtResult {
hullNumber: string | null;
registration: string | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
}
export interface CompanyResult {
@@ -102,6 +118,7 @@ export interface CompanyResult {
taxId: string | null;
matchedField: 'name' | 'legalName' | 'taxId' | 'billingEmail' | 'registrationNumber' | null;
archivedAt: string | null;
relatedVia?: RelatedVia | null;
}
export interface InvoiceResult {
@@ -716,10 +733,60 @@ async function searchResidentialInterests(
}
async function searchBerths(portId: string, query: string, limit: number): Promise<BerthResult[]> {
// Trigram (`%`) is the canonical mooring-number search — it tolerates
// a hyphen or wrong leading-zero. Fallback to ILIKE for `area`.
const ilikePattern = `%${query}%`;
// Mooring numbers are short alphanumeric codes (A1, B12, E18) where
// prefix-on-number expansion produces confusing UX — typing "A1"
// when A1 exists shouldn't *also* surface A10, A11, A12. Reps know
// mooring numbers and almost always type them in full.
//
// Strategy: if an exact mooring-number match exists for the query,
// return ONLY that one row. Otherwise fall back to letter-prefix +
// number-prefix matching (so typing "A" returns the whole A dock,
// typing "A1" with no A1 in the DB returns A10/A11/A12, etc.).
// Area-name matches are also folded into the fallback.
const trimmed = query.trim();
const m = /^([A-Za-z]*)(\d*)$/.exec(trimmed);
const letterPart = (m?.[1] ?? '').toUpperCase();
const numberPart = m?.[2] ?? '';
const isStructured = letterPart.length > 0 || numberPart.length > 0;
const ilikePattern = `%${trimmed}%`;
const prefixPattern = `${trimmed}%`;
// First: try for an exact match. Cheap — uses the unique-index on
// (port_id, mooring_number).
const exact = await db.execute<{
id: string;
mooring_number: string;
area: string | null;
status: string;
linked_interest_count: string;
}>(sql`
SELECT
b.id, b.mooring_number, b.area, b.status,
(
SELECT COUNT(*)::text FROM interest_berths ib
JOIN interests i ON ib.interest_id = i.id
WHERE ib.berth_id = b.id AND i.archived_at IS NULL
) AS linked_interest_count
FROM berths b
WHERE b.port_id = ${portId}
AND UPPER(b.mooring_number) = ${trimmed.toUpperCase()}
LIMIT 1
`);
const exactRows = Array.from(exact);
if (exactRows.length > 0) {
return exactRows.map((r) => ({
id: r.id,
mooringNumber: r.mooring_number,
area: r.area ?? null,
status: r.status,
linkedInterestCount: Number(r.linked_interest_count) || 0,
}));
}
// No exact match — fall back to letter+number-prefix matching plus
// a generic area/ILIKE fallback for non-structured queries.
const rows = await db.execute<{
id: string;
mooring_number: string;
@@ -728,30 +795,33 @@ async function searchBerths(portId: string, query: string, limit: number): Promi
linked_interest_count: string;
}>(sql`
SELECT
b.id,
b.mooring_number,
b.area,
b.status,
b.id, b.mooring_number, b.area, b.status,
(
SELECT COUNT(*)::text
FROM interest_berths ib
SELECT COUNT(*)::text FROM interest_berths ib
JOIN interests i ON ib.interest_id = i.id
WHERE ib.berth_id = b.id AND i.archived_at IS NULL
) AS linked_interest_count
FROM berths b
WHERE b.port_id = ${portId}
AND (
b.mooring_number ILIKE ${ilikePattern}
OR b.mooring_number % ${query}
${
isStructured
? sql`(
regexp_replace(b.mooring_number, '[0-9]+$', '') = ${letterPart}
AND regexp_replace(b.mooring_number, '^[A-Za-z]+', '') LIKE ${numberPart + '%'}
)`
: sql`FALSE`
}
OR b.mooring_number ILIKE ${prefixPattern}
OR b.area ILIKE ${ilikePattern}
)
ORDER BY
CASE
WHEN b.mooring_number ILIKE ${query + '%'} THEN 1
WHEN b.mooring_number ILIKE ${ilikePattern} THEN 2
WHEN b.mooring_number ILIKE ${prefixPattern} THEN 1
WHEN b.area ILIKE ${prefixPattern} THEN 2
ELSE 3
END,
similarity(b.mooring_number, ${query}) DESC,
length(b.mooring_number),
b.mooring_number
LIMIT ${limit}
`);
@@ -1245,6 +1315,429 @@ async function searchOtherPorts(
// ─── Public entrypoint ──────────────────────────────────────────────────────
/**
* Graph expansion — for every direct match in a search, fetch the
* 1-hop related entities and add them to the appropriate bucket.
*
* Berth match → its interests + their clients + their yachts
* Interest match → its berth + client + yacht
* Client match → their interests + their owned yachts + companies
* they're members of
* Yacht match → its interests + its owner (client/company)
* Company match → its members (clients) + their interests
*
* Depth limited to 1 hop to avoid quadratic fan-out. Each expansion row
* carries a `relatedVia` hint so the UI can show "via Berth A10" beneath
* the row's title.
*
* Rows that are already a direct match are NOT duplicated — the dedupe
* runs on `id`. Direct matches always take precedence (their relatedVia
* stays unset).
*/
async function expandGraph(
portId: string,
direct: {
berthIds: string[];
interestIds: string[];
clientIds: string[];
yachtIds: string[];
companyIds: string[];
},
perBucketCap: number,
): Promise<{
interests: InterestResult[];
clients: ClientResult[];
yachts: YachtResult[];
companies: CompanyResult[];
berths: BerthResult[];
}> {
// Helper: SQL-safe ANY() needs a non-empty array; bail early otherwise.
const hasAny = (arr: string[]) => arr.length > 0;
// ─── Berth → Interests (and their clients + yachts) ─────────────────
const interestsFromBerths = hasAny(direct.berthIds)
? await db.execute<{
id: string;
client_name: string;
mooring_number: string;
pipeline_stage: string;
outcome: string | null;
via_berth_id: string;
via_berth_label: string;
}>(sql`
SELECT
i.id,
c.full_name AS client_name,
b.mooring_number,
i.pipeline_stage,
i.outcome,
b.id AS via_berth_id,
b.mooring_number AS via_berth_label
FROM interest_berths ib
JOIN interests i ON ib.interest_id = i.id
JOIN clients c ON i.client_id = c.id
JOIN berths b ON ib.berth_id = b.id
WHERE ib.berth_id IN (${sql.join(direct.berthIds.map((id) => sql`${id}`), sql`, `)})
AND i.port_id = ${portId}
AND i.archived_at IS NULL
ORDER BY ib.is_primary DESC, i.created_at DESC
LIMIT ${perBucketCap * direct.berthIds.length}
`)
: [];
// ─── Interest → Berth, Client, Yacht ─────────────────────────────────
// For interests that matched directly, surface their connected berth +
// client + yacht as related entries in those buckets.
const fromInterests = hasAny(direct.interestIds)
? await db.execute<{
interest_id: string;
client_id: string;
client_name: string;
yacht_id: string | null;
yacht_name: string | null;
berth_id: string | null;
mooring_number: string | null;
berth_area: string | null;
berth_status: string | null;
}>(sql`
SELECT
i.id AS interest_id,
c.id AS client_id,
c.full_name AS client_name,
y.id AS yacht_id,
y.name AS yacht_name,
b.id AS berth_id,
b.mooring_number,
b.area AS berth_area,
b.status AS berth_status
FROM interests i
JOIN clients c ON i.client_id = c.id
LEFT JOIN yachts y ON i.yacht_id = y.id
LEFT JOIN LATERAL (
SELECT b.* FROM interest_berths ib2
JOIN berths b ON ib2.berth_id = b.id
WHERE ib2.interest_id = i.id
ORDER BY ib2.is_primary DESC
LIMIT 1
) b ON TRUE
WHERE i.id IN (${sql.join(direct.interestIds.map((id) => sql`${id}`), sql`, `)})
AND i.port_id = ${portId}
`)
: [];
// ─── Client → Interests, Owned Yachts, Member Companies ──────────────
const fromClients = hasAny(direct.clientIds)
? await Promise.all([
// Their interests
db.execute<{
id: string;
client_id: string;
client_name: string;
mooring_number: string | null;
pipeline_stage: string;
outcome: string | null;
}>(sql`
SELECT i.id, i.client_id, c.full_name AS client_name,
b.mooring_number, i.pipeline_stage, i.outcome
FROM interests i
JOIN clients c ON i.client_id = c.id
LEFT JOIN LATERAL (
SELECT b.mooring_number FROM interest_berths ib
JOIN berths b ON ib.berth_id = b.id
WHERE ib.interest_id = i.id
ORDER BY ib.is_primary DESC LIMIT 1
) b ON TRUE
WHERE i.client_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
AND i.port_id = ${portId}
AND i.archived_at IS NULL
ORDER BY i.created_at DESC
LIMIT ${perBucketCap * direct.clientIds.length}
`),
// Yachts they own (current_owner_type='client')
db.execute<{
id: string;
name: string;
hull_number: string | null;
registration: string | null;
archived_at: string | null;
owner_id: string;
owner_name: string;
}>(sql`
SELECT y.id, y.name, y.hull_number, y.registration, y.archived_at::text,
c.id AS owner_id, c.full_name AS owner_name
FROM yachts y
JOIN clients c ON y.current_owner_id = c.id
WHERE y.current_owner_type = 'client'
AND y.current_owner_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
AND y.port_id = ${portId}
ORDER BY y.name
LIMIT ${perBucketCap * direct.clientIds.length}
`),
// Companies they're members of
db.execute<{
id: string;
name: string;
legal_name: string | null;
tax_id: string | null;
archived_at: string | null;
via_client_id: string;
via_client_name: string;
}>(sql`
SELECT co.id, co.name, co.legal_name, co.tax_id, co.archived_at::text,
c.id AS via_client_id, c.full_name AS via_client_name
FROM company_memberships cm
JOIN companies co ON cm.company_id = co.id
JOIN clients c ON cm.client_id = c.id
WHERE cm.client_id IN (${sql.join(direct.clientIds.map((id) => sql`${id}`), sql`, `)})
AND cm.end_date IS NULL
AND co.port_id = ${portId}
ORDER BY co.name
LIMIT ${perBucketCap * direct.clientIds.length}
`),
])
: [[], [], []];
// ─── Yacht → Interests, Owner ───────────────────────────────────────
const fromYachts = hasAny(direct.yachtIds)
? await Promise.all([
// Interests on these yachts
db.execute<{
id: string;
client_name: string;
mooring_number: string | null;
pipeline_stage: string;
outcome: string | null;
via_yacht_id: string;
via_yacht_name: string;
}>(sql`
SELECT i.id, c.full_name AS client_name,
b.mooring_number, i.pipeline_stage, i.outcome,
y.id AS via_yacht_id, y.name AS via_yacht_name
FROM interests i
JOIN clients c ON i.client_id = c.id
JOIN yachts y ON i.yacht_id = y.id
LEFT JOIN LATERAL (
SELECT b.mooring_number FROM interest_berths ib
JOIN berths b ON ib.berth_id = b.id
WHERE ib.interest_id = i.id
ORDER BY ib.is_primary DESC LIMIT 1
) b ON TRUE
WHERE i.yacht_id IN (${sql.join(direct.yachtIds.map((id) => sql`${id}`), sql`, `)})
AND i.port_id = ${portId}
AND i.archived_at IS NULL
ORDER BY i.created_at DESC
LIMIT ${perBucketCap * direct.yachtIds.length}
`),
// Owners (client + company variants via polymorphic FKs)
db.execute<{
yacht_id: string;
yacht_name: string;
owner_type: string;
owner_id: string;
owner_label: string;
}>(sql`
SELECT y.id AS yacht_id, y.name AS yacht_name,
y.current_owner_type AS owner_type,
COALESCE(c.id, co.id) AS owner_id,
COALESCE(c.full_name, co.name) AS owner_label
FROM yachts y
LEFT JOIN clients c
ON y.current_owner_type = 'client' AND y.current_owner_id = c.id
LEFT JOIN companies co
ON y.current_owner_type = 'company' AND y.current_owner_id = co.id
WHERE y.id IN (${sql.join(direct.yachtIds.map((id) => sql`${id}`), sql`, `)})
AND y.port_id = ${portId}
AND y.current_owner_id IS NOT NULL
`),
])
: [[], []];
// ─── Company → Members (Clients), their Interests ────────────────────
const fromCompanies = hasAny(direct.companyIds)
? await Promise.all([
db.execute<{
id: string;
full_name: string;
archived_at: string | null;
via_company_id: string;
via_company_name: string;
}>(sql`
SELECT c.id, c.full_name, c.archived_at::text,
co.id AS via_company_id, co.name AS via_company_name
FROM company_memberships cm
JOIN clients c ON cm.client_id = c.id
JOIN companies co ON cm.company_id = co.id
WHERE cm.company_id IN (${sql.join(direct.companyIds.map((id) => sql`${id}`), sql`, `)})
AND cm.end_date IS NULL
AND c.port_id = ${portId}
ORDER BY c.full_name
LIMIT ${perBucketCap * direct.companyIds.length}
`),
])
: [[]];
// ─── Marshal into bucket-shaped result rows ──────────────────────────
const expandedInterests: InterestResult[] = [];
const expandedClients: ClientResult[] = [];
const expandedYachts: YachtResult[] = [];
const expandedCompanies: CompanyResult[] = [];
const expandedBerths: BerthResult[] = [];
// From berths
for (const r of Array.from(interestsFromBerths)) {
expandedInterests.push({
id: r.id,
clientName: r.client_name,
berthMooringNumber: r.mooring_number,
pipelineStage: r.pipeline_stage,
outcome: r.outcome,
relatedVia: { type: 'berth', id: r.via_berth_id, label: `Berth ${r.via_berth_label}` },
});
}
// From interests (the matched row's client, yacht, berth)
for (const r of Array.from(fromInterests)) {
if (r.client_id) {
expandedClients.push({
id: r.client_id,
fullName: r.client_name,
matchedContact: null,
matchedContactChannel: null,
archivedAt: null,
relatedVia: { type: 'interest', id: r.interest_id, label: `Interest · ${r.client_name}` },
});
}
if (r.yacht_id) {
expandedYachts.push({
id: r.yacht_id,
name: r.yacht_name ?? '(unnamed yacht)',
hullNumber: null,
registration: null,
archivedAt: null,
relatedVia: { type: 'interest', id: r.interest_id, label: `Interest · ${r.client_name}` },
});
}
if (r.berth_id) {
expandedBerths.push({
id: r.berth_id,
mooringNumber: r.mooring_number ?? '',
area: r.berth_area,
status: r.berth_status ?? 'available',
linkedInterestCount: 0,
relatedVia: { type: 'interest', id: r.interest_id, label: `Interest · ${r.client_name}` },
});
}
}
// From clients
const [clientInterests, clientYachts, clientCompanies] = fromClients;
for (const r of Array.from(clientInterests)) {
expandedInterests.push({
id: r.id,
clientName: r.client_name,
berthMooringNumber: r.mooring_number,
pipelineStage: r.pipeline_stage,
outcome: r.outcome,
relatedVia: { type: 'client', id: r.client_id, label: r.client_name },
});
}
for (const r of Array.from(clientYachts)) {
expandedYachts.push({
id: r.id,
name: r.name,
hullNumber: r.hull_number,
registration: r.registration,
archivedAt: r.archived_at,
relatedVia: { type: 'client', id: r.owner_id, label: r.owner_name },
});
}
for (const r of Array.from(clientCompanies)) {
expandedCompanies.push({
id: r.id,
name: r.name,
legalName: r.legal_name,
taxId: r.tax_id,
matchedField: null,
archivedAt: r.archived_at,
relatedVia: { type: 'client', id: r.via_client_id, label: r.via_client_name },
});
}
// From yachts
const [yachtInterests, yachtOwners] = fromYachts;
for (const r of Array.from(yachtInterests)) {
expandedInterests.push({
id: r.id,
clientName: r.client_name,
berthMooringNumber: r.mooring_number,
pipelineStage: r.pipeline_stage,
outcome: r.outcome,
relatedVia: { type: 'yacht', id: r.via_yacht_id, label: r.via_yacht_name },
});
}
for (const r of Array.from(yachtOwners)) {
if (!r.owner_id) continue;
if (r.owner_type === 'client') {
expandedClients.push({
id: r.owner_id,
fullName: r.owner_label,
matchedContact: null,
matchedContactChannel: null,
archivedAt: null,
relatedVia: { type: 'yacht', id: r.yacht_id, label: r.yacht_name },
});
} else if (r.owner_type === 'company') {
expandedCompanies.push({
id: r.owner_id,
name: r.owner_label,
legalName: null,
taxId: null,
matchedField: null,
archivedAt: null,
relatedVia: { type: 'yacht', id: r.yacht_id, label: r.yacht_name },
});
}
}
// From companies
const [companyMembers] = fromCompanies;
for (const r of Array.from(companyMembers)) {
expandedClients.push({
id: r.id,
fullName: r.full_name,
matchedContact: null,
matchedContactChannel: null,
archivedAt: r.archived_at,
relatedVia: { type: 'company', id: r.via_company_id, label: r.via_company_name },
});
}
return {
interests: expandedInterests,
clients: expandedClients,
yachts: expandedYachts,
companies: expandedCompanies,
berths: expandedBerths,
};
}
/**
* Merge direct-match rows with graph-expansion rows. Direct matches
* (those without `relatedVia` set) take precedence — if a row appears
* in both, the direct version wins. Direct matches sort before
* related matches.
*/
function mergeWithExpansion<
T extends { id: string; relatedVia?: RelatedVia | null },
>(direct: T[], expansion: T[], cap: number): T[] {
const seen = new Set(direct.map((r) => r.id));
const merged = [
...direct.map((r) => ({ ...r, relatedVia: null as RelatedVia | null })),
...expansion.filter((r) => !seen.has(r.id) && (seen.add(r.id), true)),
];
return merged.slice(0, cap);
}
/**
* Returns a populated `SearchResults` for the given port + query. All
* unrequested or permission-denied buckets come back as empty arrays so
@@ -1252,6 +1745,10 @@ async function searchOtherPorts(
*
* Per-bucket queries are run in parallel via `Promise.all` — total
* latency is bounded by the single slowest bucket.
*
* Graph expansion: after the direct-match phase, related entities are
* fetched in a single second pass (`expandGraph`) so reps searching for
* one entity see everything connected to it. See expandGraph docstring.
*/
export async function search(
portId: string,
@@ -1263,8 +1760,20 @@ export async function search(
if (!query || query.trim().length < 1) return empty;
// Single-bucket mode (used by /search?type=clients) — skip everything
// else for speed.
if (opts.type) return runSingleBucket(portId, query, limit, opts);
// else for speed. Graph-expansion buckets (clients, yachts, companies,
// interests, berths) fall through to the full pipeline below so that
// related-via matches survive the chip narrow — otherwise typing
// "carlos vega" with the Yachts chip selected would return zero rows
// even though the All chip shows "Yachts (1)" (the yacht owned by
// Carlos, surfaced through expandGraph). We trim to the requested
// bucket at the end.
type GraphBucket = 'clients' | 'yachts' | 'companies' | 'interests' | 'berths';
const GRAPH_BUCKETS: GraphBucket[] = ['clients', 'yachts', 'companies', 'interests', 'berths'];
const narrowTo: GraphBucket | null =
opts.type && (GRAPH_BUCKETS as readonly string[]).includes(opts.type)
? (opts.type as GraphBucket)
: null;
if (opts.type && !narrowTo) return runSingleBucket(portId, query, limit, opts);
const wantEmail = looksLikeEmail(query);
const wantPhone = normalizePhoneQuery(query) !== null;
@@ -1350,17 +1859,43 @@ export async function search(
void wantEmail;
void wantPhone;
// ─── Phase 2: graph expansion ───────────────────────────────────────
// For every direct match, fetch its 1-hop related entities so reps
// who search "A10" see the linked interests/clients/yachts/companies
// surface alongside the berth. See `expandGraph` docstring for the
// relationship map and per-bucket caps.
const expanded = await expandGraph(
portId,
{
berthIds: berths.map((b) => b.id),
interestIds: interests.map((i) => i.id),
clientIds: clients.map((c) => c.id),
yachtIds: yachts.map((y) => y.id),
companyIds: companies.map((c) => c.id),
},
limit,
);
const apply = <T extends { id: string }>(rows: T[]) =>
applyAffinity(rows, opts.recentlyTouchedIds);
// Merge direct matches with expansion rows; direct rows always win
// ties and sort first. Each bucket caps at `limit * 2` so reps still
// see the full direct-match set plus a healthy expansion tail.
const mergedClients = mergeWithExpansion(clients, expanded.clients, limit * 2);
const mergedInterests = mergeWithExpansion(interests, expanded.interests, limit * 2);
const mergedYachts = mergeWithExpansion(yachts, expanded.yachts, limit * 2);
const mergedCompanies = mergeWithExpansion(companies, expanded.companies, limit * 2);
const mergedBerths = mergeWithExpansion(berths, expanded.berths, limit * 2);
const result: SearchResults = {
clients: apply(clients),
clients: apply(mergedClients),
residentialClients: apply(residentialClients),
yachts: apply(yachts),
companies: apply(companies),
interests: apply(interests),
yachts: apply(mergedYachts),
companies: apply(mergedCompanies),
interests: apply(mergedInterests),
residentialInterests: apply(residentialInterests),
berths: apply(berths),
berths: apply(mergedBerths),
invoices: apply(invoices),
expenses: apply(expenses),
documents: apply(documents),
@@ -1371,13 +1906,13 @@ export async function search(
navigation,
notes,
totals: {
clients: clients.length,
clients: mergedClients.length,
residentialClients: residentialClients.length,
yachts: yachts.length,
companies: companies.length,
interests: interests.length,
yachts: mergedYachts.length,
companies: mergedCompanies.length,
interests: mergedInterests.length,
residentialInterests: residentialInterests.length,
berths: berths.length,
berths: mergedBerths.length,
invoices: invoices.length,
expenses: expenses.length,
documents: documents.length,
@@ -1391,6 +1926,22 @@ export async function search(
otherPorts: otherPorts.length > 0 ? otherPorts : undefined,
};
// When narrowing to a graph bucket, zero out every other bucket so the
// dropdown only renders the chosen one. Totals for the other buckets
// stay populated so the chip row still shows their counts — the client
// already snapshots the last "all" totals separately, but keeping them
// here means a direct API hit with ?type=yachts still sees all chip
// counts for free.
if (narrowTo) {
const keep = narrowTo;
return {
...emptyResults(),
[keep]: result[keep],
totals: result.totals,
otherPorts: result.otherPorts,
} as SearchResults;
}
return result;
}