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:
@@ -131,6 +131,128 @@ async function resolveLeadCategory(
|
||||
return leadCategory ?? undefined;
|
||||
}
|
||||
|
||||
// ─── Board (kanban) ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Soft cap on board rows. The kanban legitimately needs every active
|
||||
* interest in one shot — paginating would split deals across pages and
|
||||
* break drag-drop semantics — but unbounded SELECTs are a footgun if a
|
||||
* port suddenly has tens of thousands of stale interests. At 5000 the
|
||||
* payload is still well under a megabyte (≈50 bytes per minimal row),
|
||||
* and any port near that ceiling needs virtualization in the kanban UI
|
||||
* anyway, so failing loud here is the right escalation.
|
||||
*/
|
||||
const BOARD_MAX_ROWS = 5000;
|
||||
|
||||
export interface BoardInterestRow {
|
||||
id: string;
|
||||
clientName: string | null;
|
||||
berthMooringNumber: string | null;
|
||||
leadCategory: string | null;
|
||||
pipelineStage: string;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface BoardFilters {
|
||||
/** Free-text search against client name. */
|
||||
search?: string;
|
||||
leadCategory?: string;
|
||||
source?: string;
|
||||
eoiStatus?: string;
|
||||
/** Tag IDs the interest must be tagged with (any-of). */
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal-projection list for the kanban board. Skips the validator's
|
||||
* `max(100)` page cap since the board renders the entire pipeline at
|
||||
* once. Returns only the fields PipelineCard renders — no tags-list, no
|
||||
* notes-count, no EOI status badges, no urgency joins. Always filters
|
||||
* out archived interests (the kanban is for active deals; the list view
|
||||
* has the includeArchived toggle for history).
|
||||
*
|
||||
* Filters are intentionally a SUBSET of listInterests — `pipelineStage`
|
||||
* is omitted because the columns ARE the stages, and `includeArchived`
|
||||
* is omitted because the kanban shouldn't surface archived deals.
|
||||
*
|
||||
* One round-trip for the interests + clientName join, one batched
|
||||
* round-trip via getPrimaryBerthsForInterests for the mooring numbers,
|
||||
* and one batched lookup for tag-id filtering when supplied.
|
||||
*/
|
||||
export async function listInterestsForBoard(
|
||||
portId: string,
|
||||
filters: BoardFilters = {},
|
||||
): Promise<{ data: BoardInterestRow[]; truncated: boolean; total: number }> {
|
||||
const conditions = [eq(interests.portId, portId), isNull(interests.archivedAt)];
|
||||
|
||||
if (filters.leadCategory) {
|
||||
conditions.push(eq(interests.leadCategory, filters.leadCategory));
|
||||
}
|
||||
if (filters.source) {
|
||||
conditions.push(eq(interests.source, filters.source));
|
||||
}
|
||||
if (filters.eoiStatus) {
|
||||
conditions.push(eq(interests.eoiStatus, filters.eoiStatus));
|
||||
}
|
||||
|
||||
// Tag-id filter resolves through the join table first so the main
|
||||
// query stays a simple WHERE id IN (…) rather than a SELECT DISTINCT
|
||||
// with LEFT JOIN — keeps Postgres' planner happy at scale.
|
||||
if (filters.tagIds && filters.tagIds.length > 0) {
|
||||
const tagMatches = await db
|
||||
.selectDistinct({ interestId: interestTags.interestId })
|
||||
.from(interestTags)
|
||||
.where(inArray(interestTags.tagId, filters.tagIds));
|
||||
const matchingIds = tagMatches.map((r) => r.interestId);
|
||||
if (matchingIds.length === 0) {
|
||||
return { data: [], truncated: false, total: 0 };
|
||||
}
|
||||
conditions.push(inArray(interests.id, matchingIds));
|
||||
}
|
||||
|
||||
// Search hits client name via the LEFT JOIN. ILIKE is correct here —
|
||||
// the kanban list is small (≤5000 rows) so an index scan isn't
|
||||
// required, and pg_trgm would be overkill for the board surface.
|
||||
if (filters.search && filters.search.trim().length > 0) {
|
||||
const term = `%${filters.search.trim().replace(/[%_]/g, '\\$&')}%`;
|
||||
conditions.push(sql`${clients.fullName} ILIKE ${term}`);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: interests.id,
|
||||
clientName: clients.fullName,
|
||||
leadCategory: interests.leadCategory,
|
||||
pipelineStage: interests.pipelineStage,
|
||||
updatedAt: interests.updatedAt,
|
||||
})
|
||||
.from(interests)
|
||||
.leftJoin(clients, eq(interests.clientId, clients.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(interests.updatedAt))
|
||||
.limit(BOARD_MAX_ROWS + 1);
|
||||
|
||||
const truncated = rows.length > BOARD_MAX_ROWS;
|
||||
const data = truncated ? rows.slice(0, BOARD_MAX_ROWS) : rows;
|
||||
|
||||
// Primary-berth resolution stays in the junction-aware service so the
|
||||
// board sees the same "the berth for this deal" as every other surface.
|
||||
const primaryBerthMap = await getPrimaryBerthsForInterests(data.map((r) => r.id));
|
||||
|
||||
return {
|
||||
data: data.map((r) => ({
|
||||
id: r.id,
|
||||
clientName: r.clientName ?? null,
|
||||
berthMooringNumber: primaryBerthMap.get(r.id)?.mooringNumber ?? null,
|
||||
leadCategory: r.leadCategory ?? null,
|
||||
pipelineStage: r.pipelineStage,
|
||||
updatedAt: r.updatedAt,
|
||||
})),
|
||||
truncated,
|
||||
total: data.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listInterests(portId: string, query: ListInterestsInput) {
|
||||
@@ -367,6 +489,15 @@ export async function getInterestById(id: string, portId: string) {
|
||||
const berthId = primaryBerth?.berthId ?? null;
|
||||
const berthMooringNumber = primaryBerth?.mooringNumber ?? null;
|
||||
|
||||
// Total linked-berth count powers the "Berth Interest" milestone on
|
||||
// the OverviewTab — first thing the rep needs to capture, especially
|
||||
// for general_interest leads. Resolved here (not from the join above)
|
||||
// so the count includes berths the rep added without marking primary.
|
||||
const [{ count: linkedBerthCount } = { count: 0 }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(interestBerths)
|
||||
.where(eq(interestBerths.interestId, id));
|
||||
|
||||
const tagRows = await db
|
||||
.select({ id: tags.id, name: tags.name, color: tags.color })
|
||||
.from(interestTags)
|
||||
@@ -410,6 +541,7 @@ export async function getInterestById(id: string, portId: string) {
|
||||
clientHasAddress: !!addressRow,
|
||||
berthId,
|
||||
berthMooringNumber,
|
||||
linkedBerthCount,
|
||||
tags: tagRows,
|
||||
notesCount,
|
||||
recentNote: recentNote ?? null,
|
||||
|
||||
Reference in New Issue
Block a user