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:
@@ -7,8 +7,8 @@ import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core';
|
||||
|
||||
import { PipelineColumn } from '@/components/interests/pipeline-column';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { usePipelineStore } from '@/stores/pipeline-store';
|
||||
import { PIPELINE_STAGES, STAGE_LABELS } from '@/lib/constants';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
|
||||
interface InterestRow {
|
||||
id: string;
|
||||
@@ -19,28 +19,77 @@ interface InterestRow {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function PipelineBoard() {
|
||||
interface BoardResponse {
|
||||
data: InterestRow[];
|
||||
truncated: boolean;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface PipelineBoardProps {
|
||||
/** Filter values from the parent's FilterBar — passed through to the
|
||||
* /api/v1/interests/board endpoint. Subset of listInterests filters
|
||||
* (no pipelineStage, no includeArchived). Optional; board works
|
||||
* fine without filters. */
|
||||
filters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function PipelineBoard({ filters }: PipelineBoardProps = {}) {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const queryClient = useQueryClient();
|
||||
const { boardFilters } = usePipelineStore();
|
||||
|
||||
const { data: allData, isLoading } = useQuery<{ data: InterestRow[] }>({
|
||||
queryKey: ['interests-board', portSlug],
|
||||
queryFn: () => apiFetch('/api/v1/interests?limit=500'),
|
||||
// Build the board endpoint URL with the supported filter subset.
|
||||
// pipelineStage + includeArchived are intentionally not threaded
|
||||
// through — see boardFiltersSchema on the backend. Stable JSON-string
|
||||
// form is reused as the queryKey so React Query caches per filter combo.
|
||||
const queryString = useMemo(() => {
|
||||
if (!filters) return '';
|
||||
const params = new URLSearchParams();
|
||||
const pick = (k: string) => {
|
||||
const v = filters[k];
|
||||
if (v === null || v === undefined || v === '' || v === false) return;
|
||||
if (Array.isArray(v)) {
|
||||
if (v.length === 0) return;
|
||||
params.set(k, v.join(','));
|
||||
} else {
|
||||
params.set(k, String(v));
|
||||
}
|
||||
};
|
||||
pick('search');
|
||||
pick('leadCategory');
|
||||
pick('source');
|
||||
pick('eoiStatus');
|
||||
pick('tagIds');
|
||||
const s = params.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}, [filters]);
|
||||
|
||||
const boardQueryKey = ['interests-board', portSlug, queryString] as const;
|
||||
// Dedicated board endpoint — bypasses the paginated list's max(100)
|
||||
// cap, projects only the 5 fields PipelineCard renders, and hard-caps
|
||||
// at 5000 server-side. If `truncated: true`, surface a banner so the
|
||||
// rep knows the board isn't showing every active deal.
|
||||
const {
|
||||
data: allData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery<BoardResponse>({
|
||||
queryKey: boardQueryKey,
|
||||
queryFn: () => apiFetch(`/api/v1/interests/board${queryString}`),
|
||||
});
|
||||
|
||||
const interests = useMemo(() => {
|
||||
if (!allData?.data) return [];
|
||||
return allData.data.filter((i) => {
|
||||
if (boardFilters.leadCategory && i.leadCategory !== boardFilters.leadCategory) return false;
|
||||
if (boardFilters.search) {
|
||||
const q = boardFilters.search.toLowerCase();
|
||||
if (!i.clientName?.toLowerCase().includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [allData, boardFilters]);
|
||||
// Invalidate the entire ['interests-board', portSlug, *] family so
|
||||
// realtime events refresh whatever filter combo is currently active.
|
||||
// Using the prefix keeps stale per-filter caches from lingering after
|
||||
// the underlying data changes elsewhere in the app.
|
||||
useRealtimeInvalidation({
|
||||
'interest:created': [['interests-board', portSlug]],
|
||||
'interest:updated': [['interests-board', portSlug]],
|
||||
'interest:stageChanged': [['interests-board', portSlug]],
|
||||
'interest:archived': [['interests-board', portSlug]],
|
||||
});
|
||||
|
||||
const interests = useMemo(() => allData?.data ?? [], [allData]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map: Record<string, InterestRow[]> = {};
|
||||
@@ -98,8 +147,31 @@ export function PipelineBoard() {
|
||||
return <div className="flex gap-3 overflow-x-auto pb-4 animate-pulse h-64" />;
|
||||
}
|
||||
|
||||
// Surface fetch failures instead of silently rendering nine "Empty"
|
||||
// columns, which is indistinguishable from "no interests yet" and was
|
||||
// exactly the bug that hid this view's silent failure for so long.
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-4 py-6 text-sm text-destructive">
|
||||
Couldn't load the pipeline board.{' '}
|
||||
<button
|
||||
className="underline underline-offset-2"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: boardQueryKey })}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
{allData?.truncated ? (
|
||||
<div className="mb-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
Showing the {allData.total.toLocaleString()} most-recently-updated interests. Older active
|
||||
deals aren't on the board — archive completed work to keep the kanban readable.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex gap-3 overflow-x-auto pb-4">
|
||||
{PIPELINE_STAGES.map((stage) => (
|
||||
<PipelineColumn
|
||||
|
||||
Reference in New Issue
Block a user