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:
2026-05-07 20:59:28 +02:00
parent 267c2b6d1f
commit 3e4d9d6310
87 changed files with 5593 additions and 902 deletions

View File

@@ -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));

View File

@@ -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'),

View File

@@ -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',
{

View File

@@ -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 (3280 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 });