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

@@ -14,6 +14,7 @@ import { DetailHeaderStrip } from '@/components/shared/detail-header-strip';
import { PermissionGate } from '@/components/shared/permission-gate';
import { YachtForm } from '@/components/yachts/yacht-form';
import { YachtTransferDialog } from '@/components/yachts/yacht-transfer-dialog';
import { formatYachtDimensionsBothUnits } from '@/components/yachts/yacht-dimensions';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
@@ -95,18 +96,9 @@ export function OwnerLink({
}
function formatDimensions(yacht: YachtDetailHeaderYacht): string | null {
const parts: string[] = [];
if (yacht.lengthFt) parts.push(`${yacht.lengthFt} ft`);
if (yacht.widthFt) parts.push(`${yacht.widthFt} ft`);
let summary: string | null = null;
if (parts.length > 0) {
summary = parts.join(' × ');
}
if (yacht.draftFt) {
summary = summary ? `${summary} (draft ${yacht.draftFt} ft)` : `draft ${yacht.draftFt} ft`;
}
return summary;
// Show both units; derive whichever is unset from the other so reps
// never need to enter both. See `yacht-dimensions.ts`.
return formatYachtDimensionsBothUnits(yacht);
}
export function YachtDetailHeader({ yacht }: YachtDetailHeaderProps) {

View File

@@ -8,6 +8,7 @@ import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provid
import { YachtDetailHeader } from '@/components/yachts/yacht-detail-header';
import { getYachtTabs } from '@/components/yachts/yacht-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { useBreadcrumbHint } from '@/hooks/use-breadcrumb-hint';
import { apiFetch } from '@/lib/api/client';
export interface YachtData {
@@ -54,6 +55,8 @@ export function YachtDetail({ yachtId, currentUserId }: YachtDetailProps) {
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
useBreadcrumbHint(data ? { parents: [], current: data.name } : null);
useRealtimeInvalidation({
'yacht:updated': [['yachts', yachtId]],
'yacht:archived': [['yachts', yachtId]],

View File

@@ -0,0 +1,96 @@
/**
* Imperial ↔ metric conversion + display helpers for yacht dimensions.
* The schema stores both `*Ft` and `*M` separately so the rep can edit
* either unit without losing precision; this module is the single source
* for the formulas and rendering.
*/
const FT_PER_M = 3.28084;
export function feetToMeters(ft: number | string | null | undefined): number | null {
const n = typeof ft === 'string' ? Number.parseFloat(ft) : ft;
if (n === null || n === undefined || !Number.isFinite(n)) return null;
return n / FT_PER_M;
}
export function metersToFeet(m: number | string | null | undefined): number | null {
const n = typeof m === 'string' ? Number.parseFloat(m) : m;
if (n === null || n === undefined || !Number.isFinite(n)) return null;
return n * FT_PER_M;
}
/** One decimal place is enough for marina-scale dimensions; trailing
* zero stripped for cleaner display. Returns null when input is null. */
export function formatNumber1dp(n: number | null | undefined): string | null {
if (n === null || n === undefined || !Number.isFinite(n)) return null;
return n.toFixed(1).replace(/\.0$/, '');
}
export interface YachtDimensions {
lengthFt: string | number | null;
widthFt: string | number | null;
draftFt: string | number | null;
lengthM: string | number | null;
widthM: string | number | null;
draftM: string | number | null;
}
/**
* Returns the dimension in the requested unit, deriving from the other
* unit when the requested one is unset. Lets the UI render both units
* side-by-side without the rep having to enter both.
*/
export function dimInFeet(value: {
ft: number | string | null;
m: number | string | null;
}): string | null {
const direct = parseNum(value.ft);
if (direct !== null) return formatNumber1dp(direct);
return formatNumber1dp(metersToFeet(value.m));
}
export function dimInMeters(value: {
ft: number | string | null;
m: number | string | null;
}): string | null {
const direct = parseNum(value.m);
if (direct !== null) return formatNumber1dp(direct);
return formatNumber1dp(feetToMeters(value.ft));
}
function parseNum(v: number | string | null | undefined): number | null {
if (v === null || v === undefined) return null;
const n = typeof v === 'string' ? Number.parseFloat(v) : v;
return Number.isFinite(n) ? n : null;
}
/**
* One-line summary used in the detail header. Shows both units when
* any dimension is known, deriving missing values via the formulas
* above. Returns null only when the yacht has no dimensions at all.
*/
export function formatYachtDimensionsBothUnits(yacht: YachtDimensions): string | null {
const lFt = dimInFeet({ ft: yacht.lengthFt, m: yacht.lengthM });
const wFt = dimInFeet({ ft: yacht.widthFt, m: yacht.widthM });
const dFt = dimInFeet({ ft: yacht.draftFt, m: yacht.draftM });
const lM = dimInMeters({ ft: yacht.lengthFt, m: yacht.lengthM });
const wM = dimInMeters({ ft: yacht.widthFt, m: yacht.widthM });
const dM = dimInMeters({ ft: yacht.draftFt, m: yacht.draftM });
const ftParts: string[] = [];
if (lFt) ftParts.push(`${lFt} ft`);
if (wFt) ftParts.push(`${wFt} ft`);
const mParts: string[] = [];
if (lM) mParts.push(`${lM} m`);
if (wM) mParts.push(`${wM} m`);
if (ftParts.length === 0 && !dFt) return null;
const ftSummary = ftParts.join(' × ');
const mSummary = mParts.join(' × ');
const head = ftSummary && mSummary ? `${ftSummary} (${mSummary})` : ftSummary || mSummary;
if (dFt && dM) return `${head} (draft ${dFt} ft / ${dM} m)`;
if (dFt) return `${head} (draft ${dFt} ft)`;
if (dM) return `${head} (draft ${dM} m)`;
return head;
}

View File

@@ -128,8 +128,6 @@ export function YachtList() {
/>
<SavedViewsDropdown
entityType="yachts"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, _savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));

View File

@@ -94,7 +94,45 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
const value = transform ? transform(next) : next;
await mutation.mutateAsync({ [field]: value });
};
const numericString = (next: string | null) => (next === null ? null : next);
/**
* Bidirectional dimension save: when the rep edits Length/Width/Draft
* in feet, also write the metric counterpart (and vice versa). Avoids
* the "I entered ft but the m row still says '-'" surprise.
*
* If the rep clears a field (next === null), only that side is
* cleared — we never overwrite their other-unit value with a derived
* one, since they may have intentionally entered a more precise
* metric figure.
*/
function saveDimension(
primaryField: 'lengthFt' | 'widthFt' | 'draftFt' | 'lengthM' | 'widthM' | 'draftM',
) {
const isFt = primaryField.endsWith('Ft');
const counterpart = (
isFt ? primaryField.replace('Ft', 'M') : primaryField.replace('M', 'Ft')
) as YachtPatchField;
return async (next: string | null) => {
if (next === null || next === '') {
await mutation.mutateAsync({ [primaryField]: null });
return;
}
const n = Number.parseFloat(next);
if (!Number.isFinite(n)) {
await mutation.mutateAsync({ [primaryField]: next });
return;
}
const FT_PER_M = 3.28084;
const converted = isFt ? n / FT_PER_M : n * FT_PER_M;
const convertedStr = converted
.toFixed(2)
.replace(/\.0+$/, '')
.replace(/(\.\d)0$/, '$1');
await mutation.mutateAsync({
[primaryField]: next,
[counterpart]: convertedStr,
});
};
}
const yearTransform = (next: string | null) => {
if (next === null) return null;
const n = Number.parseInt(next, 10);
@@ -157,13 +195,13 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
<h3 className="text-sm font-medium mb-2">Dimensions (ft)</h3>
<dl>
<EditableRow label="Length (ft)">
<InlineEditableField value={yacht.lengthFt} onSave={save('lengthFt', numericString)} />
<InlineEditableField value={yacht.lengthFt} onSave={saveDimension('lengthFt')} />
</EditableRow>
<EditableRow label="Width (ft)">
<InlineEditableField value={yacht.widthFt} onSave={save('widthFt', numericString)} />
<InlineEditableField value={yacht.widthFt} onSave={saveDimension('widthFt')} />
</EditableRow>
<EditableRow label="Draft (ft)">
<InlineEditableField value={yacht.draftFt} onSave={save('draftFt', numericString)} />
<InlineEditableField value={yacht.draftFt} onSave={saveDimension('draftFt')} />
</EditableRow>
</dl>
</div>
@@ -173,13 +211,13 @@ function OverviewTab({ yachtId, yacht }: { yachtId: string; yacht: YachtTabsYach
<h3 className="text-sm font-medium mb-2">Dimensions (m)</h3>
<dl>
<EditableRow label="Length (m)">
<InlineEditableField value={yacht.lengthM} onSave={save('lengthM', numericString)} />
<InlineEditableField value={yacht.lengthM} onSave={saveDimension('lengthM')} />
</EditableRow>
<EditableRow label="Width (m)">
<InlineEditableField value={yacht.widthM} onSave={save('widthM', numericString)} />
<InlineEditableField value={yacht.widthM} onSave={saveDimension('widthM')} />
</EditableRow>
<EditableRow label="Draft (m)">
<InlineEditableField value={yacht.draftM} onSave={save('draftM', numericString)} />
<InlineEditableField value={yacht.draftM} onSave={saveDimension('draftM')} />
</EditableRow>
</dl>
</div>