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:
@@ -33,6 +33,18 @@ export function isCustomRange(range: DateRange): range is CustomDateRange {
|
||||
return typeof range === 'object' && range.kind === 'custom';
|
||||
}
|
||||
|
||||
/**
|
||||
* Filename-safe slug for the active range. Used by chart exports so a
|
||||
* PNG/CSV download lands as `pipeline-funnel-30d.png` rather than
|
||||
* `pipeline-funnel-[object Object].png` (which is what a raw template
|
||||
* literal does to a CustomDateRange — Chrome then strips the name and
|
||||
* the file falls back to the blob URL's UUID with no extension).
|
||||
*/
|
||||
export function rangeToSlug(range: DateRange): string {
|
||||
if (isCustomRange(range)) return `${range.from}_${range.to}`;
|
||||
return range;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve any DateRange (preset or custom) to a concrete {from, to} pair.
|
||||
* - Preset ranges anchor `to` at "now" and `from` at `now - N days`.
|
||||
|
||||
@@ -168,11 +168,39 @@ export const BERTH_ACCESS_OPTIONS = [
|
||||
'Car (3.5t) to Vessel',
|
||||
] as const;
|
||||
|
||||
/** Helper to map a readonly enum tuple into shadcn `<Select>` `{value,label}` objects. */
|
||||
/**
|
||||
* Map a readonly enum tuple into shadcn `<Select>` `{value, label}` objects.
|
||||
* `value` is the raw enum string (what the API expects); `label` is a
|
||||
* human-formatted version (underscores → spaces, Title Case) so reps
|
||||
* see "Under Offer" instead of "under_offer" in dropdowns. Specific
|
||||
* acronyms keep their canonical casing.
|
||||
*/
|
||||
const LABEL_OVERRIDES: Record<string, string> = {
|
||||
// 3-letter acronyms — preserve all-caps where the enum stores lowercase.
|
||||
vhf: 'VHF',
|
||||
eoi: 'EOI',
|
||||
nda: 'NDA',
|
||||
// Status enums where the natural title-cased form differs slightly.
|
||||
under_offer: 'Under Offer',
|
||||
fixed_term: 'Fixed Term',
|
||||
reservation_agreement: 'Reservation Agreement',
|
||||
hot_lead: 'Hot Lead',
|
||||
general_interest: 'General Interest',
|
||||
specific_qualified: 'Specific Qualified',
|
||||
};
|
||||
|
||||
function humanizeEnum(raw: string): string {
|
||||
const override = LABEL_OVERRIDES[raw.toLowerCase()];
|
||||
if (override) return override;
|
||||
return raw
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function toSelectOptions<T extends readonly string[]>(
|
||||
values: T,
|
||||
): Array<{ value: T[number]; label: T[number] }> {
|
||||
return values.map((v) => ({ value: v, label: v }));
|
||||
): Array<{ value: T[number]; label: string }> {
|
||||
return values.map((v) => ({ value: v, label: humanizeEnum(v) }));
|
||||
}
|
||||
|
||||
// ─── Lead Categories ─────────────────────────────────────────────────────────
|
||||
@@ -181,6 +209,34 @@ export const LEAD_CATEGORIES = ['general_interest', 'specific_qualified', 'hot_l
|
||||
|
||||
export type LeadCategory = (typeof LEAD_CATEGORIES)[number];
|
||||
|
||||
// ─── Sources (interests + clients + residential) ─────────────────────────────
|
||||
// Single source of truth for the source dropdown. Keep these in lockstep
|
||||
// across forms, inline-edit selects, list-column labels and chart bucketing
|
||||
// so values written from one surface render with the same label on another.
|
||||
|
||||
export const SOURCES = [
|
||||
{ value: 'website', label: 'Website' },
|
||||
{ value: 'manual', label: 'Manual' },
|
||||
{ value: 'referral', label: 'Referral' },
|
||||
{ value: 'broker', label: 'Broker' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
] as const;
|
||||
|
||||
export type SourceValue = (typeof SOURCES)[number]['value'];
|
||||
|
||||
export const SOURCE_LABELS: Record<SourceValue, string> = SOURCES.reduce(
|
||||
(acc, s) => ({ ...acc, [s.value]: s.label }),
|
||||
{} as Record<SourceValue, string>,
|
||||
);
|
||||
|
||||
/** Returns the canonical label for a stored source value, falling back to a
|
||||
* Title-Case rendering of the raw string for legacy / free-text values. */
|
||||
export function formatSource(source: string | null | undefined): string | null {
|
||||
if (!source) return null;
|
||||
if (source in SOURCE_LABELS) return SOURCE_LABELS[source as SourceValue];
|
||||
return source.charAt(0).toUpperCase() + source.slice(1);
|
||||
}
|
||||
|
||||
// ─── Document Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export const DOCUMENT_TYPES = ['eoi', 'contract', 'nda', 'reservation_agreement', 'other'] as const;
|
||||
|
||||
@@ -165,6 +165,13 @@ export type UserPreferences = {
|
||||
country?: string;
|
||||
/** Keyed by entity type: `clients`, `yachts`, `interests`, etc. */
|
||||
tablePreferences?: Record<string, TablePreferences>;
|
||||
/**
|
||||
* Dashboard widget visibility, keyed by widget id from the registry
|
||||
* in `src/components/dashboard/widget-registry.ts`. Missing keys fall
|
||||
* back to `defaultVisible` from the registry — so adding a new widget
|
||||
* surfaces it for everyone without a migration. `false` hides it.
|
||||
*/
|
||||
dashboardWidgets?: Record<string, boolean>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { buildListQuery } from '@/lib/db/query-builder';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import { setEntityTags } from '@/lib/services/entity-tags.helper';
|
||||
import { getPortBerthsDefaultCurrency } from '@/lib/services/port-config';
|
||||
import { ConflictError } from '@/lib/errors';
|
||||
import { sortByMooring } from '@/lib/utils/mooring-sort';
|
||||
import type {
|
||||
CreateBerthInput,
|
||||
UpdateBerthInput,
|
||||
@@ -487,6 +489,12 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
|
||||
throw new ConflictError(`Berth "${data.mooringNumber}" already exists in this port`);
|
||||
}
|
||||
|
||||
// Caller-specified currency wins; otherwise inherit the port's admin-
|
||||
// configured default (system_settings.berths_default_currency, USD if
|
||||
// unset). Lets a multi-currency portfolio be modelled cleanly without
|
||||
// forcing reps to pick a currency on every new-berth form.
|
||||
const resolvedCurrency = data.priceCurrency ?? (await getPortBerthsDefaultCurrency(portId));
|
||||
|
||||
const [berth] = await db
|
||||
.insert(berths)
|
||||
.values({
|
||||
@@ -501,7 +509,7 @@ export async function createBerth(portId: string, data: CreateBerthInput, meta:
|
||||
draftFt: data.draftFt?.toString(),
|
||||
draftM: data.draftM?.toString(),
|
||||
price: data.price?.toString(),
|
||||
priceCurrency: data.priceCurrency ?? 'USD',
|
||||
priceCurrency: resolvedCurrency,
|
||||
tenureType: data.tenureType ?? 'permanent',
|
||||
mooringType: data.mooringType,
|
||||
powerCapacity: data.powerCapacity?.toString(),
|
||||
@@ -563,7 +571,10 @@ export async function deleteBerth(id: string, portId: string, meta: AuditMeta) {
|
||||
// ─── Options ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getBerthOptions(portId: string) {
|
||||
return db
|
||||
// DB-side `ORDER BY mooring_number` is lexicographic (A1, A10, A11, A2…).
|
||||
// Natural-sort in JS so dropdowns surface them as reps read them: A1, A2,
|
||||
// …, A10, A11. See compareMooringNumbers for the prefix/index split.
|
||||
const rows = await db
|
||||
.select({
|
||||
id: berths.id,
|
||||
mooringNumber: berths.mooringNumber,
|
||||
@@ -571,6 +582,6 @@ export async function getBerthOptions(portId: string) {
|
||||
status: berths.status,
|
||||
})
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, portId))
|
||||
.orderBy(berths.mooringNumber);
|
||||
.where(eq(berths.portId, portId));
|
||||
return sortByMooring(rows, (r) => r.mooringNumber);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import type {
|
||||
ListCompaniesInput,
|
||||
} from '@/lib/validators/companies';
|
||||
|
||||
type CreateCompanyInput = z.input<typeof createCompanySchema>;
|
||||
type CreateCompanyInput = z.output<typeof createCompanySchema>;
|
||||
|
||||
export type { Company };
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { and, count, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { and, count, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { companies } from '@/lib/db/schema/companies';
|
||||
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { invoices, expenses } from '@/lib/db/schema/financial';
|
||||
import { documents } from '@/lib/db/schema/documents';
|
||||
import { reminders } from '@/lib/db/schema/operations';
|
||||
import { systemSettings, auditLogs } from '@/lib/db/schema/system';
|
||||
import { PIPELINE_STAGES, STAGE_WEIGHTS } from '@/lib/constants';
|
||||
|
||||
@@ -156,6 +161,119 @@ export async function getRevenueForecast(portId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Compact widget queries ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Berth status split for the donut widget. Returns counts plus the total
|
||||
* so the chart can show "12 of 47 sold" alongside the segment percentage.
|
||||
*/
|
||||
export async function getBerthStatusDistribution(portId: string) {
|
||||
const rows = await db
|
||||
.select({ status: berths.status, c: sql<number>`count(*)::int` })
|
||||
.from(berths)
|
||||
.where(eq(berths.portId, portId))
|
||||
.groupBy(berths.status);
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const r of rows) counts[r.status] = r.c;
|
||||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
available: counts['available'] ?? 0,
|
||||
underOffer: counts['under_offer'] ?? 0,
|
||||
sold: counts['sold'] ?? 0,
|
||||
maintenance: counts['maintenance'] ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Top 5 active interests closest to closing — ranked by pipeline stage
|
||||
* (further = closer to closing) with most-recent activity as a
|
||||
* tiebreaker. Surfaces the deals reps should actually be chasing on the
|
||||
* dashboard without making them open the pipeline board.
|
||||
*/
|
||||
export async function getHotDeals(portId: string, limit = 5) {
|
||||
// Stage rank: bigger = closer to closing.
|
||||
const rank = sql<number>`CASE ${interests.pipelineStage}
|
||||
WHEN 'completed' THEN 8
|
||||
WHEN 'contract_signed' THEN 7
|
||||
WHEN 'contract_sent' THEN 6
|
||||
WHEN 'deposit_10' THEN 5
|
||||
WHEN 'eoi_signed' THEN 4
|
||||
WHEN 'eoi_sent' THEN 3
|
||||
WHEN 'in_comms' THEN 2
|
||||
WHEN 'details_sent' THEN 1
|
||||
ELSE 0
|
||||
END`;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: interests.id,
|
||||
stage: interests.pipelineStage,
|
||||
clientName: clients.fullName,
|
||||
mooring: berths.mooringNumber,
|
||||
lastContact: interests.dateLastContact,
|
||||
updatedAt: interests.updatedAt,
|
||||
rank,
|
||||
})
|
||||
.from(interests)
|
||||
.innerJoin(clients, eq(interests.clientId, clients.id))
|
||||
.leftJoin(
|
||||
interestBerths,
|
||||
and(eq(interestBerths.interestId, interests.id), eq(interestBerths.isPrimary, true)),
|
||||
)
|
||||
.leftJoin(berths, eq(interestBerths.berthId, berths.id))
|
||||
.where(
|
||||
and(
|
||||
eq(interests.portId, portId),
|
||||
isNull(interests.archivedAt),
|
||||
isNull(interests.outcome), // exclude won/lost — they're not "closing"
|
||||
),
|
||||
)
|
||||
.orderBy(desc(rank), desc(interests.updatedAt))
|
||||
.limit(limit);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
stage: r.stage,
|
||||
clientName: r.clientName,
|
||||
mooringNumber: r.mooring,
|
||||
lastContact: r.lastContact ? r.lastContact.toISOString() : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-conversion breakdown for the marketing widget. Returns per-
|
||||
* source totals (active + won + lost) and a derived conversion rate so
|
||||
* reps see which channels deliver buyers vs tire-kickers — orthogonal
|
||||
* to the existing "lead source attribution" chart which only counts
|
||||
* inbound volume.
|
||||
*/
|
||||
export async function getSourceConversion(portId: string) {
|
||||
const rows = await db
|
||||
.select({
|
||||
source: interests.source,
|
||||
total: sql<number>`count(*)::int`,
|
||||
won: sql<number>`sum(case when ${interests.outcome} = 'won' then 1 else 0 end)::int`,
|
||||
lost: sql<number>`sum(case when ${interests.outcome} = 'lost' then 1 else 0 end)::int`,
|
||||
})
|
||||
.from(interests)
|
||||
.where(and(eq(interests.portId, portId), isNull(interests.archivedAt)))
|
||||
.groupBy(interests.source);
|
||||
|
||||
return rows
|
||||
.filter((r) => r.source)
|
||||
.map((r) => ({
|
||||
source: r.source!,
|
||||
total: r.total,
|
||||
won: r.won,
|
||||
lost: r.lost,
|
||||
conversionRate: r.total > 0 ? r.won / r.total : 0,
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
}
|
||||
|
||||
// ─── Recent Activity ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function getRecentActivity(portId: string, limit = 20) {
|
||||
@@ -166,6 +284,9 @@ export async function getRecentActivity(portId: string, limit = 20) {
|
||||
entityType: auditLogs.entityType,
|
||||
entityId: auditLogs.entityId,
|
||||
userId: auditLogs.userId,
|
||||
fieldChanged: auditLogs.fieldChanged,
|
||||
oldValue: auditLogs.oldValue,
|
||||
newValue: auditLogs.newValue,
|
||||
metadata: auditLogs.metadata,
|
||||
createdAt: auditLogs.createdAt,
|
||||
})
|
||||
@@ -174,5 +295,121 @@ export async function getRecentActivity(portId: string, limit = 20) {
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
return rows;
|
||||
// Resolve a human label per row (client name, yacht name, invoice number,
|
||||
// …). The dashboard widget previously rendered the bare UUID prefix which
|
||||
// told reps nothing about which entity was touched. We batch one SELECT
|
||||
// per entityType, capping at the row set's natural size (<= `limit`).
|
||||
const byType = new Map<string, Set<string>>();
|
||||
for (const r of rows) {
|
||||
if (!r.entityId) continue;
|
||||
if (!byType.has(r.entityType)) byType.set(r.entityType, new Set());
|
||||
byType.get(r.entityType)!.add(r.entityId);
|
||||
}
|
||||
|
||||
const labels = new Map<string, string>(); // `${type}:${id}` → label
|
||||
|
||||
async function loadLabels<T extends { id: string }>(
|
||||
type: string,
|
||||
fetcher: (ids: string[]) => Promise<T[]>,
|
||||
pick: (row: T) => string,
|
||||
) {
|
||||
const ids = Array.from(byType.get(type) ?? []);
|
||||
if (ids.length === 0) return;
|
||||
const fetched = await fetcher(ids);
|
||||
for (const row of fetched) labels.set(`${type}:${row.id}`, pick(row));
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadLabels(
|
||||
'client',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: clients.id, name: clients.fullName })
|
||||
.from(clients)
|
||||
.where(and(eq(clients.portId, portId), inArray(clients.id, ids))),
|
||||
(r) => r.name,
|
||||
),
|
||||
loadLabels(
|
||||
'yacht',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: yachts.id, name: yachts.name })
|
||||
.from(yachts)
|
||||
.where(and(eq(yachts.portId, portId), inArray(yachts.id, ids))),
|
||||
(r) => r.name,
|
||||
),
|
||||
loadLabels(
|
||||
'company',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: companies.id, name: companies.name })
|
||||
.from(companies)
|
||||
.where(and(eq(companies.portId, portId), inArray(companies.id, ids))),
|
||||
(r) => r.name,
|
||||
),
|
||||
loadLabels(
|
||||
'interest',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: interests.id, clientName: clients.fullName })
|
||||
.from(interests)
|
||||
.innerJoin(clients, eq(interests.clientId, clients.id))
|
||||
.where(and(eq(interests.portId, portId), inArray(interests.id, ids))),
|
||||
(r) => r.clientName,
|
||||
),
|
||||
loadLabels(
|
||||
'berth',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: berths.id, mooring: berths.mooringNumber })
|
||||
.from(berths)
|
||||
.where(and(eq(berths.portId, portId), inArray(berths.id, ids))),
|
||||
(r) => `Berth ${r.mooring}`,
|
||||
),
|
||||
loadLabels(
|
||||
'invoice',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: invoices.id, num: invoices.invoiceNumber })
|
||||
.from(invoices)
|
||||
.where(and(eq(invoices.portId, portId), inArray(invoices.id, ids))),
|
||||
(r) => r.num,
|
||||
),
|
||||
loadLabels(
|
||||
'expense',
|
||||
(ids) =>
|
||||
db
|
||||
.select({
|
||||
id: expenses.id,
|
||||
desc: expenses.description,
|
||||
vendor: expenses.establishmentName,
|
||||
})
|
||||
.from(expenses)
|
||||
.where(and(eq(expenses.portId, portId), inArray(expenses.id, ids))),
|
||||
(r) => r.desc ?? r.vendor ?? 'Expense',
|
||||
),
|
||||
loadLabels(
|
||||
'document',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: documents.id, title: documents.title })
|
||||
.from(documents)
|
||||
.where(and(eq(documents.portId, portId), inArray(documents.id, ids))),
|
||||
(r) => r.title,
|
||||
),
|
||||
loadLabels(
|
||||
'reminder',
|
||||
(ids) =>
|
||||
db
|
||||
.select({ id: reminders.id, title: reminders.title })
|
||||
.from(reminders)
|
||||
.where(and(eq(reminders.portId, portId), inArray(reminders.id, ids))),
|
||||
(r) => r.title,
|
||||
),
|
||||
]);
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
label: r.entityId ? (labels.get(`${r.entityType}:${r.entityId}`) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -778,13 +778,16 @@ export async function changeInterestStage(
|
||||
.where(and(eq(interests.id, id), eq(interests.portId, portId)))
|
||||
.returning();
|
||||
|
||||
// BR-133: Auto-populate milestones based on stage
|
||||
// BR-133: Auto-populate milestones based on stage. The rep can override the
|
||||
// stamp via `milestoneDate` when they're back-dating a real event (e.g.
|
||||
// "deposit landed yesterday"); we still default to now when omitted.
|
||||
const milestoneDate = data.milestoneDate ? new Date(data.milestoneDate) : new Date();
|
||||
const milestoneUpdates: Record<string, unknown> = {};
|
||||
if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = new Date();
|
||||
if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = new Date();
|
||||
if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = new Date();
|
||||
if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = new Date();
|
||||
if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = new Date();
|
||||
if (data.pipelineStage === 'eoi_sent') milestoneUpdates.dateEoiSent = milestoneDate;
|
||||
if (data.pipelineStage === 'eoi_signed') milestoneUpdates.dateEoiSigned = milestoneDate;
|
||||
if (data.pipelineStage === 'deposit_10pct') milestoneUpdates.dateDepositReceived = milestoneDate;
|
||||
if (data.pipelineStage === 'contract_sent') milestoneUpdates.dateContractSent = milestoneDate;
|
||||
if (data.pipelineStage === 'contract_signed') milestoneUpdates.dateContractSigned = milestoneDate;
|
||||
if (Object.keys(milestoneUpdates).length > 0) {
|
||||
await db
|
||||
.update(interests)
|
||||
|
||||
@@ -101,6 +101,9 @@ export const SETTING_KEYS = {
|
||||
reminderDigestEnabled: 'reminder_digest_enabled',
|
||||
reminderDigestTime: 'reminder_digest_time',
|
||||
reminderDigestTimezone: 'reminder_digest_timezone',
|
||||
|
||||
// Berths
|
||||
berthsDefaultCurrency: 'berths_default_currency',
|
||||
} as const;
|
||||
|
||||
// ─── Helper ──────────────────────────────────────────────────────────────────
|
||||
@@ -442,6 +445,16 @@ const DEFAULT_REMINDER: PortReminderConfig = {
|
||||
digestTimezone: 'Europe/Warsaw',
|
||||
};
|
||||
|
||||
/**
|
||||
* Port-level default currency for newly-created berths. Per-berth
|
||||
* `priceCurrency` overrides this when set. Defaults to USD because
|
||||
* 95% of marinas in the rollout target are USD-denominated.
|
||||
*/
|
||||
export async function getPortBerthsDefaultCurrency(portId: string): Promise<string> {
|
||||
const value = await readSetting<string>(SETTING_KEYS.berthsDefaultCurrency, portId);
|
||||
return (value ?? 'USD').trim().toUpperCase() || 'USD';
|
||||
}
|
||||
|
||||
export async function getPortReminderConfig(portId: string): Promise<PortReminderConfig> {
|
||||
const [defaultDays, defaultEnabled, digestEnabled, digestTime, digestTimezone] =
|
||||
await Promise.all([
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,8 +42,18 @@ export function formatCurrency(
|
||||
if (amount === null || amount === undefined || amount === '') return '';
|
||||
const value = typeof amount === 'number' ? amount : Number(amount);
|
||||
if (!Number.isFinite(value)) return '';
|
||||
const code = (currency ?? 'USD').toUpperCase();
|
||||
const { locale = 'en-US', minFractionDigits = 2, maxFractionDigits = 2 } = options;
|
||||
// Defensive: trim + uppercase + fallback to USD when blank. Legacy
|
||||
// NocoDB rows sometimes contain whitespace/empty strings rather than
|
||||
// proper ISO codes, which would make Intl.NumberFormat throw.
|
||||
const rawCode = (currency ?? 'USD').trim().toUpperCase();
|
||||
const code = rawCode === '' ? 'USD' : rawCode;
|
||||
// If the caller only specifies max (common: maxFractionDigits: 0 for
|
||||
// whole-currency display), clamp min down to match — otherwise
|
||||
// `toLocaleString` throws "Computed minimumFractionDigits is larger
|
||||
// than maximumFractionDigits". The same defensive clamp protects
|
||||
// Intl.NumberFormat too.
|
||||
const { locale = 'en-US', maxFractionDigits = 2 } = options;
|
||||
const minFractionDigits = options.minFractionDigits ?? Math.min(2, maxFractionDigits);
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
@@ -52,10 +62,15 @@ export function formatCurrency(
|
||||
maximumFractionDigits: maxFractionDigits,
|
||||
}).format(value);
|
||||
} catch {
|
||||
// Unknown currency code — degrade to a bare number with the code
|
||||
// appended rather than throwing. Keeps display robust against any
|
||||
// legacy NocoDB rows that smuggled non-ISO strings into the column.
|
||||
return `${value.toFixed(maxFractionDigits)} ${code}`;
|
||||
// Unknown currency code — format the number with thousand
|
||||
// separators (still useful) and append the code as a suffix rather
|
||||
// than letting Intl crash the render. Keeps display robust against
|
||||
// any legacy NocoDB rows that smuggled non-ISO strings.
|
||||
const formatted = value.toLocaleString(locale, {
|
||||
minimumFractionDigits: minFractionDigits,
|
||||
maximumFractionDigits: maxFractionDigits,
|
||||
});
|
||||
return `${formatted} ${code}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
27
src/lib/utils/mooring-sort.ts
Normal file
27
src/lib/utils/mooring-sort.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Natural-sort comparator for mooring numbers (`^[A-Z]+\d+$`, e.g. A1, B12,
|
||||
* E18). Plain alphabetic sort orders them A1, A10, A11, A2 because '1' < '2'
|
||||
* in lexicographic terms; this comparator splits the prefix from the index so
|
||||
* A2 lands before A10 the way reps expect.
|
||||
*
|
||||
* Stable across the CRM: use this anywhere mooring numbers are presented in
|
||||
* a list (dropdowns, table sorts, public-map ordering, berth options).
|
||||
*/
|
||||
const MOORING_PATTERN = /^([A-Z]+)(\d+)$/;
|
||||
|
||||
export function compareMooringNumbers(a: string, b: string): number {
|
||||
const ma = MOORING_PATTERN.exec(a);
|
||||
const mb = MOORING_PATTERN.exec(b);
|
||||
// Fall back to plain locale compare for anything that doesn't match the
|
||||
// canonical shape so we never throw on unexpected input.
|
||||
if (!ma || !mb) return a.localeCompare(b);
|
||||
const [, prefixA, numA] = ma;
|
||||
const [, prefixB, numB] = mb;
|
||||
if (prefixA !== prefixB) return (prefixA ?? '').localeCompare(prefixB ?? '');
|
||||
return Number(numA) - Number(numB);
|
||||
}
|
||||
|
||||
/** Sort an array of objects by their mooring-number field, in place safe. */
|
||||
export function sortByMooring<T>(items: T[], pick: (item: T) => string): T[] {
|
||||
return [...items].sort((a, b) => compareMooringNumbers(pick(a), pick(b)));
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import { z } from 'zod';
|
||||
import { baseListQuerySchema } from '@/lib/api/list-query';
|
||||
import { optionalCountryIsoSchema, optionalSubdivisionIsoSchema } from '@/lib/validators/i18n';
|
||||
|
||||
// react-hook-form posts empty strings for unfilled inputs; treat those
|
||||
// as "not provided" before `.email()` / `.date()` validators fire.
|
||||
const emptyToUndef = (v: unknown) => (v === '' ? undefined : v);
|
||||
|
||||
export const createCompanySchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
legalName: z.string().optional(),
|
||||
@@ -11,9 +15,9 @@ export const createCompanySchema = z.object({
|
||||
incorporationCountryIso: optionalCountryIsoSchema.optional(),
|
||||
/** ISO 3166-2 state/province of incorporation. */
|
||||
incorporationSubdivisionIso: optionalSubdivisionIsoSchema.optional(),
|
||||
incorporationDate: z.coerce.date().optional(),
|
||||
incorporationDate: z.preprocess(emptyToUndef, z.coerce.date().optional()),
|
||||
status: z.enum(['active', 'dissolved']).optional().default('active'),
|
||||
billingEmail: z.string().email().optional(),
|
||||
billingEmail: z.preprocess(emptyToUndef, z.string().email().optional()),
|
||||
notes: z.string().optional(),
|
||||
tagIds: z.array(z.string()).optional().default([]),
|
||||
});
|
||||
|
||||
@@ -59,6 +59,14 @@ export const changeStageSchema = z.object({
|
||||
* to hold the `interests.override_stage` permission. Reason becomes
|
||||
* required when override=true (recorded in the audit log). */
|
||||
override: z.boolean().optional(),
|
||||
/** Optional ISO date (YYYY-MM-DD or full ISO timestamp) to stamp on the
|
||||
* matching milestone column instead of "now". Used when a rep marks a
|
||||
* milestone manually (e.g. deposit received yesterday) so the recorded
|
||||
* date reflects the real event instead of the click time. */
|
||||
milestoneDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}(T.*)?$/)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ─── Outcome (Won / Lost) ─────────────────────────────────────────────────────
|
||||
@@ -68,6 +76,7 @@ export const INTEREST_OUTCOMES = [
|
||||
'lost_other_marina',
|
||||
'lost_unqualified',
|
||||
'lost_no_response',
|
||||
'lost_other',
|
||||
'cancelled',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -15,6 +15,13 @@ export const updateUserPreferencesSchema = z.object({
|
||||
locale: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
reminders: reminderPreferencesSchema.optional(),
|
||||
/**
|
||||
* Widget id → visible flag. Persists which dashboard cards the user
|
||||
* wants to see; missing ids fall back to registry defaults. Kept loose
|
||||
* (record-of-bool) so adding a new widget doesn't require a validator
|
||||
* update.
|
||||
*/
|
||||
dashboardWidgets: z.record(z.string(), z.boolean()).optional(),
|
||||
});
|
||||
|
||||
export type UpdateUserPreferencesInput = z.infer<typeof updateUserPreferencesSchema>;
|
||||
|
||||
@@ -6,6 +6,15 @@ export const ownerRefSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
// Numeric columns on the yachts table accept a stringified decimal or
|
||||
// null. The form posts empty strings for unfilled fields, which Postgres
|
||||
// rejects with `invalid input syntax for type numeric: ""`. Strip empty
|
||||
// strings here so the service can confidently `?? null` them.
|
||||
const optionalNumericString = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v === '' || v === undefined ? undefined : v));
|
||||
|
||||
export const createYachtSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
hullNumber: z.string().optional(),
|
||||
@@ -15,12 +24,12 @@ export const createYachtSchema = z.object({
|
||||
builder: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
hullMaterial: z.string().optional(),
|
||||
lengthFt: z.string().optional(),
|
||||
widthFt: z.string().optional(),
|
||||
draftFt: z.string().optional(),
|
||||
lengthM: z.string().optional(),
|
||||
widthM: z.string().optional(),
|
||||
draftM: z.string().optional(),
|
||||
lengthFt: optionalNumericString,
|
||||
widthFt: optionalNumericString,
|
||||
draftFt: optionalNumericString,
|
||||
lengthM: optionalNumericString,
|
||||
widthM: optionalNumericString,
|
||||
draftM: optionalNumericString,
|
||||
owner: ownerRefSchema, // required; yacht must have an owner
|
||||
status: z.enum(['active', 'retired', 'sold_away']).optional().default('active'),
|
||||
notes: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user