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 (
{
e.stopPropagation();
onClick();
}}
aria-label={title}
title={title}
>
);
}
// ---------------------------------------------------------------------------
// 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 ── */}
{/* ── 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 (
);
}
// ---------------------------------------------------------------------------
// 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 */}
{/* Col 2: Balance Sheet */}
{/* Documents */}
{/* Section header */}
Documents
{selectedDocIds.size > 0 && (
{
const selected = documents.filter((d) =>
selectedDocIds.has(d.id),
);
onDocumentsDownload?.(selected);
}}
>
Download ({selectedDocIds.size})
)}
{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 && (
)}
{/*
* Portal container — MUST be the last DOM element in this component.
* React portals render their output INTO this node, so placing it last
* ensures the Dialog overlay + popup paint on top of all drawer content
* and on top of the scrim above.
*/}
>
);
}