'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; } 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({ 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 = {}; 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
; } // 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 (
Couldn't load the pipeline board.{' '}
); } return ( {allData?.truncated ? (
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.
) : null}
{PIPELINE_STAGES.map((stage) => ( ))}
); }