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:
@@ -1,17 +1,29 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { eq, and, desc, inArray } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { clientNotes, clients } from '@/lib/db/schema/clients';
|
||||
import { interestNotes, interests } from '@/lib/db/schema/interests';
|
||||
import { yachtNotes, yachts } from '@/lib/db/schema/yachts';
|
||||
import { companyNotes, companies } from '@/lib/db/schema/companies';
|
||||
import {
|
||||
residentialClients,
|
||||
residentialClientNotes,
|
||||
residentialInterests,
|
||||
residentialInterestNotes,
|
||||
} from '@/lib/db/schema/residential';
|
||||
import { userProfiles } from '@/lib/db/schema/users';
|
||||
import { CodedError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import type { CreateNoteInput, UpdateNoteInput } from '@/lib/validators/notes';
|
||||
|
||||
const EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
type EntityType = 'clients' | 'interests' | 'yachts' | 'companies';
|
||||
type EntityType =
|
||||
| 'clients'
|
||||
| 'interests'
|
||||
| 'yachts'
|
||||
| 'companies'
|
||||
| 'residential_clients'
|
||||
| 'residential_interests';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,18 +53,194 @@ async function verifyParentBelongsToPort(
|
||||
.where(and(eq(yachts.id, entityId), eq(yachts.portId, portId)))
|
||||
.limit(1);
|
||||
if (!r.length) throw new NotFoundError('Yacht');
|
||||
} else {
|
||||
} else if (entityType === 'companies') {
|
||||
const r = await db
|
||||
.select({ id: companies.id })
|
||||
.from(companies)
|
||||
.where(and(eq(companies.id, entityId), eq(companies.portId, portId)))
|
||||
.limit(1);
|
||||
if (!r.length) throw new NotFoundError('Company');
|
||||
} else if (entityType === 'residential_clients') {
|
||||
const r = await db
|
||||
.select({ id: residentialClients.id })
|
||||
.from(residentialClients)
|
||||
.where(and(eq(residentialClients.id, entityId), eq(residentialClients.portId, portId)))
|
||||
.limit(1);
|
||||
if (!r.length) throw new NotFoundError('Residential client');
|
||||
} else {
|
||||
const r = await db
|
||||
.select({ id: residentialInterests.id })
|
||||
.from(residentialInterests)
|
||||
.where(and(eq(residentialInterests.id, entityId), eq(residentialInterests.portId, portId)))
|
||||
.limit(1);
|
||||
if (!r.length) throw new NotFoundError('Residential interest');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to centralise the per-entity table dispatch — keeps the CRUD
|
||||
// branches below from each having their own switch.
|
||||
function tableForEntity(entityType: EntityType) {
|
||||
switch (entityType) {
|
||||
case 'clients':
|
||||
return { table: clientNotes, fk: 'clientId' as const };
|
||||
case 'interests':
|
||||
return { table: interestNotes, fk: 'interestId' as const };
|
||||
case 'yachts':
|
||||
return { table: yachtNotes, fk: 'yachtId' as const };
|
||||
case 'companies':
|
||||
return { table: companyNotes, fk: 'companyId' as const };
|
||||
case 'residential_clients':
|
||||
return { table: residentialClientNotes, fk: 'residentialClientId' as const };
|
||||
case 'residential_interests':
|
||||
return { table: residentialInterestNotes, fk: 'residentialInterestId' as const };
|
||||
}
|
||||
}
|
||||
void tableForEntity;
|
||||
|
||||
// ─── Service ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aggregated note timeline for a client. Unions client-level notes
|
||||
* with notes attached to ANY of the client's interests + directly-
|
||||
* owned yachts (polymorphic ownership: `owner_type='client' AND
|
||||
* owner_id=clientId`). Each row carries source metadata so the UI
|
||||
* can show "from interest E17" or "from yacht Sea Breeze" badges
|
||||
* and offer a "Group by source" view alongside chronological.
|
||||
*
|
||||
* Company-owned yachts the client is a member of are excluded —
|
||||
* those are properly the company's notes, not the client's.
|
||||
*/
|
||||
export interface AggregatedClientNote {
|
||||
id: string;
|
||||
content: string;
|
||||
mentions: string[] | null;
|
||||
isLocked: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
authorId: string;
|
||||
authorName: string | null;
|
||||
source: 'client' | 'interest' | 'yacht';
|
||||
/** Origin entity id — interest_id / yacht_id / client_id. */
|
||||
sourceId: string;
|
||||
/** Human label for the source (interest's berth mooring, yacht
|
||||
* name, or "Client" for client-level). */
|
||||
sourceLabel: string;
|
||||
}
|
||||
|
||||
export async function listForClientAggregated(
|
||||
portId: string,
|
||||
clientId: string,
|
||||
): Promise<AggregatedClientNote[]> {
|
||||
await verifyParentBelongsToPort('clients', clientId, portId);
|
||||
|
||||
// Collect interest + yacht ids upfront so the note-table queries
|
||||
// can be IN-list filtered.
|
||||
const [interestRows, yachtRows] = await Promise.all([
|
||||
db
|
||||
.select({ id: interests.id })
|
||||
.from(interests)
|
||||
.where(and(eq(interests.clientId, clientId), eq(interests.portId, portId))),
|
||||
db
|
||||
.select({ id: yachts.id, name: yachts.name })
|
||||
.from(yachts)
|
||||
.where(
|
||||
and(
|
||||
eq(yachts.portId, portId),
|
||||
eq(yachts.currentOwnerType, 'client'),
|
||||
eq(yachts.currentOwnerId, clientId),
|
||||
),
|
||||
),
|
||||
]);
|
||||
const interestIds = interestRows.map((r) => r.id);
|
||||
const yachtIds = yachtRows.map((r) => r.id);
|
||||
const yachtNameById = new Map(yachtRows.map((y) => [y.id, y.name]));
|
||||
|
||||
// Resolve each interest's primary-berth mooring for the source
|
||||
// label. Cheap single round-trip via the existing junction helper.
|
||||
const primaryBerthMap =
|
||||
interestIds.length > 0
|
||||
? await (
|
||||
await import('@/lib/services/interest-berths.service')
|
||||
).getPrimaryBerthsForInterests(interestIds)
|
||||
: new Map<string, { mooringNumber: string }>();
|
||||
|
||||
// Three parallel reads against the per-entity note tables; merged
|
||||
// in JS rather than via UNION because each table has a different
|
||||
// FK column name and Drizzle's UNION syntax forces matching shapes.
|
||||
const [clientLevel, interestLevel, yachtLevel] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: clientNotes.id,
|
||||
content: clientNotes.content,
|
||||
mentions: clientNotes.mentions,
|
||||
isLocked: clientNotes.isLocked,
|
||||
createdAt: clientNotes.createdAt,
|
||||
updatedAt: clientNotes.updatedAt,
|
||||
authorId: clientNotes.authorId,
|
||||
authorName: userProfiles.displayName,
|
||||
sourceId: clientNotes.clientId,
|
||||
})
|
||||
.from(clientNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, clientNotes.authorId))
|
||||
.where(eq(clientNotes.clientId, clientId)),
|
||||
interestIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
id: interestNotes.id,
|
||||
content: interestNotes.content,
|
||||
mentions: interestNotes.mentions,
|
||||
isLocked: interestNotes.isLocked,
|
||||
createdAt: interestNotes.createdAt,
|
||||
updatedAt: interestNotes.updatedAt,
|
||||
authorId: interestNotes.authorId,
|
||||
authorName: userProfiles.displayName,
|
||||
sourceId: interestNotes.interestId,
|
||||
})
|
||||
.from(interestNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, interestNotes.authorId))
|
||||
.where(inArray(interestNotes.interestId, interestIds))
|
||||
: Promise.resolve([] as never[]),
|
||||
yachtIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
id: yachtNotes.id,
|
||||
content: yachtNotes.content,
|
||||
mentions: yachtNotes.mentions,
|
||||
isLocked: yachtNotes.isLocked,
|
||||
createdAt: yachtNotes.createdAt,
|
||||
updatedAt: yachtNotes.updatedAt,
|
||||
authorId: yachtNotes.authorId,
|
||||
authorName: userProfiles.displayName,
|
||||
sourceId: yachtNotes.yachtId,
|
||||
})
|
||||
.from(yachtNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
|
||||
.where(inArray(yachtNotes.yachtId, yachtIds))
|
||||
: Promise.resolve([] as never[]),
|
||||
]);
|
||||
|
||||
const merged: AggregatedClientNote[] = [
|
||||
...clientLevel.map((n) => ({
|
||||
...n,
|
||||
source: 'client' as const,
|
||||
sourceLabel: 'Client',
|
||||
})),
|
||||
...interestLevel.map((n) => ({
|
||||
...n,
|
||||
source: 'interest' as const,
|
||||
sourceLabel: primaryBerthMap.get(n.sourceId)?.mooringNumber ?? 'Interest',
|
||||
})),
|
||||
...yachtLevel.map((n) => ({
|
||||
...n,
|
||||
source: 'yacht' as const,
|
||||
sourceLabel: yachtNameById.get(n.sourceId) ?? 'Yacht',
|
||||
})),
|
||||
];
|
||||
|
||||
merged.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function listForEntity(portId: string, entityType: EntityType, entityId: string) {
|
||||
await verifyParentBelongsToPort(entityType, entityId, portId);
|
||||
|
||||
@@ -107,7 +295,7 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, yachtNotes.authorId))
|
||||
.where(eq(yachtNotes.yachtId, entityId))
|
||||
.orderBy(desc(yachtNotes.createdAt));
|
||||
} else {
|
||||
} else if (entityType === 'companies') {
|
||||
return db
|
||||
.select({
|
||||
id: companyNotes.id,
|
||||
@@ -124,6 +312,40 @@ export async function listForEntity(portId: string, entityType: EntityType, enti
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, companyNotes.authorId))
|
||||
.where(eq(companyNotes.companyId, entityId))
|
||||
.orderBy(desc(companyNotes.createdAt));
|
||||
} else if (entityType === 'residential_clients') {
|
||||
return db
|
||||
.select({
|
||||
id: residentialClientNotes.id,
|
||||
residentialClientId: residentialClientNotes.residentialClientId,
|
||||
authorId: residentialClientNotes.authorId,
|
||||
content: residentialClientNotes.content,
|
||||
mentions: residentialClientNotes.mentions,
|
||||
isLocked: residentialClientNotes.isLocked,
|
||||
createdAt: residentialClientNotes.createdAt,
|
||||
updatedAt: residentialClientNotes.updatedAt,
|
||||
authorName: userProfiles.displayName,
|
||||
})
|
||||
.from(residentialClientNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, residentialClientNotes.authorId))
|
||||
.where(eq(residentialClientNotes.residentialClientId, entityId))
|
||||
.orderBy(desc(residentialClientNotes.createdAt));
|
||||
} else {
|
||||
return db
|
||||
.select({
|
||||
id: residentialInterestNotes.id,
|
||||
residentialInterestId: residentialInterestNotes.residentialInterestId,
|
||||
authorId: residentialInterestNotes.authorId,
|
||||
content: residentialInterestNotes.content,
|
||||
mentions: residentialInterestNotes.mentions,
|
||||
isLocked: residentialInterestNotes.isLocked,
|
||||
createdAt: residentialInterestNotes.createdAt,
|
||||
updatedAt: residentialInterestNotes.updatedAt,
|
||||
authorName: userProfiles.displayName,
|
||||
})
|
||||
.from(residentialInterestNotes)
|
||||
.leftJoin(userProfiles, eq(userProfiles.userId, residentialInterestNotes.authorId))
|
||||
.where(eq(residentialInterestNotes.residentialInterestId, entityId))
|
||||
.orderBy(desc(residentialInterestNotes.createdAt));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +429,8 @@ export async function create(
|
||||
}
|
||||
|
||||
return { ...note, authorName };
|
||||
} else {
|
||||
}
|
||||
if (entityType === 'interests') {
|
||||
const [note] = await db
|
||||
.insert(interestNotes)
|
||||
.values({ interestId: entityId, authorId, content: data.content })
|
||||
@@ -247,6 +470,38 @@ export async function create(
|
||||
|
||||
return { ...note, authorName };
|
||||
}
|
||||
if (entityType === 'residential_clients') {
|
||||
const [note] = await db
|
||||
.insert(residentialClientNotes)
|
||||
.values({ residentialClientId: entityId, authorId, content: data.content })
|
||||
.returning();
|
||||
if (!note)
|
||||
throw new CodedError('INSERT_RETURNING_EMPTY', {
|
||||
internalMessage: 'Residential client note insert returned no row',
|
||||
});
|
||||
const profile = await db
|
||||
.select({ displayName: userProfiles.displayName })
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, authorId))
|
||||
.limit(1);
|
||||
return { ...note, authorName: profile[0]?.displayName ?? null };
|
||||
}
|
||||
if (entityType === 'residential_interests') {
|
||||
const [note] = await db
|
||||
.insert(residentialInterestNotes)
|
||||
.values({ residentialInterestId: entityId, authorId, content: data.content })
|
||||
.returning();
|
||||
if (!note)
|
||||
throw new CodedError('INSERT_RETURNING_EMPTY', {
|
||||
internalMessage: 'Residential interest note insert returned no row',
|
||||
});
|
||||
const profile = await db
|
||||
.select({ displayName: userProfiles.displayName })
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, authorId))
|
||||
.limit(1);
|
||||
return { ...note, authorName: profile[0]?.displayName ?? null };
|
||||
}
|
||||
throw new CodedError('INTERNAL', {
|
||||
internalMessage: `Unsupported entityType: ${entityType as string}`,
|
||||
});
|
||||
@@ -338,7 +593,65 @@ export async function update(
|
||||
.limit(1);
|
||||
|
||||
return { ...updated, authorName: profile[0]?.displayName ?? null };
|
||||
} else {
|
||||
}
|
||||
if (entityType === 'residential_clients') {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(residentialClientNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(residentialClientNotes.id, noteId),
|
||||
eq(residentialClientNotes.residentialClientId, entityId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) throw new NotFoundError('Note');
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
const [updated] = await db
|
||||
.update(residentialClientNotes)
|
||||
.set({ content: data.content, updatedAt: new Date() })
|
||||
.where(eq(residentialClientNotes.id, noteId))
|
||||
.returning();
|
||||
if (!updated) throw new NotFoundError('Note');
|
||||
const profile = await db
|
||||
.select({ displayName: userProfiles.displayName })
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, updated.authorId))
|
||||
.limit(1);
|
||||
return { ...updated, authorName: profile[0]?.displayName ?? null };
|
||||
}
|
||||
if (entityType === 'residential_interests') {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(residentialInterestNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(residentialInterestNotes.id, noteId),
|
||||
eq(residentialInterestNotes.residentialInterestId, entityId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) throw new NotFoundError('Note');
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
const [updated] = await db
|
||||
.update(residentialInterestNotes)
|
||||
.set({ content: data.content, updatedAt: new Date() })
|
||||
.where(eq(residentialInterestNotes.id, noteId))
|
||||
.returning();
|
||||
if (!updated) throw new NotFoundError('Note');
|
||||
const profile = await db
|
||||
.select({ displayName: userProfiles.displayName })
|
||||
.from(userProfiles)
|
||||
.where(eq(userProfiles.userId, updated.authorId))
|
||||
.limit(1);
|
||||
return { ...updated, authorName: profile[0]?.displayName ?? null };
|
||||
}
|
||||
// Default: interests (the marina-side, not residential)
|
||||
{
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(interestNotes)
|
||||
@@ -416,7 +729,45 @@ export async function deleteNote(
|
||||
|
||||
await db.delete(clientNotes).where(eq(clientNotes.id, noteId));
|
||||
return existing;
|
||||
} else {
|
||||
}
|
||||
if (entityType === 'residential_clients') {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(residentialClientNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(residentialClientNotes.id, noteId),
|
||||
eq(residentialClientNotes.residentialClientId, entityId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) throw new NotFoundError('Note');
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
await db.delete(residentialClientNotes).where(eq(residentialClientNotes.id, noteId));
|
||||
return existing;
|
||||
}
|
||||
if (entityType === 'residential_interests') {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(residentialInterestNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(residentialInterestNotes.id, noteId),
|
||||
eq(residentialInterestNotes.residentialInterestId, entityId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing) throw new NotFoundError('Note');
|
||||
if (Date.now() - new Date(existing.createdAt).getTime() > EDIT_WINDOW_MS) {
|
||||
throw new ValidationError('Note edit window has expired (15 minutes)');
|
||||
}
|
||||
await db.delete(residentialInterestNotes).where(eq(residentialInterestNotes.id, noteId));
|
||||
return existing;
|
||||
}
|
||||
// Default: interests
|
||||
{
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(interestNotes)
|
||||
|
||||
Reference in New Issue
Block a user