import * as React from "react"; import { MoreVertical, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import { useThemeVars } from "@/lib/theme-provider"; import { formatCurrency } from "@/lib/format-currency"; import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { LeadCard, OpportunityCard } from "@/components/ui/opportunity-card"; import type { OpportunityCardProps } from "@/components/ui/opportunity-card"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; /** * KanbanColumn — WealthX DS (L4 Section) * * Kanban column wrapper for the Pipeline board. * Renders the stage header (name, count, total value, growth chip) and * a scrollable list of OpportunityCard items. * * **Does NOT include:** * - Drag-and-drop (react-dnd) — handled by the app's KanbanBoard wrapper. * - Infinite scroll logic — the app passes `loaderRef` and manages IntersectionObserver. * * Data source: `Stage` from `loan-crm.ts` via `useInfiniteColumnOpportunities`. */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface KanbanColumnStage { id: string; name: string; /** Number of opportunities currently in this stage. */ count: number; /** Total loan value across all opportunities in this stage. */ totalValue: number; /** * Growth metric for the column (e.g. new opps this week). * Positive → success color; negative/zero → destructive color. */ growth?: number | null; /** * Optional top-border accent color. * Pass a CSS variable string: `"var(--color-destructive)"`. * Defaults to `"var(--color-border)"`. */ accentColor?: string | null; /** * Optional color for the stage count number in the column header. * Pass a CSS variable string: `"var(--color-destructive)"`. * Defaults to `text-muted-foreground`. */ countColor?: string | null; } export interface KanbanColumnProps { // ── Column data ────────────────────────────────────────────── stage: KanbanColumnStage; /** Opportunity cards to render in this column. */ opportunities: OpportunityCardProps[]; // ── Column visual states (controlled by parent DnD) ───────── /** Whether this column is currently being dragged. Reduces opacity. */ isDragging?: boolean; /** * Whether a card is hovering over this column and can be dropped. * Shows a drop-target highlight. */ isDropTarget?: boolean; /** * Whether this is a fixed/system column (Leads, On Hold, High Priority). * Fixed columns show no Delete option in the menu. */ isDefault?: boolean; // ── Async / loading state ──────────────────────────────────── /** Shows a full-column skeleton/spinner on initial load. */ isLoading?: boolean; /** Shows a small spinner at the bottom while fetching the next page. */ isLoadingMore?: boolean; /** Whether there are more opportunities to load. */ hasMore?: boolean; /** * Ref for the loader sentinel element at the column bottom. * Attach an IntersectionObserver in the app to call `fetchNextPage()` when visible. */ loaderRef?: React.Ref; // ── Column actions ─────────────────────────────────────────── onEditColumn?: () => void; /** Only shown for non-default columns. */ onDeleteColumn?: () => void; /** * When provided, renders a `+` icon button in the column header. * Intended for the Leads column — opens the Add Lead flow. */ onAddLead?: () => void; // ── Card callbacks (forwarded to each OpportunityCard) ─────── onTaskToggle?: (opportunityId: string, taskId: string) => void; onMarkAsDone?: (opportunityId: string) => void; onMoveToNextStage?: (opportunityId: string) => void; onSendLoanApplication?: (opportunityId: string) => void; /** Fires when clicking the non-interactive card body. */ onCardClick?: (opportunityId: string) => void; onViewDetails?: (opportunityId: string) => void; onChangePriority?: (opportunityId: string) => void; onPutOnHold?: (opportunityId: string) => void; onPlaceBack?: (opportunityId: string) => void; /** When true, every card in this column renders in on-hold mode. */ isOnHoldColumn?: boolean; 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; /** Fires when a card is dropped onto this column (HTML5 DnD). */ onCardDrop?: (cardId: string) => void; /** Opportunity ID currently being submitted — disables that card's actions. */ submittingOpportunityId?: string | null; className?: string; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function growthColor(growth: number): string { // Use darkened text tokens — full-saturation tokens (#4CAF50, #F44336) fail 4.5:1 on white bg return growth > 0 ? "var(--color-success-text)" : "var(--color-destructive-text)"; } // --------------------------------------------------------------------------- // KanbanColumn // --------------------------------------------------------------------------- export function KanbanColumn({ stage, opportunities, isDragging = false, isDropTarget = false, isDefault = false, isLoading = false, isLoadingMore = false, hasMore = false, loaderRef, onEditColumn, onDeleteColumn, onAddLead, onTaskToggle, onMarkAsDone, onMoveToNextStage, onSendLoanApplication, onCardClick, onViewDetails, onChangePriority, onPutOnHold, onPlaceBack, isOnHoldColumn, onDeleteOpportunity, onSendToAI, onSyncCrmTarget, onCardDrop, submittingOpportunityId, className, }: KanbanColumnProps) { const themeVars = useThemeVars(); const accentColor = stage.accentColor ?? "var(--color-border)"; const hasMenu = onEditColumn || (!isDefault && onDeleteColumn); // ── HTML5 drag-and-drop drop-zone state ────────────────────── const [isDragOver, setIsDragOver] = React.useState(false); function handleDragOver(e: React.DragEvent) { if (!onCardDrop) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; setIsDragOver(true); } function handleDragLeave(e: React.DragEvent) { // Only clear when leaving the column entirely (not entering a child) if (!e.currentTarget.contains(e.relatedTarget as Node)) { setIsDragOver(false); } } function handleDrop(e: React.DragEvent) { if (!onCardDrop) return; e.preventDefault(); setIsDragOver(false); const cardId = e.dataTransfer.getData("text/plain"); if (cardId) onCardDrop(cardId); } return (
{/* ── Header ── */}
{/* Title row */}

{stage.count} {" "} {stage.name}

{onAddLead && ( } /> Add lead )} {hasMenu && ( {onEditColumn && ( Edit column settings )} {!isDefault && onDeleteColumn && ( <> {onEditColumn && } Delete column )} )}
{/* Stats row */}
{stage.growth != null ? ( {stage.growth > 0 ? "+" : ""} {stage.growth} ) : ( )} {formatCurrency(stage.totalValue)}
{/* ── Body ── */}
{/* Drop target hint — shown at top when a card is dragged over */} {(isDropTarget || isDragOver) && (
Drop here → {stage.name}
)} {isLoading ? (
) : opportunities.length === 0 ? (

No opportunities in this stage

) : ( <> {opportunities.map((opp) => onSendLoanApplication ? ( onSendLoanApplication(opp.id)} onDelete={ onDeleteOpportunity ? () => onDeleteOpportunity(opp.id) : undefined } onSendToAI={onSendToAI ? () => onSendToAI(opp) : undefined} isSubmitting={submittingOpportunityId === opp.id} /> ) : ( { e.dataTransfer.setData("text/plain", opp.id); e.dataTransfer.effectAllowed = "move"; } : undefined } onCardClick={ onCardClick ? () => onCardClick(opp.id) : undefined } onTaskToggle={ onTaskToggle ? (taskId) => onTaskToggle(opp.id, taskId) : undefined } onMarkAsDone={ onMarkAsDone ? () => onMarkAsDone(opp.id) : undefined } onMoveToNextStage={ onMoveToNextStage ? () => onMoveToNextStage(opp.id) : undefined } onViewDetails={ onViewDetails ? () => onViewDetails(opp.id) : undefined } onChangePriority={ onChangePriority ? () => onChangePriority(opp.id) : undefined } isOnHold={isOnHoldColumn} onPutOnHold={ onPutOnHold ? () => onPutOnHold(opp.id) : undefined } onPlaceBack={ onPlaceBack ? () => onPlaceBack(opp.id) : undefined } onDelete={ onDeleteOpportunity ? () => onDeleteOpportunity(opp.id) : undefined } onSendToAI={onSendToAI} onSyncCrmTarget={ onSyncCrmTarget ? (targetId) => onSyncCrmTarget(opp.id, targetId) : undefined } isSubmitting={submittingOpportunityId === opp.id} /> ), )} {/* Infinite-scroll loader sentinel */} {hasMore && (
{isLoadingMore && ( )}
)} )}
); }