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,14 +1,4 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
ilike,
|
||||
isNull,
|
||||
or,
|
||||
sql,
|
||||
type SQL,
|
||||
} from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, ilike, isNull, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { PgTable, PgColumn } from 'drizzle-orm/pg-core';
|
||||
import { db } from './index';
|
||||
|
||||
@@ -20,6 +10,13 @@ export interface BuildListQueryOptions {
|
||||
updatedAtColumn: PgColumn;
|
||||
filters?: SQL[];
|
||||
sort?: { column: PgColumn; direction: 'asc' | 'desc' };
|
||||
/**
|
||||
* Custom ORDER BY clauses, used INSTEAD of `sort`. For cases where
|
||||
* the natural ordering needs raw SQL (e.g. natural alphanumeric sort
|
||||
* on berth mooring numbers like A1, A2, A10, B1...). Deterministic
|
||||
* tail-sort on `updatedAt DESC, id DESC` is still appended.
|
||||
*/
|
||||
customOrderBy?: SQL[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
searchColumns?: PgColumn[];
|
||||
@@ -40,9 +37,7 @@ export interface ListResult<T> {
|
||||
* - `archivedAt IS NULL` by default (unless `includeArchived` is true).
|
||||
* - Deterministic secondary sort: `updatedAt DESC, id DESC`.
|
||||
*/
|
||||
export async function buildListQuery<T>(
|
||||
opts: BuildListQueryOptions,
|
||||
): Promise<ListResult<T>> {
|
||||
export async function buildListQuery<T>(opts: BuildListQueryOptions): Promise<ListResult<T>> {
|
||||
const {
|
||||
table,
|
||||
portIdColumn,
|
||||
@@ -51,6 +46,7 @@ export async function buildListQuery<T>(
|
||||
updatedAtColumn,
|
||||
filters = [],
|
||||
sort,
|
||||
customOrderBy,
|
||||
page,
|
||||
pageSize,
|
||||
searchColumns = [],
|
||||
@@ -68,9 +64,7 @@ export async function buildListQuery<T>(
|
||||
|
||||
// Full-text search across multiple columns via ILIKE
|
||||
if (searchTerm && searchColumns.length > 0) {
|
||||
const searchConditions = searchColumns.map((col) =>
|
||||
ilike(col, `%${searchTerm}%`),
|
||||
);
|
||||
const searchConditions = searchColumns.map((col) => ilike(col, `%${searchTerm}%`));
|
||||
conditions.push(or(...searchConditions)!);
|
||||
}
|
||||
|
||||
@@ -86,12 +80,13 @@ export async function buildListQuery<T>(
|
||||
.where(where);
|
||||
const total = countResult[0]?.count ?? 0;
|
||||
|
||||
// Build order by: user sort + deterministic secondary sort
|
||||
// Build order by: customOrderBy (if provided) wins over the default
|
||||
// column-based sort. Deterministic secondary sort always trails.
|
||||
const orderClauses: SQL[] = [];
|
||||
if (sort) {
|
||||
orderClauses.push(
|
||||
sort.direction === 'asc' ? asc(sort.column) : desc(sort.column),
|
||||
);
|
||||
if (customOrderBy && customOrderBy.length > 0) {
|
||||
orderClauses.push(...customOrderBy);
|
||||
} else if (sort) {
|
||||
orderClauses.push(sort.direction === 'asc' ? asc(sort.column) : desc(sort.column));
|
||||
}
|
||||
orderClauses.push(desc(updatedAtColumn), desc(idColumn));
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ export const interests = pgTable(
|
||||
outcomeReason: text('outcome_reason'),
|
||||
/** When the outcome was decided. Lets us age 'how long ago did we lose'. */
|
||||
outcomeAt: timestamp('outcome_at', { withTimezone: true }),
|
||||
notes: text('notes'),
|
||||
/** Recommender inputs - imperial; resolver treats nulls as "no constraint"
|
||||
* on that axis, with a banner prompting the rep to add the missing dim. */
|
||||
desiredLengthFt: numeric('desired_length_ft'),
|
||||
|
||||
@@ -143,10 +143,27 @@ export type RolePermissions = {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-table column visibility — drives the `<ColumnPicker>` and the
|
||||
* DataTable `columnVisibility` state. `hiddenColumns` is the source of
|
||||
* truth; an entry's absence means "show this column" (so newly-added
|
||||
* columns show by default for existing users without us having to
|
||||
* migrate stored preferences).
|
||||
*/
|
||||
export type TablePreferences = {
|
||||
hiddenColumns?: string[];
|
||||
};
|
||||
|
||||
export type UserPreferences = {
|
||||
dark_mode?: boolean;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
/** ISO-3166-1 alpha-2. Drives the default timezone when the rep
|
||||
* hasn't picked one explicitly, and lets the auto-detect banner
|
||||
* spot a mismatch when they're travelling. */
|
||||
country?: string;
|
||||
/** Keyed by entity type: `clients`, `yachts`, `interests`, etc. */
|
||||
tablePreferences?: Record<string, TablePreferences>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -209,6 +226,12 @@ export const userProfiles = pgTable(
|
||||
userId: text('user_id').notNull().unique(), // references Better Auth user ID
|
||||
displayName: text('display_name').notNull(),
|
||||
avatarUrl: text('avatar_url'),
|
||||
/** FK into the polymorphic `files` table — the avatar is stored
|
||||
* via getStorageBackend() so an S3↔filesystem swap carries it
|
||||
* without breaking the URL. The legacy `avatarUrl` column is
|
||||
* kept for any external photo sources but the file pointer wins
|
||||
* when both are set. */
|
||||
avatarFileId: text('avatar_file_id'),
|
||||
phone: text('phone'),
|
||||
isSuperAdmin: boolean('is_super_admin').notNull().default(false),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
@@ -261,6 +284,37 @@ export const portRoleOverrides = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Pending email-change records for the verify-old-and-new flow.
|
||||
* The CRM's `/api/v1/me/email` endpoint creates a row here, emails
|
||||
* the OLD address with a cancel link and the NEW address with a
|
||||
* confirm link, and applies the change only when the new address
|
||||
* confirms (or auto-cancels at `expiresAt`).
|
||||
*
|
||||
* `confirmTokenHash` stores a sha256 of the random confirmation
|
||||
* token; the raw token is only present in the email body.
|
||||
*/
|
||||
export const userEmailChanges = pgTable(
|
||||
'user_email_changes',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
userId: text('user_id').notNull(),
|
||||
oldEmail: text('old_email').notNull(),
|
||||
newEmail: text('new_email').notNull(),
|
||||
confirmTokenHash: text('confirm_token_hash').notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
appliedAt: timestamp('applied_at', { withTimezone: true }),
|
||||
cancelledAt: timestamp('cancelled_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_uec_user').on(table.userId),
|
||||
index('idx_uec_token').on(table.confirmTokenHash),
|
||||
],
|
||||
);
|
||||
|
||||
export const userPortRoles = pgTable(
|
||||
'user_port_roles',
|
||||
{
|
||||
|
||||
@@ -87,7 +87,9 @@ export interface SyntheticSeedSummary {
|
||||
}
|
||||
|
||||
interface SyntheticClientSpec {
|
||||
/** Used as a name suffix so test selectors can target it deterministically. */
|
||||
/** Stable identifier used by Playwright selectors and intra-seed wiring
|
||||
* (memberships, yachts, etc.). Decoupled from the display name so the
|
||||
* rendered list looks like real client data, not test fixtures. */
|
||||
tag: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
@@ -105,6 +107,14 @@ interface SyntheticClientSpec {
|
||||
/** Archive the CLIENT after creation. When 'rich', fabricate
|
||||
* archive_metadata so the smart-restore wizard surfaces reversals. */
|
||||
archive?: 'simple' | 'rich';
|
||||
/** Acquisition source — varied across the fixture set so the list view
|
||||
* looks like a real funnel rather than a wall of "Manual". */
|
||||
source?: 'website' | 'manual' | 'referral' | 'broker';
|
||||
/** How long ago (in days) this client record was created. Spreads the
|
||||
* "Created" column across realistic timestamps so list pages look like
|
||||
* a real CRM with months of history rather than 12 rows all stamped
|
||||
* with today's date. */
|
||||
createdDaysAgo?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,150 +125,184 @@ interface SyntheticClientSpec {
|
||||
* Berth indices map deterministically into the NocoDB snapshot which is
|
||||
* pre-sorted: idx 0..4 available, 5..9 under_offer, 10..11 sold.
|
||||
*/
|
||||
/**
|
||||
* Believable demo dataset — names, emails, phone numbers, addresses, and
|
||||
* acquisition sources read like a real marina's prospect list rather
|
||||
* than fixtures keyed on enum names. The `tag` field still carries the
|
||||
* stage/role identity for selectors and intra-seed wiring; nothing in
|
||||
* the rendered UI references it.
|
||||
*
|
||||
* Spread across acquisition sources, ages (3–280 days), and countries
|
||||
* so list / dashboard / kanban surfaces look populated and natural.
|
||||
*/
|
||||
const PIPELINE_CLIENTS: SyntheticClientSpec[] = [
|
||||
{
|
||||
tag: 'open',
|
||||
fullName: 'Olivia Open — open',
|
||||
email: 'olivia.open@test.local',
|
||||
phone: '+1 555 010 0001',
|
||||
fullName: 'Olivia Sinclair',
|
||||
email: 'olivia.sinclair@gmail.com',
|
||||
phone: '+44 7700 900142',
|
||||
countryIso: 'GB',
|
||||
city: 'London',
|
||||
street: '1 Open Lane',
|
||||
postalCode: 'OP1 1OP',
|
||||
street: '14 Cheyne Walk',
|
||||
postalCode: 'SW3 5RA',
|
||||
stage: 'open',
|
||||
source: 'website',
|
||||
createdDaysAgo: 4,
|
||||
// Open stage: no berth link yet
|
||||
},
|
||||
{
|
||||
tag: 'details',
|
||||
fullName: 'Daniel Details — details_sent',
|
||||
email: 'daniel.details@test.local',
|
||||
phone: '+1 555 010 0002',
|
||||
fullName: 'Daniel Whitaker',
|
||||
email: 'daniel.whitaker@outlook.com',
|
||||
phone: '+1 305 555 0182',
|
||||
countryIso: 'US',
|
||||
city: 'Miami',
|
||||
street: '2 Brochure Way',
|
||||
postalCode: '33101',
|
||||
street: '880 Brickell Bay Drive',
|
||||
postalCode: '33131',
|
||||
stage: 'details_sent',
|
||||
berthIdx: 0,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 12,
|
||||
},
|
||||
{
|
||||
tag: 'comms',
|
||||
fullName: 'Carla Communicating — in_communication',
|
||||
email: 'carla.comms@test.local',
|
||||
phone: '+1 555 010 0003',
|
||||
fullName: 'Carla Mendoza',
|
||||
email: 'carla.mendoza@gmail.com',
|
||||
phone: '+34 971 555 028',
|
||||
countryIso: 'ES',
|
||||
city: 'Palma',
|
||||
street: '3 Reply Street',
|
||||
postalCode: '07012',
|
||||
city: 'Palma de Mallorca',
|
||||
street: 'Carrer de Sant Magí 23',
|
||||
postalCode: '07013',
|
||||
stage: 'in_communication',
|
||||
berthIdx: 5,
|
||||
source: 'referral',
|
||||
createdDaysAgo: 28,
|
||||
},
|
||||
{
|
||||
tag: 'eoi-sent',
|
||||
fullName: 'Eric EoiSent — eoi_sent',
|
||||
email: 'eric.eoisent@test.local',
|
||||
phone: '+1 555 010 0004',
|
||||
fullName: 'Marco Bianchi',
|
||||
email: 'marco.bianchi@libero.it',
|
||||
phone: '+39 010 8740 215',
|
||||
countryIso: 'IT',
|
||||
city: 'Genoa',
|
||||
street: '4 Envelope Plaza',
|
||||
postalCode: '16124',
|
||||
street: 'Via XX Settembre 47',
|
||||
postalCode: '16121',
|
||||
stage: 'eoi_sent',
|
||||
berthIdx: 6,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 45,
|
||||
},
|
||||
{
|
||||
tag: 'eoi-signed',
|
||||
fullName: 'Sara EoiSigned — eoi_signed',
|
||||
email: 'sara.eoisigned@test.local',
|
||||
phone: '+1 555 010 0005',
|
||||
fullName: 'Sara Laurent',
|
||||
email: 'sara.laurent@orange.fr',
|
||||
phone: '+33 4 93 92 18 47',
|
||||
countryIso: 'FR',
|
||||
city: 'Nice',
|
||||
street: '5 Signed Avenue',
|
||||
postalCode: '06300',
|
||||
street: '8 Promenade des Anglais',
|
||||
postalCode: '06000',
|
||||
stage: 'eoi_signed',
|
||||
berthIdx: 7,
|
||||
source: 'website',
|
||||
createdDaysAgo: 72,
|
||||
},
|
||||
{
|
||||
tag: 'deposit',
|
||||
fullName: 'Dario Deposit — deposit_10pct',
|
||||
email: 'dario.deposit@test.local',
|
||||
phone: '+1 555 010 0006',
|
||||
fullName: 'Nikolas Papadakis',
|
||||
email: 'n.papadakis@gmail.com',
|
||||
phone: '+30 210 8945 612',
|
||||
countryIso: 'GR',
|
||||
city: 'Athens',
|
||||
street: '6 Deposit Quay',
|
||||
postalCode: '10558',
|
||||
street: 'Vouliagmenis Avenue 142',
|
||||
postalCode: '16674',
|
||||
stage: 'deposit_10pct',
|
||||
berthIdx: 8,
|
||||
source: 'referral',
|
||||
createdDaysAgo: 95,
|
||||
},
|
||||
{
|
||||
tag: 'contract-sent',
|
||||
fullName: 'Connor ContractSent — contract_sent',
|
||||
email: 'connor.contract@test.local',
|
||||
phone: '+1 555 010 0007',
|
||||
fullName: 'Connor Murphy',
|
||||
email: 'connor.murphy@me.com',
|
||||
phone: '+353 1 555 0184',
|
||||
countryIso: 'IE',
|
||||
city: 'Dublin',
|
||||
street: '7 Contract Row',
|
||||
street: '12 Merrion Square North',
|
||||
postalCode: 'D02 E2X3',
|
||||
stage: 'contract_sent',
|
||||
berthIdx: 9,
|
||||
source: 'manual',
|
||||
createdDaysAgo: 118,
|
||||
},
|
||||
{
|
||||
tag: 'contract-signed',
|
||||
fullName: 'Carmen ContractSigned — contract_signed',
|
||||
email: 'carmen.signed@test.local',
|
||||
phone: '+1 555 010 0008',
|
||||
fullName: 'Carmen Costa',
|
||||
email: 'carmen.costa@sapo.pt',
|
||||
phone: '+351 21 386 4520',
|
||||
countryIso: 'PT',
|
||||
city: 'Lisbon',
|
||||
street: '8 Notary Square',
|
||||
postalCode: '1100-001',
|
||||
street: 'Rua Garrett 88',
|
||||
postalCode: '1200-205',
|
||||
stage: 'contract_signed',
|
||||
berthIdx: 4,
|
||||
source: 'broker',
|
||||
createdDaysAgo: 156,
|
||||
},
|
||||
{
|
||||
tag: 'completed-won',
|
||||
fullName: 'Carlos Completed — completed (won)',
|
||||
email: 'carlos.complete@test.local',
|
||||
phone: '+1 555 010 0009',
|
||||
fullName: 'Carlos Vega',
|
||||
email: 'carlos.vega@gmail.com',
|
||||
phone: '+507 6612 4485',
|
||||
countryIso: 'PA',
|
||||
city: 'Panama City',
|
||||
street: '9 Owner Lane',
|
||||
postalCode: '0801',
|
||||
street: 'Calle 50, Torre Banistmo Piso 18',
|
||||
postalCode: '0816',
|
||||
stage: 'completed',
|
||||
berthIdx: 10,
|
||||
outcome: 'won',
|
||||
source: 'referral',
|
||||
createdDaysAgo: 245,
|
||||
},
|
||||
{
|
||||
tag: 'completed-lost',
|
||||
fullName: 'Lara LostLead — completed (lost)',
|
||||
email: 'lara.lost@test.local',
|
||||
phone: '+1 555 010 0010',
|
||||
fullName: 'Hannah Schmidt',
|
||||
email: 'hannah.schmidt@gmx.de',
|
||||
phone: '+49 40 4286 9152',
|
||||
countryIso: 'DE',
|
||||
city: 'Hamburg',
|
||||
street: '10 Other Marina',
|
||||
postalCode: '20457',
|
||||
street: 'Alsterufer 28',
|
||||
postalCode: '20354',
|
||||
stage: 'completed',
|
||||
berthIdx: 1,
|
||||
outcome: 'lost_unqualified',
|
||||
source: 'website',
|
||||
createdDaysAgo: 84,
|
||||
},
|
||||
{
|
||||
tag: 'archived-simple',
|
||||
fullName: 'Anna ArchivedSimple — archived',
|
||||
email: 'anna.archived@test.local',
|
||||
phone: '+1 555 010 0011',
|
||||
fullName: 'Anna de Jong',
|
||||
email: 'anna.dejong@kpn.nl',
|
||||
phone: '+31 20 624 7185',
|
||||
countryIso: 'NL',
|
||||
city: 'Amsterdam',
|
||||
street: '11 Quiet Path',
|
||||
postalCode: '1011',
|
||||
street: 'Herengracht 412',
|
||||
postalCode: '1017 BX',
|
||||
archive: 'simple',
|
||||
source: 'website',
|
||||
createdDaysAgo: 201,
|
||||
},
|
||||
{
|
||||
tag: 'archived-rich',
|
||||
fullName: 'Rita ArchivedRich — archived w/ metadata',
|
||||
email: 'rita.archivedrich@test.local',
|
||||
phone: '+1 555 010 0012',
|
||||
fullName: 'Rita Vermeulen',
|
||||
email: 'rita.vermeulen@telenet.be',
|
||||
phone: '+32 3 226 8420',
|
||||
countryIso: 'BE',
|
||||
city: 'Antwerp',
|
||||
street: '12 Rich Metadata Blvd',
|
||||
street: 'Meir 102',
|
||||
postalCode: '2000',
|
||||
archive: 'rich',
|
||||
source: 'broker',
|
||||
createdDaysAgo: 280,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -358,14 +402,25 @@ export async function seedSyntheticPortData(
|
||||
const clientRows = await tx
|
||||
.insert(clients)
|
||||
.values(
|
||||
PIPELINE_CLIENTS.map((spec) => ({
|
||||
portId,
|
||||
fullName: spec.fullName,
|
||||
nationalityIso: spec.countryIso,
|
||||
preferredContactMethod: 'email' as const,
|
||||
preferredLanguage: 'en',
|
||||
source: 'manual' as const,
|
||||
})),
|
||||
PIPELINE_CLIENTS.map((spec) => {
|
||||
const created =
|
||||
spec.createdDaysAgo !== undefined ? daysAgo(spec.createdDaysAgo) : new Date();
|
||||
return {
|
||||
portId,
|
||||
fullName: spec.fullName,
|
||||
nationalityIso: spec.countryIso,
|
||||
preferredContactMethod: 'email' as const,
|
||||
preferredLanguage: 'en',
|
||||
source: spec.source ?? ('manual' as const),
|
||||
// Override the schema default so the list page shows a
|
||||
// realistic range of "Created" timestamps rather than 12
|
||||
// rows all stamped with today's date. updated_at gets the
|
||||
// same value so sorted-by-recency lists put the freshest
|
||||
// records first.
|
||||
createdAt: created,
|
||||
updatedAt: created,
|
||||
};
|
||||
}),
|
||||
)
|
||||
.returning({ id: clients.id, fullName: clients.fullName });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user