Files
pn-new-crm/src/components/interests/pipeline-board.tsx
Matt 3e4d9d6310 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>
2026-05-07 20:59:28 +02:00

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&apos;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&apos;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>
);
}