import { useState } from "react"; import { Phone, Mail, User, Users, Calendar, Check, Copy, Link2, MoreVertical, Clock, ArrowRight, ChevronDown, ChevronRight, Sparkles, RefreshCw, Loader2, CheckCircle2, AlertCircle, } from "lucide-react"; import { cn } from "@/lib/utils"; import { formatCurrency } from "@/lib/format-currency"; import { formatDateShort, daysUntil } from "@/lib/format-date"; import { Badge } from "@/components/ui/badge"; import { Button, buttonVariants } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { TaskCheckItem } from "@/components/ui/pipeline-primitives"; import { Progress } from "@/components/ui/progress"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; /** * OpportunityCard — WealthX DS (L3 Card) * * Kanban card for a single loan opportunity in the Pipeline board. * Renders customer info, loan metadata, task progress, and quick actions. * * This component is display-only — drag-and-drop is handled by the * KanbanColumn wrapper in the app. * * Data source: `listLoans()` → `Opportunity` in `loan-crm.ts` */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type Priority = "HIGH" | "MEDIUM" | "LOW" | "NONE"; export interface OpportunityTask { id: string; title: string; completed: boolean; } export type CrmSyncStatus = "idle" | "syncing" | "synced" | "error"; /** A connected Broker CRM (e.g. Quicklii) this opportunity can sync/activate to. */ export interface CrmSyncTarget { id: string; name: string; status: CrmSyncStatus; /** ISO date string of the last successful sync, shown once `status` is `"synced"`. */ lastSyncedAt?: string; } export interface OpportunityCardProps { // ── Core data ──────────────────────────────────────────────── id: string; customerName: string; customerPhone?: string; customerEmail?: string; /** Number of additional co-applicants beyond the primary contact. */ additionalContacts?: number; loanType?: string; loanPurposeLabel?: string; /** Loan amount in dollars. */ amount: number; /** ISO date string of the opportunity creation date. */ date: string; // ── Status ─────────────────────────────────────────────────── priority: Priority; /** Days the opportunity has been in its current stage. */ daysSinceColumnChanged?: number; /** * Stage threshold for MEDIUM (orange) warning color. * Comes from `Stage.warningDays`. */ warningDays?: number; /** * Stage threshold for HIGH (red) priority color. * Comes from `Stage.priorityDays`. */ priorityDays?: number; /** ISO date string — if set, the card shows an "On hold until …" banner. */ onHoldTo?: string | null; /** * When `true`, the card renders in on-hold mode regardless of `onHoldTo`. * Timer/dot hidden, dropdown hides "Change priority"/"Put on hold", action * area shows "Place Back" only. Set by KanbanColumn for every card in the * on-hold column — no need to stamp `onHoldTo` just to activate the mode. */ isOnHold?: boolean; /** Whether this opportunity represents a modification of a completed loan. */ isModifyCompletedLoan?: boolean; // ── Tasks ──────────────────────────────────────────────────── tasks?: OpportunityTask[]; /** Title of the next pending task (shown as a hint below the task list). */ nextTask?: string | null; // ── Lead data (passed through by KanbanColumn to LeadCard) ─── /** * URL for the loan application form. * Not rendered by OpportunityCard — used by KanbanColumn to forward * to LeadCard when this opportunity is in the Leads stage. */ loanApplicationUrl?: string; // ── Actions ────────────────────────────────────────────────── /** Fires when clicking the non-interactive card body (e.g. to open the summary drawer). */ onCardClick?: () => void; onViewDetails?: () => void; onTaskToggle?: (taskId: string) => void; onMarkAsDone?: () => void; onMoveToNextStage?: () => void; onChangePriority?: () => void; onDelete?: () => void; onPutOnHold?: () => void; /** * Callback for the "Place Back" button — shown only when the card is in the * On Hold column. When provided, the card renders in on-hold column mode: * priority dot/timer are hidden, dropdown hides "Change priority" and "Put on hold", * and the action area shows "Place Back" instead of "Move to next stage"/"Mark as done". */ onPlaceBack?: () => void; /** * Sends this opportunity's data to the AI Chrome extension. * Shown as a Sparkles icon button on the card header, visible on hover. */ onSendToAI?: (opportunity: OpportunityCardProps) => void; /** * Connected Broker CRMs (e.g. Quicklii) this opportunity can sync/activate to. * Omit or leave empty to hide the sync button entirely. */ crmSyncTargets?: CrmSyncTarget[]; /** Fires when the broker activates or re-syncs a specific CRM target. */ onSyncCrmTarget?: (targetId: string) => void; /** Shows a loading state on action buttons while an async op is in flight. */ isSubmitting?: boolean; /** Whether the card is draggable (HTML5 native DnD). */ draggable?: boolean; onDragStart?: React.DragEventHandler; className?: string; /** * Render mode for the card: * - `"deal"` (default) — full opportunity info with task accordion. * - `"task"` — compact task-first layout (next task, progress, deal ref). */ viewMode?: "deal" | "task"; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /** Used for the priority dot background — full-saturation token is fine on colored bg. */ const PRIORITY_COLORS: Record = { HIGH: "var(--color-destructive)", MEDIUM: "var(--color-warning)", LOW: "var(--color-success)", NONE: "var(--color-muted-foreground)", }; /** Used for text / icons on white/light backgrounds — darkened tokens ensure 4.5:1 contrast. */ const PRIORITY_TEXT_COLORS: Record = { HIGH: "var(--color-destructive-text)", MEDIUM: "var(--color-warning-text)", LOW: "var(--color-success-text)", NONE: "var(--color-muted-foreground)", }; function resolvePriority( days: number | undefined, warningDays: number | undefined, priorityDays: number | undefined, priority: Priority, ): Priority { if (days === undefined) return priority; if (priorityDays !== undefined && days >= priorityDays) return "HIGH"; if (warningDays !== undefined && days >= warningDays) return "MEDIUM"; if (warningDays !== undefined || priorityDays !== undefined) return "LOW"; return priority; } function formatLoanType(type: string): string { return type .split("-") .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); } /** Quick Loan applications label the purpose with a full timestamp, which is * far longer than a normal purpose label — cap it so it always fits the * header badge alongside the action icons. */ const LOAN_PURPOSE_LABEL_MAX_LENGTH = 17; function truncateLoanPurposeLabel(label: string): string { return label.length > LOAN_PURPOSE_LABEL_MAX_LENGTH ? `${label.slice(0, LOAN_PURPOSE_LABEL_MAX_LENGTH)}…` : label; } // --------------------------------------------------------------------------- // SendToAIButton — shared Sparkles button with tooltip // --------------------------------------------------------------------------- function SendToAIButton({ onClick }: { onClick: () => void }) { return ( } /> Send to AI assistant ); } // --------------------------------------------------------------------------- // CrmSyncButton — activate/re-sync this opportunity's plan to connected // Broker CRMs (e.g. Quicklii). Lists every target so the design scales past // a single connected CRM without changing the trigger. // --------------------------------------------------------------------------- const CRM_STATUS_ICON: Record = { idle: null, syncing: ( ), synced: ( ), error: ( ), }; function crmTargetActionLabel(status: CrmSyncStatus): string { if (status === "synced") return "Sync Again"; if (status === "error") return "Retry"; if (status === "syncing") return "Syncing…"; return "First Sync"; } type CrmAggregateStatus = CrmSyncStatus | "partial"; /** * Worst-case status across all targets, drives the trigger's aggregate dot. * "partial" (distinct from "idle") flags "some targets synced, some not" so * it doesn't read the same as "nothing synced yet". */ function crmAggregateStatus(targets: CrmSyncTarget[]): CrmAggregateStatus { if (targets.some((t) => t.status === "error")) return "error"; if (targets.some((t) => t.status === "syncing")) return "syncing"; if (targets.every((t) => t.status === "synced")) return "synced"; if (targets.some((t) => t.status === "synced")) return "partial"; return "idle"; } const CRM_AGGREGATE_DOT_COLOR: Record = { idle: undefined, partial: "var(--color-muted-foreground)", syncing: "var(--color-warning)", synced: "var(--color-success)", error: "var(--color-destructive)", }; function CrmSyncButton({ targets, onSync, }: { targets: CrmSyncTarget[]; onSync?: (targetId: string) => void; }) { const aggregate = crmAggregateStatus(targets); const dotColor = CRM_AGGREGATE_DOT_COLOR[aggregate]; return ( }> {dotColor && ( {targets.map((target) => ( onSync?.(target.id)} > {target.name} {CRM_STATUS_ICON[target.status]} {crmTargetActionLabel(target.status)} ))} Sync to CRM ); } // --------------------------------------------------------------------------- // TaskViewCard — compact task-first card (viewMode === "task") // // Three states driven by task progress: // A) Has next task → shows task title + AI agent + segmented progress // B) All tasks done → shows ✓ message + full green progress bar // C) No tasks → shows fallback muted text // --------------------------------------------------------------------------- function TaskViewCard({ id, customerName, customerPhone, customerEmail, additionalContacts, loanType, loanPurposeLabel, amount, date, priority, daysSinceColumnChanged, warningDays, priorityDays, onHoldTo, isOnHold, isModifyCompletedLoan, tasks = [], nextTask, onCardClick, onViewDetails, onTaskToggle, onMarkAsDone, onMoveToNextStage, onChangePriority, onDelete, onPutOnHold, onPlaceBack, onSendToAI, crmSyncTargets, onSyncCrmTarget, isSubmitting = false, draggable = false, onDragStart, className, }: OpportunityCardProps): React.JSX.Element { const resolvedPriority = resolvePriority( daysSinceColumnChanged, warningDays, priorityDays, priority, ); const priorityColor = PRIORITY_COLORS[resolvedPriority]; const completedCount = tasks.filter((t) => t.completed).length; const totalCount = tasks.length; const hasTasks = totalCount > 0; const allDone = hasTasks && completedCount === totalCount; const isOnHoldCard = isOnHold ?? Boolean(onHoldTo); const [subtasksExpanded, setSubtasksExpanded] = useState(false); const hasMenu = !!( onViewDetails || onDelete || (!isOnHoldCard && (onChangePriority || onPutOnHold)) ); const stopProp = (e: React.MouseEvent): void => { e.stopPropagation(); }; // Compact deal reference: "John Smith · Buy a House · $850,000" const purposeLabel = loanPurposeLabel ?? (loanType ? formatLoanType(loanType) : null); const dealRef = [ customerName, purposeLabel, amount > 0 ? formatCurrency(amount) : null, ] .filter(Boolean) .join(" · "); // Task header content (no nested ternary — avoids lint error) let taskHeader: React.ReactNode; if (allDone) { taskHeader = (

All tasks complete

); } else if (nextTask) { taskHeader = (

{nextTask}

); } else { taskHeader = (

No tasks defined for this stage

); } return (
{/* ── Task header + priority dot ── */}
{taskHeader}
{/* ── Progress (only when tasks exist) ── */} {hasTasks && (
{completedCount}/{totalCount}
)} {/* ── Collapsible subtask list ── */} {hasTasks && (
e.stopPropagation()} > {subtasksExpanded && (
    {tasks.map((task) => ( onTaskToggle?.(task.id)} size="xs" /> ))}
)}
)} {/* ── Deal reference line ── */}

{dealRef}

{/* ── Footer: days badge + AI button + 3-dot menu ── */}
{daysSinceColumnChanged !== undefined && ( {daysSinceColumnChanged}d )}
{onSendToAI && ( onSendToAI({ id, customerName, customerPhone, customerEmail, additionalContacts, loanType, loanPurposeLabel, amount, date, priority, daysSinceColumnChanged, warningDays, priorityDays, onHoldTo, isOnHold, isModifyCompletedLoan, tasks, nextTask, }) } /> )} {crmSyncTargets && crmSyncTargets.length > 0 && ( )} {hasMenu && ( {onViewDetails && ( View details )} {!isOnHoldCard && onChangePriority && ( Change priority )} {!isOnHoldCard && onPutOnHold && ( Put on hold )} {onDelete && ( <> Delete )} )}
{/* ── Action button ── */} {isOnHoldCard && onPlaceBack && (
)} {!onHoldTo && allDone && onMoveToNextStage && (
)} {!onHoldTo && !allDone && nextTask && onMarkAsDone && (
)}
); } // --------------------------------------------------------------------------- // OpportunityCard // --------------------------------------------------------------------------- export function OpportunityCard({ id, customerName, customerPhone, customerEmail, additionalContacts, loanType, loanPurposeLabel, amount, date, priority, daysSinceColumnChanged, warningDays, priorityDays, onHoldTo, isOnHold, isModifyCompletedLoan, tasks = [], nextTask, onCardClick, onViewDetails, onTaskToggle, onMarkAsDone, onMoveToNextStage, onChangePriority, onDelete, onPutOnHold, onPlaceBack, onSendToAI, crmSyncTargets, onSyncCrmTarget, isSubmitting = false, draggable = false, onDragStart, className, viewMode = "deal", }: OpportunityCardProps) { const isOnHoldCard = isOnHold ?? Boolean(onHoldTo); if (viewMode === "task") { return ( ); } const resolvedPriority = resolvePriority( daysSinceColumnChanged, warningDays, priorityDays, priority, ); const priorityColor = PRIORITY_COLORS[resolvedPriority]; const priorityTextColor = PRIORITY_TEXT_COLORS[resolvedPriority]; const completedCount = tasks.filter((t) => t.completed).length; const hasTasks = tasks.length > 0; const hasActions = !!(isOnHoldCard ? onPlaceBack : onMarkAsDone || onMoveToNextStage); const hasMenu = !!( onViewDetails || onDelete || (!isOnHoldCard && (onChangePriority || onPutOnHold)) ); const stopProp = (e: React.MouseEvent): void => { e.stopPropagation(); }; return (
{/* ── On-hold banner ── */} {onHoldTo && (
On hold until {formatDateShort(onHoldTo)} {daysUntil(onHoldTo) > 0 ? ` (${daysUntil(onHoldTo)}d left)` : ""}
)} {/* ── Modify-completed-loan tag ── */} {isModifyCompletedLoan && (
Modify completed loan
)} {/* ── Header: loan purpose + amount + menu ── */}
{(loanPurposeLabel || loanType) && ( {truncateLoanPurposeLabel( loanPurposeLabel ?? formatLoanType(loanType!), )} )} {formatCurrency(amount)}
{onSendToAI && ( onSendToAI({ id, customerName, customerPhone, customerEmail, additionalContacts, loanType, loanPurposeLabel, amount, date, priority, daysSinceColumnChanged, warningDays, priorityDays, onHoldTo, isOnHold, isModifyCompletedLoan, tasks, nextTask, }) } /> )} {crmSyncTargets && crmSyncTargets.length > 0 && ( )} {hasMenu && ( {onViewDetails && ( View details )} {!isOnHoldCard && onChangePriority && ( Change priority )} {!isOnHoldCard && onPutOnHold && ( Put on hold )} {onDelete && ( <> Delete )} )}
{/* ── Customer info ── */}
{customerName} {additionalContacts && additionalContacts > 0 ? ( ) : ( )}
{customerPhone && ( )} {customerEmail && ( )}
{/* ── Metadata: date (left) | days-since chip + priority dot (right, adjacent) ── */}
{!isOnHoldCard && daysSinceColumnChanged !== undefined && ( <>
{/* ── Tasks: segmented progress bar + animated accordion ── */} {hasTasks && ( // stopPropagation: accordion expand/collapse + task checkboxes must not bubble to onCardClick
{/* Segmented bar — one segment per task, filled = completed */}
{tasks.map((t, i) => (
))}
{/* Accordion — animated expand/collapse using shadcn Accordion */} Tasks ({completedCount}/{tasks.length})
{tasks.map((task) => ( onTaskToggle(task.id) : undefined } disabled={isSubmitting} /> ))}
{/* Next task hint — only visible when accordion is collapsed */} {nextTask && (
)}
)} {/* ── Action buttons ── */} {hasActions && ( // stopPropagation: button clicks must not bubble to onCardClick
{isOnHoldCard ? ( ) : ( <> {onMoveToNextStage && ( )} {onMarkAsDone && ( )} )}
)}
); } // --------------------------------------------------------------------------- // LeadCard — simplified card for the Leads stage // // Leads are new clients who haven't started their loan application yet. // No loan amount, no purpose tag, no joint badge, no tasks, no date. // Primary action: send them the loan application link. // --------------------------------------------------------------------------- function normalizeUrl(url: string): string { return `https://${url.replace(/^https?:\/\//, "")}`; } /** Shortened + copyable loan application URL row. */ function LoanApplicationLink({ url }: { url: string }) { const [copied, setCopied] = useState(false); const href = normalizeUrl(url); function handleCopy() { navigator.clipboard.writeText(href).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); } return (

Or the link below to fill out the loan application directly.

{url}
); } export interface LeadCardProps { id: string; customerName: string; customerPhone?: string; customerEmail?: string; onSendLoanApplication?: () => void; loanApplicationUrl?: string; onDelete?: () => void; onSendToAI?: () => void; isSubmitting?: boolean; className?: string; } export function LeadCard({ customerName, customerPhone, customerEmail, onSendLoanApplication, loanApplicationUrl, onDelete, onSendToAI, isSubmitting = false, className, }: LeadCardProps) { return (
{/* ── Customer info + delete menu ── */}
{customerName} {customerPhone && ( )} {customerEmail && ( )}
{onSendToAI && } {onDelete && ( Delete )}
{/* ── Send loan application action ── */} {onSendLoanApplication && (
{loanApplicationUrl && ( )}
)}
); }