import * as React from "react"; import { RefreshCw, Search, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { KanbanColumn } from "@/components/ui/kanban-column"; import type { KanbanColumnStage } from "@/components/ui/kanban-column"; import type { OpportunityCardProps } from "@/components/ui/opportunity-card"; import { pipelinePrimaryColor } from "@/lib/pipeline-colors"; /** * PipelineBoard — WealthX DS (L5 Board) * * Full Pipeline / Loan CRM kanban board. * Renders a horizontal list of KanbanColumns with a toolbar (search + column filters). * * **Does NOT include:** * - Drag-and-drop (react-dnd) — handled by the app's DndProvider + KanbanColumn wiring. * - Data fetching — the app supplies `columns` with pre-loaded opportunities. * - Modals (edit column, delete, change priority, put on hold) — opened by the app. * * ### Layer: L5 Board * ``` * PipelineBoard (L5) * └── KanbanColumn (L4) ×N * └── OpportunityCard (L3) ×N * └── TaskCheckItem (L2) ×N * ``` */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface PipelineBoardColumn { /** Unique column key (used as React key — may differ from stage.id). */ key: string; stage: KanbanColumnStage; opportunities: OpportunityCardProps[]; /** * When set, each card in this column shows a "Send Loan Application Request" * button. Intended for the Leads stage only — cards there have no tasks yet. */ onSendLoanApplication?: (opportunityId: string) => void; /** DnD index (-1 for High Priority fixed column). */ columnIndex?: number; /** Whether this column is being dragged (controlled by app DnD). */ isDragging?: boolean; /** Whether a card is hovering over this column (controlled by app DnD). */ isDropTarget?: boolean; /** Whether this is a fixed/system column (no Delete in menu). */ isDefault?: boolean; /** When true, every card in this column renders in on-hold mode. */ isOnHold?: boolean; /** * When true, this column is rendered in a fixed left section and does not * scroll horizontally with the rest of the board. Use for the High Priority * column, which must always be visible. */ isPinned?: boolean; isLoading?: boolean; isLoadingMore?: boolean; hasMore?: boolean; loaderRef?: React.Ref; /** * When provided, renders a `+` icon button in the column header. * Intended for the Leads column — opens the Add Lead flow. */ onAddLead?: () => void; } export interface PipelineBoardProps { // ── Data ───────────────────────────────────────────────────── columns: PipelineBoardColumn[]; // ── Toolbar ────────────────────────────────────────────────── /** Current keyword search value (controlled). */ searchValue?: string; onSearchChange?: (value: string) => void; /** * Column filter chips. Each item is a label shown as a chip. * Pass `"View All"` as a special value to show/select all columns. */ filterOptions?: string[]; activeFilters?: string[]; onFilterChange?: (filter: string) => void; // ── Column callbacks ───────────────────────────────────────── onEditColumn?: (stageId: string) => void; onDeleteColumn?: (stageId: string) => void; onRefresh?: () => void; // ── Card callbacks ─────────────────────────────────────────── /** * Fires when a card is dragged from one column and dropped into another. * The app is responsible for updating `columns` accordingly. */ onMoveCard?: (cardId: string, toColumnKey: string) => void; /** Fires when clicking the non-interactive card body. */ onCardClick?: (opportunityId: string) => void; onTaskToggle?: (opportunityId: string, taskId: string) => void; onMarkAsDone?: (opportunityId: string) => void; onMoveToNextStage?: (opportunityId: string) => void; onViewDetails?: (opportunityId: string) => void; onChangePriority?: (opportunityId: string) => void; onPutOnHold?: (opportunityId: string) => void; onPlaceBack?: (opportunityId: string) => void; onDeleteOpportunity?: (opportunityId: string) => void; onSendToAI?: (opp: OpportunityCardProps) => void; /** Fires when a broker activates/re-syncs a CRM target on a card (BUILD-2228). */ onSyncCrmTarget?: (opportunityId: string, targetId: string) => void; submittingOpportunityId?: string | null; className?: string; } // --------------------------------------------------------------------------- // Toolbar // --------------------------------------------------------------------------- interface ToolbarProps { searchValue: string; onSearchChange: (v: string) => void; filterOptions: string[]; activeFilters: string[]; onFilterChange: (f: string) => void; onRefresh?: () => void; } function Toolbar({ searchValue, onSearchChange, filterOptions, activeFilters, onFilterChange, onRefresh, }: ToolbarProps) { return (
{/* Search */}
onSearchChange(e.target.value)} className="h-8 pl-8 text-sm" /> {searchValue && ( )}
{/* Column filter toggle group */} {filterOptions.length > 0 && ( { const toggled = newValues.find((v) => !activeFilters.includes(v)) ?? activeFilters.find((v) => !newValues.includes(v)); if (toggled) onFilterChange(toggled); }} > {filterOptions.map((option) => ( {option} ))} )} {/* Refresh */} {onRefresh && ( )}
); } // --------------------------------------------------------------------------- // PipelineBoard // --------------------------------------------------------------------------- export function PipelineBoard({ columns, searchValue = "", onSearchChange, filterOptions = [], activeFilters = [], onFilterChange, onRefresh, onEditColumn, onDeleteColumn, onMoveCard, onCardClick, onTaskToggle, onMarkAsDone, onMoveToNextStage, onViewDetails, onChangePriority, onPutOnHold, onPlaceBack, onDeleteOpportunity, onSendToAI, onSyncCrmTarget, submittingOpportunityId, className, }: PipelineBoardProps) { const hasToolbar = onSearchChange || (filterOptions.length > 0 && onFilterChange); const pinnedCols = columns.filter((c) => c.isPinned); const scrollableCols = columns.filter((c) => !c.isPinned); // Columns without a custom accentColor get primary color with descending // opacity — matching PipelineChart's scheme via the shared utility. const noAccentCols = scrollableCols.filter((c) => !c.stage.accentColor); const noAccentCount = noAccentCols.length; const computedAccents = new Map( noAccentCols.map((col, i) => [ col.key, pipelinePrimaryColor(i, noAccentCount), ]), ); const renderColumn = (col: PipelineBoardColumn) => { const resolvedStage = computedAccents.has(col.key) ? { ...col.stage, accentColor: computedAccents.get(col.key) } : col.stage; return ( onEditColumn(col.stage.id) : undefined } onDeleteColumn={ onDeleteColumn && !col.isDefault && !col.isPinned ? () => onDeleteColumn(col.stage.id) : undefined } onCardDrop={ onMoveCard ? (cardId) => onMoveCard(cardId, col.key) : undefined } onCardClick={onCardClick} onTaskToggle={onTaskToggle} onMarkAsDone={onMarkAsDone} onMoveToNextStage={onMoveToNextStage} onSendLoanApplication={col.onSendLoanApplication} onViewDetails={onViewDetails} onChangePriority={onChangePriority} isOnHoldColumn={col.isOnHold} onPutOnHold={onPutOnHold} onPlaceBack={onPlaceBack} onDeleteOpportunity={onDeleteOpportunity} onSendToAI={onSendToAI} onSyncCrmTarget={onSyncCrmTarget} onAddLead={col.onAddLead} submittingOpportunityId={submittingOpportunityId} /> ); }; return (
{/* ── Toolbar ── */} {hasToolbar && ( {})} filterOptions={filterOptions} activeFilters={activeFilters} onFilterChange={onFilterChange ?? (() => {})} onRefresh={onRefresh} /> )} {/* ── Board area ── */}
{/* Pinned columns — always visible, do not scroll */} {pinnedCols.length > 0 && (
{pinnedCols.map(renderColumn)}
)} {/* Scrollable columns */}
{scrollableCols.map(renderColumn)} {columns.length === 0 && (

No columns to display.

)}
); }