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>
188 lines
6.5 KiB
TypeScript
188 lines
6.5 KiB
TypeScript
'use client';
|
|
|
|
import { useParams } from 'next/navigation';
|
|
import { useMemo } from 'react';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core';
|
|
|
|
import { PipelineColumn } from '@/components/interests/pipeline-column';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import { PIPELINE_STAGES, STAGE_LABELS } from '@/lib/constants';
|
|
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
|
|
|
interface InterestRow {
|
|
id: string;
|
|
clientName: string | null;
|
|
berthMooringNumber: string | null;
|
|
leadCategory: string | null;
|
|
pipelineStage: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
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();
|
|
|
|
// 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}`),
|
|
});
|
|
|
|
// 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[]> = {};
|
|
for (const stage of PIPELINE_STAGES) {
|
|
map[stage] = [];
|
|
}
|
|
for (const interest of interests) {
|
|
if (map[interest.pipelineStage]) {
|
|
map[interest.pipelineStage]!.push(interest);
|
|
}
|
|
}
|
|
return map;
|
|
}, [interests]);
|
|
|
|
async function handleDragEnd(event: DragEndEvent) {
|
|
const { active, over } = event;
|
|
if (!over || active.id === over.id) return;
|
|
|
|
// over.id is a stage when dropped on a column, or an item id when dropped on a card
|
|
let newStage = over.id as string;
|
|
|
|
// If dropped on a card (not a stage), find which stage that card belongs to
|
|
if (!PIPELINE_STAGES.includes(newStage as (typeof PIPELINE_STAGES)[number])) {
|
|
const targetInterest = interests.find((i) => i.id === newStage);
|
|
if (!targetInterest) return;
|
|
newStage = targetInterest.pipelineStage;
|
|
}
|
|
|
|
const interestId = active.id as string;
|
|
const currentInterest = interests.find((i) => i.id === interestId);
|
|
if (!currentInterest || currentInterest.pipelineStage === newStage) return;
|
|
|
|
// Optimistic update
|
|
queryClient.setQueryData<{ data: InterestRow[] }>(['interests-board', portSlug], (old) => {
|
|
if (!old) return old;
|
|
return {
|
|
...old,
|
|
data: old.data.map((i) => (i.id === interestId ? { ...i, pipelineStage: newStage } : i)),
|
|
};
|
|
});
|
|
|
|
try {
|
|
await apiFetch(`/api/v1/interests/${interestId}/stage`, {
|
|
method: 'PATCH',
|
|
body: { pipelineStage: newStage },
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
|
} catch {
|
|
// Revert optimistic update
|
|
queryClient.invalidateQueries({ queryKey: ['interests-board', portSlug] });
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
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
|
|
key={stage}
|
|
stage={stage}
|
|
label={STAGE_LABELS[stage]}
|
|
items={grouped[stage] ?? []}
|
|
/>
|
|
))}
|
|
</div>
|
|
</DndContext>
|
|
);
|
|
}
|