import * as React from "react"; import { useState } from "react"; import { AlertCircle, CheckCircle2, Download, FileText, Mail, Pencil, Phone, } from "lucide-react"; import { Badge } from "./badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./select"; import { Button } from "./button"; import { Checkbox } from "./checkbox"; import { cn } from "@/lib/utils"; import { formatCurrency } from "@/lib/format-currency"; import { PROPERTY_ASSET_TYPES } from "@/lib/opportunity-constants"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "./tabs"; import { FinancialDetailField, FinancialLineItem, FinancialSectionLabel, FinancialSubtotalFrame, FinancialSubtotalBlock, } from "./financial-primitives"; import type { DebtCardProps, PropertyCardProps } from "./financial-cards"; import { AboutCard, DebtCard, IncomeCard, ExpensesCard, PropertyCard, } from "./financial-cards"; import { EditLoanScenarioModal, EditAssetsModal, EditDebtsModal, EditAboutApplicantModal, EditIncomeModal, EditExpensesModal, } from "./opportunity-edit-modals"; import type { LoanScenarioFormData, AssetLineItem, DebtLineItem, AboutApplicantFormData, IncomeFormData, ExpensesFormData, } from "./opportunity-edit-modals"; /** * OpportunitySummaryTab — WealthX DS (Level 5) * * Full Summary tab for the OpportunityDetailsDrawer. * Renders: * - Hero Band: deal-at-a-glance strip (applicant names, LVR, net surplus, * serviceability signal, contact shortcuts) * - Loan Scenario card (collapsible, always above the sub-tabs) * - Overview / Main / Co applicant sub-tabs (filled variant, full width) * - All inline edit modals (managed internally, scoped to drawer portal) * * Pass as the `summary` slot of `OpportunityDetailsDrawer`. */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface DocumentItem { id: string; name: string; documentType: string; /** Which applicant uploaded this document. */ uploadedBy: "main" | "co"; uploadedAt: string; status: "verified" | "pending" | "rejected"; /** PDF URL — opens in browser when title is clicked. */ url?: string; /** Document checklist category this satisfies (e.g. "Income Verification"). */ checklistItem?: string; } export interface OpportunitySummaryTabProps { /** Whether this is a joint application (shows Co-Applicant tab). */ isJoint?: boolean; // ── Loan scenario ────────────────────────────────────────────────────────── loanScenario: LoanScenarioFormData; onLoanScenarioChange?: (data: LoanScenarioFormData) => void; // ── Joint assets & debts ────────────────────────────────────────────────── assets: AssetLineItem[]; onAssetsChange?: (items: AssetLineItem[]) => void; debts: DebtLineItem[]; onDebtsChange?: (items: DebtLineItem[]) => void; // ── Main applicant ──────────────────────────────────────────────────────── mainAbout: AboutApplicantFormData; onMainAboutChange?: (data: AboutApplicantFormData) => void; mainIncome: IncomeFormData; onMainIncomeChange?: (data: IncomeFormData) => void; mainExpenses: ExpensesFormData; onMainExpensesChange?: (data: ExpensesFormData) => void; // ── Co-applicant (only relevant when isJoint) ───────────────────────────── coAbout?: AboutApplicantFormData; onCoAboutChange?: (data: AboutApplicantFormData) => void; coIncome?: IncomeFormData; onCoIncomeChange?: (data: IncomeFormData) => void; coExpenses?: ExpensesFormData; onCoExpensesChange?: (data: ExpensesFormData) => void; // ── Documents ───────────────────────────────────────────────────────────── documents?: DocumentItem[]; /** Called with the selected documents when advisor clicks Download. */ onDocumentsDownload?: (docs: DocumentItem[]) => void; /** Called when advisor changes a document's verification status. */ onDocumentStatusChange?: ( docId: string, status: DocumentItem["status"], ) => void; } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- function toMonthly(amount: number, freq: "Monthly" | "Weekly") { return freq === "Monthly" ? amount : (amount * 52) / 12; } /** Map an AssetLineItem for a property to PropertyCardProps. */ function assetToPropertyCard(item: AssetLineItem): PropertyCardProps { return { address: item.address || item.assetType, type: item.usedAs, estimated: formatCurrency(item.value), isLinkedToBank: false, }; } /** Map a DebtLineItem to DebtCardProps. */ function debtToCard(debt: DebtLineItem): DebtCardProps { return { lenderName: debt.lender || debt.debtType, currentLoanAmount: formatCurrency(debt.amountOwing), interestRate: debt.interestRate ? `${debt.interestRate}% p.a.` : undefined, originalLoanAmount: debt.originalLoanAmount ? formatCurrency(debt.originalLoanAmount) : undefined, monthlyRepayments: debt.repaymentAmount ? `${formatCurrency(debt.repaymentAmount)} / ${debt.repaymentFrequency}` : undefined, }; } /** Tailwind text-color class for the LVR metric. */ function lvrColorClass(pct: number): string { if (pct <= 0) return "text-muted-foreground"; if (pct < 80) return "text-emerald-600"; if (pct < 90) return "text-amber-600"; return "text-destructive"; } /** Serviceability label + badge variant based on net monthly surplus. */ function serviceabilityInfo(surplus: number): { label: string; variant: "success" | "warning" | "destructive"; } { if (surplus > 1500) return { label: "Likely Serviceable", variant: "success" }; if (surplus > 0) return { label: "Borderline", variant: "warning" }; return { label: "At Risk", variant: "destructive" }; } /** * Pencil icon button rendered next to a section header. * Uses stopPropagation so the click doesn't toggle a collapsible parent. */ function SectionEditButton({ onClick, title = "Edit", }: { onClick: () => void; title?: string; }) { return ( ); } // --------------------------------------------------------------------------- // HeroBand (internal) // Deal-at-a-glance strip always visible at the top of the summary. // --------------------------------------------------------------------------- interface HeroBandProps { mainAbout: AboutApplicantFormData; coAbout?: AboutApplicantFormData; isJoint: boolean; loanAmount: number; propertyEstimate: number; cashEquity: number; netSurplus: number; } function HeroBand({ mainAbout, coAbout, isJoint, loanAmount, propertyEstimate, cashEquity, netSurplus, }: HeroBandProps) { const lvrPct = propertyEstimate > 0 ? (loanAmount / propertyEstimate) * 100 : 0; const svc = serviceabilityInfo(netSurplus); const mainName = [mainAbout.firstName, mainAbout.lastName].filter(Boolean).join(" ") || "Main Applicant"; const coName = coAbout ? [coAbout.firstName, coAbout.lastName].filter(Boolean).join(" ") : ""; const surplusAbs = Math.abs(Math.round(netSurplus)); const surplusDisplay = `${netSurplus >= 0 ? "+" : "-"}${formatCurrency(surplusAbs)}`; return (
{/* ── Applicant row ── */}
{mainName} {isJoint && coName && ( + {coName} · Joint Application )}
{mainAbout.phone && ( )} {mainAbout.email && ( )}
{/* ── Key metrics row ── */}
Loan Amount {formatCurrency(loanAmount)}
Property {propertyEstimate > 0 ? formatCurrency(propertyEstimate) : "—"}
LVR {lvrPct > 0 ? `${lvrPct.toFixed(1)}%` : "—"}
Cash / Deposit {formatCurrency(cashEquity)}
{/* ── Serviceability row ── */}
Net Surplus / Month = 0 ? "text-emerald-600" : "text-destructive", )} > {surplusDisplay}
{svc.label}
); } // --------------------------------------------------------------------------- // ApplicantCardTab (internal) // --------------------------------------------------------------------------- interface ApplicantCardTabProps { about: AboutApplicantFormData; income: IncomeFormData; expenses: ExpensesFormData; onEditAbout: () => void; onEditIncome: () => void; onEditExpenses: () => void; } function ApplicantCardTab({ about, income, expenses, onEditAbout, onEditIncome, onEditExpenses, }: ApplicantCardTabProps) { const totalMonthlyIncome = formatCurrency( income.items.reduce( (sum, i) => sum + toMonthly(i.incomeAmount, i.frequency), 0, ), ); const totalMonthlyExpenses = formatCurrency( expenses.items.reduce( (sum, e) => sum + toMonthly(e.amount, e.frequency), 0, ), ); return (
{/* ── About ── */}
About
{/* ── Income ── */}
Income
({ incomeType: i.incomeType, jobTitle: i.jobTitle, companyName: i.companyName, companyAddress: i.companyAddress, startDate: i.startDate, stillInPosition: i.stillInPosition, endDate: i.endDate, companyType: i.companyType, amountLabel: `${formatCurrency(i.incomeAmount)} / ${i.frequency}`, }))} totalMonthly={totalMonthlyIncome} />
{/* ── Expenses ── */}
Expenses
({ expenseType: e.expenseType, amountLabel: `${formatCurrency(e.amount)} / ${e.frequency}`, }))} totalMonthly={totalMonthlyExpenses} />
); } // --------------------------------------------------------------------------- // DocRow — single document row (flat list or grouped) // --------------------------------------------------------------------------- const STATUS_OPTIONS = ["verified", "pending", "rejected"] as const; const STATUS_CONFIG: Record< DocumentItem["status"], { Icon: React.ComponentType<{ className?: string }>; label: string; iconCls: string; triggerCls: string; } > = { verified: { Icon: CheckCircle2, label: "Verified", iconCls: "text-success", triggerCls: "border-success text-success", }, pending: { Icon: AlertCircle, label: "Pending", iconCls: "text-warning", triggerCls: "border-warning text-warning", }, rejected: { Icon: AlertCircle, label: "Rejected", iconCls: "text-destructive", triggerCls: "border-destructive text-destructive", }, }; interface DocRowProps { doc: DocumentItem; status: DocumentItem["status"]; isSelected: boolean; uploaderName: string; onRowClick: () => void; onStatusChange: (status: DocumentItem["status"]) => void; } function DocRow({ doc, status, isSelected, uploaderName, onRowClick, onStatusChange, }: DocRowProps) { const { Icon: StatusIcon, label: statusLabel, iconCls, triggerCls, } = STATUS_CONFIG[status]; return (
e.stopPropagation()}> onRowClick()} /> {/* Name + subtitle */}
{doc.url ? ( e.stopPropagation()} className="w-fit text-label-medium text-foreground underline-offset-2 hover:underline" > {doc.name} ) : ( {doc.name} )} {doc.documentType} {doc.checklistItem ? ` · ${doc.checklistItem}` : ""}
{/* Uploader */} {uploaderName} {/* Status — select */} e.stopPropagation()}> {/* Upload date */} {doc.uploadedAt} {/* Per-row download */} {doc.url && ( e.stopPropagation()} className="shrink-0 text-muted-foreground transition-colors hover:text-foreground" > )}
); } // --------------------------------------------------------------------------- // OpportunitySummaryTab (exported) // --------------------------------------------------------------------------- export function OpportunitySummaryTab({ isJoint = false, loanScenario, onLoanScenarioChange, assets, onAssetsChange, debts, onDebtsChange, mainAbout, onMainAboutChange, mainIncome, onMainIncomeChange, mainExpenses, onMainExpensesChange, coAbout, onCoAboutChange, coIncome, onCoIncomeChange, coExpenses, onCoExpensesChange, documents = [], onDocumentsDownload, onDocumentStatusChange, }: OpportunitySummaryTabProps) { // ── Portal container — scopes edit modal overlays inside the drawer ──────── const [portalEl, setPortalEl] = useState(null); // ── Sub-tab state ───────────────────────────────────────────────────────── const [summarySubTab, setSummarySubTab] = useState<"joint" | "main" | "co">( "joint", ); // ── Document selection for download ────────────────────────────────────── const [selectedDocIds, setSelectedDocIds] = useState>(new Set()); function toggleDocSelection(id: string) { setSelectedDocIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } // ── Document status overrides (local optimistic state) ──────────────────── const [docStatusOverrides, setDocStatusOverrides] = useState< Record >({}); function handleDocStatusChange( docId: string, status: DocumentItem["status"], ) { setDocStatusOverrides((prev) => ({ ...prev, [docId]: status })); onDocumentStatusChange?.(docId, status); } function effectiveStatus(doc: DocumentItem): DocumentItem["status"] { return docStatusOverrides[doc.id] ?? doc.status; } // ── Checklist progress — derived from docs grouped by checklistItem ──────── const checklistProgress = React.useMemo(() => { const map = new Map< string, { count: number; hasVerified: boolean; hasPending: boolean } >(); for (const doc of documents) { if (!doc.checklistItem) continue; const st = docStatusOverrides[doc.id] ?? doc.status; const entry = map.get(doc.checklistItem) ?? { count: 0, hasVerified: false, hasPending: false, }; entry.count += 1; if (st === "verified") entry.hasVerified = true; if (st === "pending") entry.hasPending = true; map.set(doc.checklistItem, entry); } return Array.from(map.entries()).map(([name, data]) => ({ name, ...data })); }, [documents, docStatusOverrides]); // ── Group docs by checklistItem; null when flat list is preferred ──────── const groupedDocs = React.useMemo(() => { if (documents.length <= 5 || !documents.some((d) => d.checklistItem)) return null; const groups: Record = {}; for (const doc of documents) { const key = doc.checklistItem ?? "Other"; if (!groups[key]) groups[key] = []; groups[key].push(doc); } return groups; }, [documents]); // ── Modal open state (internal) ─────────────────────────────────────────── const [editLoanOpen, setEditLoanOpen] = useState(false); const [editAssetsOpen, setEditAssetsOpen] = useState(false); const [editDebtsOpen, setEditDebtsOpen] = useState(false); const [editMainAboutOpen, setEditMainAboutOpen] = useState(false); const [editCoAboutOpen, setEditCoAboutOpen] = useState(false); const [editMainIncomeOpen, setEditMainIncomeOpen] = useState(false); const [editCoIncomeOpen, setEditCoIncomeOpen] = useState(false); const [editMainExpensesOpen, setEditMainExpensesOpen] = useState(false); const [editCoExpensesOpen, setEditCoExpensesOpen] = useState(false); // ── Derived totals ───────────────────────────────────────────────────────── const totalAssetsAmount = assets.reduce((sum, a) => sum + a.value, 0); const totalDebtsAmount = debts.reduce((sum, d) => sum + d.amountOwing, 0); const totalAssets = formatCurrency(totalAssetsAmount); const totalDebts = formatCurrency(totalDebtsAmount); const allIncomeItems = [...mainIncome.items, ...(coIncome?.items ?? [])]; const allExpenseItems = [...mainExpenses.items, ...(coExpenses?.items ?? [])]; const combinedMonthlyIncome = allIncomeItems.reduce( (sum, i) => sum + toMonthly(i.incomeAmount, i.frequency), 0, ); const combinedMonthlyExpenses = allExpenseItems.reduce( (sum, e) => sum + toMonthly(e.amount, e.frequency), 0, ); const netSurplus = combinedMonthlyIncome - combinedMonthlyExpenses; const mainName = [mainAbout.firstName, mainAbout.lastName] .filter(Boolean) .join(" "); const coName = coAbout ? [coAbout.firstName, coAbout.lastName].filter(Boolean).join(" ") : "Co-Applicant"; // ── Property cards derived from asset list ──────────────────────────────── const propertyCards: PropertyCardProps[] = assets .filter((a) => PROPERTY_ASSET_TYPES.has(a.assetType)) .map(assetToPropertyCard); // ── Scrim: true when any edit modal is open ──────────────────────────────── const anyModalOpen = editLoanOpen || editAssetsOpen || editDebtsOpen || editMainAboutOpen || editCoAboutOpen || editMainIncomeOpen || editCoIncomeOpen || editMainExpensesOpen || editCoExpensesOpen; return ( <> {/* ── Hero Band — always visible, deal at a glance ── */} {/* ── Loan Scenario ── */}
Loan Scenario setEditLoanOpen(true)} title="Edit Loan Scenario" />
{/* ── Applicant sub-tabs — filled variant, full width ── */} setSummarySubTab(v as "joint" | "main" | "co")} className="pb-6" >
Overview {mainName} {isJoint && {coName}}
{/* ── Overview tab — combined overview ── */} {/* Financial Overview — 2-col: Cashflow | Balance Sheet */}
Financial Overview
{/* Col 1: Cashflow */}
Cashflow
{/* Col 2: Balance Sheet */}
Balance Sheet
{/* Documents */}
{/* Section header */}
Documents
{selectedDocIds.size > 0 && ( )} {documents.length > 0 && ( {documents.length} file {documents.length !== 1 ? "s" : ""} )}
{/* Checklist progress — derived from docs grouped by checklistItem */} {checklistProgress.length > 0 && (
{checklistProgress.map((cat) => ( {cat.hasVerified ? ( ) : ( )} {cat.name} ({cat.count}) ))}
)} {/* Document list */}
{documents.length === 0 ? (

No documents uploaded yet.

) : groupedDocs ? ( // Grouped by checklistItem Object.entries(groupedDocs).map(([category, docs]) => (
{category}
{docs.map((doc) => ( toggleDocSelection(doc.id)} onStatusChange={(s) => handleDocStatusChange(doc.id, s) } /> ))}
)) ) : ( // Flat list
{documents.map((doc) => ( toggleDocSelection(doc.id)} onStatusChange={(s) => handleDocStatusChange(doc.id, s)} /> ))}
)}
{/* Property Holdings — bordered card, divide-x columns, max 3 per row */} {propertyCards.length > 0 && (
Property Holdings setEditAssetsOpen(true)} title="Edit Assets" />
{propertyCards.map((card, i) => (
))}
)} {/* Mortgages & Loans — bordered card, grid of DebtCards */} {debts.length > 0 && (
Mortgages & Loans setEditDebtsOpen(true)} title="Edit Debts" />
{debts.map((debt) => ( ))}
)}
{/* ── Main Applicant tab ── */} setEditMainAboutOpen(true)} onEditIncome={() => setEditMainIncomeOpen(true)} onEditExpenses={() => setEditMainExpensesOpen(true)} /> {/* ── Co-Applicant tab (joint applications only) ── */} {isJoint && coAbout && coIncome && coExpenses && ( setEditCoAboutOpen(true)} onEditIncome={() => setEditCoIncomeOpen(true)} onEditExpenses={() => setEditCoExpensesOpen(true)} /> )}
{/* ── Edit modals — portal scoped inside the drawer ── */} { onLoanScenarioChange?.(data); setEditLoanOpen(false); }} /> { onAssetsChange?.(items); setEditAssetsOpen(false); }} /> { onDebtsChange?.(items); setEditDebtsOpen(false); }} /> { onMainAboutChange?.(data); setEditMainAboutOpen(false); }} /> {coAbout && ( { onCoAboutChange?.(data); setEditCoAboutOpen(false); }} /> )} { onMainIncomeChange?.(data); setEditMainIncomeOpen(false); }} /> {coIncome && ( { onCoIncomeChange?.(data); setEditCoIncomeOpen(false); }} /> )} { onMainExpensesChange?.(data); setEditMainExpensesOpen(false); }} /> {coExpenses && ( { onCoExpensesChange?.(data); setEditCoExpensesOpen(false); }} /> )} {/* * Drawer scrim — dims the drawer content when any edit modal is open. * * Why this lives here (not inside the Dialog itself): * The portalEl container div below is positioned LAST in the DOM so * the Dialog overlay + popup paint on top of all drawer content. * This scrim is rendered BEFORE portalEl so it is covered by the * Dialog overlay (z-50) while itself sitting above drawer content * (z-auto). Using `fixed inset-0` ensures it covers the full drawer * viewport regardless of how far the user has scrolled. */} {anyModalOpen && (