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

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