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:
@@ -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',
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user