feat(interests): EOI/contract/reservation tabs + contact log + berth interest milestone + interest list overhaul
Major interest workflow expansion driven by the rapid-fire UX session.
EOI / Contract / Reservation tabs replace the generic Documents tab when
the deal is at the relevant stage — workspace pattern with active-doc
hero, signing progress, paper-signed upload, and history strip. Stage-
conditional visibility wired through interest-tabs.tsx so the tab set
shrinks/expands as the deal moves through the pipeline.
Contact log: per-interaction structured log (channel/direction/summary/
optional follow-up reminder). New `interest_contact_log` table + service
+ tab UI (timeline with channel-coded icons + compose dialog).
auto-creates a reminder when followUpAt is set.
Berth Interest milestone: first milestone in the OverviewTab's pipeline
strip, completes the moment any berth is linked via the junction. Drives
the "have we captured what they want?" sanity check for general_interest
leads before they move to EOI.
Stage-conditional milestones: past phases collapse into a one-liner
strip, current phase expands, future phases hide behind a "Show
upcoming" toggle. Inline stage picker now defers reason capture to an
override-confirm view (only required for illegal transitions, not the
default flow).
Notes blob → threaded: dropped `interests.notes` column entirely; the
threaded `interest_notes` table is the single source of truth. Latest-
note teaser on Overview links into the dedicated Notes tab. Polymorphic
notes service gains aggregated client view (unions client + interest +
yacht notes with source chips and group-by-source toggle).
Berth interest list overhaul:
- Configurable columns via ColumnPicker (18 toggleable, 5 default-on)
- Natural-sort SQL ORDER BY on mooring number (A1, A2, A10 not A10, A2)
- Per-letter row tinting via colored left-border accent + dot in cell
- Documents tab merged Files (single attachments section)
Topbar improvements:
- Always-visible back arrow on detail pages (path depth > 2)
- Breadcrumb-hint store + useBreadcrumbHint hook so detail pages can
push their entity hierarchy (Clients › Mary Smith › Interest › B17)
- Tighter spacing, softer separators, 160px crumb truncation
DataTable upgrades:
- Page-size selector with All option (validator cap raised to 1000)
- getRowClassName slot for per-row styling (used by berth tinting)
- Fixed Radix SelectItem crash on empty-string values via __any__
sentinel (was crashing every list page that opened a select filter)
Interest list:
- Configurable columns picker
- Stage cell clickable into detail
- TagPicker + SavedViewsDropdown sized h-8 to match adjacent buttons
- Save view moved into ColumnPicker menu; Views button hidden when
no views are saved
- Pipeline kanban board endpoint at /api/v1/interests/board with
minimal projection, 5000-row cap + truncated banner, filter
pass-through
Mobile chrome + sidebar collapse removed (always-expanded design choice).
User management lists super-admins (was inner-joined on user_port_roles
which excluded global super-admins).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -84,8 +84,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
|
||||
const ids = result.data.map((r) => r.id);
|
||||
|
||||
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows] = await Promise.all(
|
||||
[
|
||||
const [yachtCounts, companyCounts, interestRows, interestCounts, contactRows, linkedBerthRows] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ ownerId: yachts.currentOwnerId, count: count() })
|
||||
.from(yachts)
|
||||
@@ -148,22 +148,60 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
clientId: string;
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164: string | null;
|
||||
isPrimary: boolean;
|
||||
createdAt: Date;
|
||||
}>(sql`
|
||||
SELECT DISTINCT ON (client_id, channel)
|
||||
client_id AS "clientId",
|
||||
client_id AS "clientId",
|
||||
channel,
|
||||
value,
|
||||
is_primary AS "isPrimary",
|
||||
created_at AS "createdAt"
|
||||
value_e164 AS "valueE164",
|
||||
is_primary AS "isPrimary",
|
||||
created_at AS "createdAt"
|
||||
FROM client_contacts
|
||||
WHERE ${inArray(clientContacts.clientId, ids)}
|
||||
AND channel IN ('email', 'phone')
|
||||
ORDER BY client_id, channel, is_primary DESC, created_at DESC
|
||||
`),
|
||||
],
|
||||
);
|
||||
// Berths each client has interests in, with the (most-active)
|
||||
// interest's stage attached so the list-view chip can self-describe
|
||||
// ("E17 · EOI sent") AND deep-link to the interest. DISTINCT ON
|
||||
// collapses (client, berth) when the client has had multiple
|
||||
// historical interests in the same berth — we keep the open-outcome
|
||||
// one if any, otherwise the most recently updated. Excludes archived
|
||||
// interests so closed deals don't crowd the chip row.
|
||||
db.execute<{
|
||||
clientId: string;
|
||||
berthId: string;
|
||||
mooringNumber: string;
|
||||
interestId: string;
|
||||
pipelineStage: string;
|
||||
outcome: string | null;
|
||||
}>(sql`
|
||||
SELECT DISTINCT ON (i.client_id, b.id)
|
||||
i.client_id AS "clientId",
|
||||
b.id AS "berthId",
|
||||
b.mooring_number AS "mooringNumber",
|
||||
i.id AS "interestId",
|
||||
i.pipeline_stage AS "pipelineStage",
|
||||
i.outcome
|
||||
FROM interests i
|
||||
JOIN interest_berths ib ON ib.interest_id = i.id
|
||||
JOIN berths b ON b.id = ib.berth_id
|
||||
WHERE i.port_id = ${portId}
|
||||
AND i.client_id IN (${sql.join(
|
||||
ids.map((id) => sql`${id}`),
|
||||
sql`, `,
|
||||
)})
|
||||
AND i.archived_at IS NULL
|
||||
ORDER BY
|
||||
i.client_id,
|
||||
b.id,
|
||||
CASE WHEN i.outcome IS NULL THEN 0 ELSE 1 END,
|
||||
i.updated_at DESC
|
||||
`),
|
||||
]);
|
||||
|
||||
const yachtCountMap = new Map(yachtCounts.map((r) => [r.ownerId, r.count]));
|
||||
const companyCountMap = new Map(companyCounts.map((r) => [r.clientId, r.count]));
|
||||
@@ -182,12 +220,16 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
// Pick the per-client primary email + phone. The SQL DISTINCT ON
|
||||
// returns at most one row per (clientId, channel); the result is
|
||||
// already the picker's "is_primary desc, created_at desc" choice.
|
||||
// We also keep the E.164 form of the phone so the UI can build a
|
||||
// wa.me/<digits> link that doesn't need re-parsing.
|
||||
const primaryEmailMap = new Map<string, string>();
|
||||
const primaryPhoneMap = new Map<string, string>();
|
||||
const primaryPhoneE164Map = new Map<string, string>();
|
||||
type ContactRow = {
|
||||
clientId: string;
|
||||
channel: string;
|
||||
value: string;
|
||||
valueE164: string | null;
|
||||
isPrimary: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
@@ -195,7 +237,66 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
(contactRows as { rows?: ContactRow[] }).rows ?? (contactRows as unknown as ContactRow[]);
|
||||
for (const c of contactRowList) {
|
||||
if (c.channel === 'email') primaryEmailMap.set(c.clientId, c.value);
|
||||
else if (c.channel === 'phone') primaryPhoneMap.set(c.clientId, c.value);
|
||||
else if (c.channel === 'phone') {
|
||||
primaryPhoneMap.set(c.clientId, c.value);
|
||||
if (c.valueE164) primaryPhoneE164Map.set(c.clientId, c.valueE164);
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate berths per client, sorted so the most-action-worthy
|
||||
// interest floats to the top of the chip row. Priority:
|
||||
// 1. open outcome (active deal) before closed (won/lost/cancelled)
|
||||
// 2. within open: most progressed stage first (contract_signed > … > open)
|
||||
// 3. tie-breaker: mooring number alphabetical for stable ordering
|
||||
// The list-view UI shows the top 2 with full labels; the rest fall
|
||||
// through into a "+N more" popover.
|
||||
const stageRank: Record<string, number> = {
|
||||
contract_signed: 1,
|
||||
deposit_10pct: 2,
|
||||
contract_sent: 3,
|
||||
eoi_signed: 4,
|
||||
eoi_sent: 5,
|
||||
in_communication: 6,
|
||||
details_sent: 7,
|
||||
qualified: 8,
|
||||
open: 9,
|
||||
completed: 10,
|
||||
};
|
||||
type LinkedBerth = {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
interestId: string;
|
||||
stage: string;
|
||||
outcome: string | null;
|
||||
};
|
||||
const linkedBerthsMap = new Map<string, LinkedBerth[]>();
|
||||
type LinkedBerthRow = typeof linkedBerthRows extends Iterable<infer T> ? T : never;
|
||||
const linkedBerthList: LinkedBerthRow[] =
|
||||
(linkedBerthRows as { rows?: LinkedBerthRow[] }).rows ??
|
||||
(linkedBerthRows as unknown as LinkedBerthRow[]);
|
||||
for (const r of linkedBerthList) {
|
||||
const list = linkedBerthsMap.get(r.clientId) ?? [];
|
||||
list.push({
|
||||
id: r.berthId,
|
||||
mooringNumber: r.mooringNumber,
|
||||
interestId: r.interestId,
|
||||
stage: r.pipelineStage,
|
||||
outcome: r.outcome,
|
||||
});
|
||||
linkedBerthsMap.set(r.clientId, list);
|
||||
}
|
||||
for (const list of linkedBerthsMap.values()) {
|
||||
list.sort((a, b) => {
|
||||
// Open before closed.
|
||||
const openA = a.outcome === null ? 0 : 1;
|
||||
const openB = b.outcome === null ? 0 : 1;
|
||||
if (openA !== openB) return openA - openB;
|
||||
// Within bucket, most-progressed stage first.
|
||||
const rankA = stageRank[a.stage] ?? 99;
|
||||
const rankB = stageRank[b.stage] ?? 99;
|
||||
if (rankA !== rankB) return rankA - rankB;
|
||||
return a.mooringNumber.localeCompare(b.mooringNumber);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -209,6 +310,8 @@ export async function listClients(portId: string, query: ListClientsInput) {
|
||||
interestCount: interestCountMap.get(row.id) ?? 0,
|
||||
primaryEmail: primaryEmailMap.get(row.id) ?? null,
|
||||
primaryPhone: primaryPhoneMap.get(row.id) ?? null,
|
||||
primaryPhoneE164: primaryPhoneE164Map.get(row.id) ?? null,
|
||||
linkedBerths: linkedBerthsMap.get(row.id) ?? [],
|
||||
latestInterest: latest
|
||||
? {
|
||||
stage: latest.stage,
|
||||
|
||||
Reference in New Issue
Block a user