import * as React from "react";
import {
Baby,
Building2,
Car,
ChevronDown,
CreditCard,
Dumbbell,
GraduationCap,
HeartPulse,
Landmark,
Receipt,
RefreshCw,
Shield,
ShoppingCart,
Shirt,
Tv,
UtensilsCrossed,
Zap,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDateShort } from "@/lib/format-date";
import { Badge } from "./badge";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./accordion";
import { RadioGroup, RadioGroupItem } from "./radio-group";
import {
FinancialDetailField,
FinancialLineItem,
FinancialLvrBar,
FinancialSubsectionTitle,
FinancialSubtotalBlock,
FinancialSubtotalFrame,
} from "./financial-primitives";
import { Button } from "./button";
/**
* Financial card molecules — WealthX DS (Level 3)
*
* Composed from financial primitives (Level 2) + shadcn atoms.
* Used inside summary report drawers, opportunity detail panels,
* and any financial data section in the backoffice.
*
* Component inventory:
* PropertyCard — property holding with optional bank link + expandable loan detail
* DebtCard — mortgage / investment-loan breakdown
* OtherLiabilityCard — credit card or personal loan card (discriminated by `type`)
* AlertCard — single alert item with severity indicator + dismiss/snooze actions
*/
// ---------------------------------------------------------------------------
// PropertyCard
// ---------------------------------------------------------------------------
export interface PropertyCardProps {
/** Street address or property name */
address: string;
/** Badge label: "Owner Occupier", "Investment", etc. */
type?: string;
/** Estimated property value e.g. "$1,200,000" */
estimated: string;
/**
* When true removes the outer card border.
* Use inside `FinancialViewSection` where the card is already inside a bordered container.
*/
borderless?: boolean;
/** Whether a bank mortgage account is linked to this property */
isLinkedToBank?: boolean;
/** Outstanding loan amount — only shown when isLinkedToBank */
loanAmount?: string;
/** Equity = estimated − loan — only shown when isLinkedToBank */
equity?: string;
/** LVR label e.g. "56% — Good" — only shown when isLinkedToBank */
lvr?: string;
/** LVR numeric percentage (drives color thresholds) */
lvrPercent?: number;
// — Expandable loan detail (visible only when isLinkedToBank && expanded) —
lenderName?: string;
interestRate?: string;
yearsRemaining?: string;
minRepayments?: string;
averageRepayments?: string;
redrawAmount?: string;
offsetAccount?: string;
// — SubtotalFrame footer —
totalMinRepayments?: string;
totalExtraRepayments?: string;
interestCharged?: string;
principlePaidOff?: string;
}
export function PropertyCard({
address,
type,
estimated,
isLinkedToBank = false,
borderless = false,
loanAmount,
equity,
lvr,
lvrPercent = 0,
lenderName,
interestRate,
yearsRemaining,
minRepayments,
averageRepayments,
redrawAmount,
offsetAccount,
totalMinRepayments,
totalExtraRepayments,
interestCharged,
principlePaidOff,
}: PropertyCardProps) {
const [expanded, setExpanded] = React.useState(false);
return (
{/* Header row */}
{isLinkedToBank ? (
) : (
{address}
{type && {type}}
)}
{!isLinkedToBank && (
No mortgage account linked to this property
)}
{/* Static fields — always visible */}
{isLinkedToBank && (
<>
>
)}
{isLinkedToBank && (
)}
{/* Expandable loan detail — CSS grid transition for smooth open/close */}
{isLinkedToBank && (
)}
);
}
// ---------------------------------------------------------------------------
// DebtCard
// ---------------------------------------------------------------------------
export interface DebtCardProps {
// — Header row —
lenderName?: string;
currentLoanAmount?: string;
/** Current annual interest rate e.g. `"6.04% p.a."` */
interestRate?: string;
// — Loan Stats section —
originalLoanAmount?: string;
/** Original term at drawdown e.g. `"30 years"` */
originalLoanTerm?: string;
/** Minimum scheduled monthly repayment */
monthlyRepayments?: string;
/** Available redraw balance */
redrawAmount?: string;
/** Linked offset account balance */
offsetAmount?: string;
/** Additional voluntary repayments per month */
extraLoanRepayments?: string;
// — SubtotalFrame footer —
/** Cumulative repayments made within the selected date range */
totalRepaymentsMade?: string;
/** Cumulative interest paid within the selected date range */
totalInterestPaid?: string;
/** Remaining loan term e.g. `"25 years"` */
yearsRemaining?: string;
}
export function DebtCard({
lenderName,
currentLoanAmount,
interestRate,
originalLoanAmount,
originalLoanTerm,
monthlyRepayments,
redrawAmount,
offsetAmount,
extraLoanRepayments,
totalRepaymentsMade,
totalInterestPaid,
yearsRemaining,
}: DebtCardProps) {
return (
{/* Header row — fixed min-h so two cards in a grid row have aligned headers */}
{/* Loan Stats section */}
{/* Footer */}
);
}
// ---------------------------------------------------------------------------
// OtherLiabilityCard
// ---------------------------------------------------------------------------
export type OtherLiabilityType = "credit_card" | "personal_loan";
export interface OtherLiabilityCardProps {
/**
* Discriminates the field set and footer layout:
* - `"credit_card"` → Card Stats (Credit Limit, Min Payment, Annual Fee)
* - `"personal_loan"` → Loan Stats (Original Amount, Term, Monthly Repayments)
*/
type: OtherLiabilityType;
lenderName?: string;
/** Annual interest rate e.g. `"19.99% p.a."` */
interestRate?: string;
// ── Credit card fields ──
/** Current outstanding balance on the card */
currentBalance?: string;
/** Approved credit limit */
creditLimit?: string;
/** Minimum required monthly payment */
minMonthlyPayment?: string;
annualFee?: string;
/** Pre-computed available credit: `creditLimit − currentBalance` */
availableCredit?: string;
// ── Personal loan fields ──
/** Current outstanding loan balance */
currentLoanAmount?: string;
originalLoanAmount?: string;
/** Original term at drawdown e.g. `"5 years"` */
originalLoanTerm?: string;
/** Minimum scheduled monthly repayment */
monthlyRepayments?: string;
/** Remaining term e.g. `"2.5 years"` */
currentLoanTermLeft?: string;
// ── Shared footer ──
/** Cumulative repayments made within the selected date range */
totalRepaymentsMade?: string;
/** Cumulative interest paid within the selected date range */
totalInterestPaid?: string;
}
export function OtherLiabilityCard({
type,
lenderName,
interestRate,
currentBalance,
creditLimit,
minMonthlyPayment,
annualFee,
availableCredit,
currentLoanAmount,
originalLoanAmount,
originalLoanTerm,
monthlyRepayments,
currentLoanTermLeft,
totalRepaymentsMade,
totalInterestPaid,
}: OtherLiabilityCardProps) {
const isCreditCard = type === "credit_card";
return (
{/* Header row — fixed min-h so two cards in a grid row have aligned headers */}
{isCreditCard ? (
) : (
)}
{/* Stats section */}
{isCreditCard ? "Card Stats" : "Loan Stats"}
{isCreditCard ? (
) : (
)}
{/* Footer */}
{isCreditCard ? (
) : (
)}
);
}
// ---------------------------------------------------------------------------
// AlertCard
// ---------------------------------------------------------------------------
export type AlertSeverity = "NEED_ACTION" | "WATCH" | "INSIGHT";
export type AlertActionType = "DISMISS" | "SNOOZE";
const SEVERITY_CLASSES: Record =
{
NEED_ACTION: { dot: "bg-destructive", border: "border-destructive" },
WATCH: { dot: "bg-warning", border: "border-warning" },
INSIGHT: { dot: "bg-success", border: "border-success" },
};
export interface AlertCardProps {
id: string;
name: string;
severityCode: AlertSeverity;
/** Whether this alert has an active ignore period */
ignored?: boolean;
/** Human-readable date string e.g. "31/12/2025" */
ignoredUntil?: string;
/** Currently selected action, controlled externally */
selectedAction?: AlertActionType | null;
onActionChange?: (action: AlertActionType) => void;
}
export function AlertCard({
id,
name,
severityCode,
ignored = false,
ignoredUntil,
selectedAction = null,
onActionChange,
}: AlertCardProps) {
const { dot, border } = SEVERITY_CLASSES[severityCode];
return (
{/* Alert name row */}
{name}
{ignored ? (
Ignored until {ignoredUntil}
) : (
onActionChange?.(val as AlertActionType)}
className="ml-[15px] flex flex-col gap-1"
>
)}
);
}
// ---------------------------------------------------------------------------
// AboutCard
// ---------------------------------------------------------------------------
export interface AboutCardProps {
title?: string;
firstName?: string;
lastName?: string;
phone?: string;
email?: string;
dob?: string;
gender?: string;
maritalStatus?: string;
numDependants?: string;
/** Individual dependant records — shows DOB + age for each. */
dependants?: Array<{ dob: string }>;
citizenStatus?: string;
residentialAddress?: string;
residentialStatus?: string;
timeAtAddressYears?: string;
timeAtAddressMonths?: string;
driversLicence?: string;
passport?: string;
propertyInTrust?: string;
companyOwnership?: string;
/** Remove the outer card border. Use when the card is already inside a bordered container. */
borderless?: boolean;
}
function ageFromDob(dobStr: string): number | null {
if (!dobStr) return null;
const dob = new Date(dobStr);
if (isNaN(dob.getTime())) return null;
const today = new Date();
let age = today.getFullYear() - dob.getFullYear();
const m = today.getMonth() - dob.getMonth();
if (m < 0 || (m === 0 && today.getDate() < dob.getDate())) age--;
return age >= 0 ? age : null;
}
/**
* Display card for applicant personal / contact information.
* Used inside the Summary tab applicant sub-tabs.
*/
export function AboutCard({
title,
firstName,
lastName,
phone,
email,
dob,
gender,
maritalStatus,
numDependants,
dependants,
citizenStatus,
residentialAddress,
residentialStatus,
timeAtAddressYears,
timeAtAddressMonths,
driversLicence,
passport,
propertyInTrust,
companyOwnership,
borderless = false,
}: AboutCardProps) {
const fullName =
[title, firstName, lastName].filter(Boolean).join(" ") || "—";
const timeAtAddress =
timeAtAddressYears || timeAtAddressMonths
? [
timeAtAddressYears && `${timeAtAddressYears}yr`,
timeAtAddressMonths && `${timeAtAddressMonths}mo`,
]
.filter(Boolean)
.join(" ")
: undefined;
return (
{/* Identity — personal details + citizenship documents */}
{/* Contact */}
{/* Family — marital status + dependants as a dedicated section */}
{dependants &&
dependants.map((dep, i) => {
const age = ageFromDob(dep.dob);
const dobFormatted = formatDateShort(dep.dob) || "—";
const value =
age !== null ? `${dobFormatted} · age ${age}` : dobFormatted;
return (
);
})}
{/* Residence */}
{/* Ownership */}
);
}
// ---------------------------------------------------------------------------
// IncomeCard
// ---------------------------------------------------------------------------
export interface IncomeCardItem {
incomeType: string;
jobTitle?: string;
companyName?: string;
companyAddress?: string;
startDate?: string;
stillInPosition?: boolean;
endDate?: string;
companyType?: string;
/** Pre-formatted amount + frequency e.g. "$9,500 / Monthly" */
amountLabel: string;
}
export interface IncomeCardProps {
items: IncomeCardItem[];
/** Pre-formatted total monthly income e.g. "$12,300" */
totalMonthly?: string;
/** Remove the outer card border. Use when the card is already inside a bordered container. */
borderless?: boolean;
}
/**
* Display card for applicant income items.
* Each income source is an accordion row: collapsed = type + amount,
* expanded = full employment details.
*/
export function IncomeCard({
items,
totalMonthly,
borderless = false,
}: IncomeCardProps) {
if (items.length === 0) {
return (
);
}
return (
{items.map((item, i) => (
{item.incomeType}
{item.jobTitle && (
· {item.jobTitle}
)}
{item.amountLabel}
{item.companyAddress && (
)}
))}
{totalMonthly && (
)}
);
}
// ---------------------------------------------------------------------------
// ExpensesCard
// ---------------------------------------------------------------------------
const EXPENSE_ICON_MAP: Record = {
groceries: ShoppingCart,
"dining out": UtensilsCrossed,
dining: UtensilsCrossed,
restaurants: UtensilsCrossed,
transport: Car,
transportation: Car,
vehicle: Car,
utilities: Zap,
electricity: Zap,
insurance: Shield,
"council rates": Landmark,
council: Landmark,
rates: Landmark,
medical: HeartPulse,
health: HeartPulse,
subscriptions: RefreshCw,
subscription: RefreshCw,
"credit card": CreditCard,
education: GraduationCap,
childcare: Baby,
entertainment: Tv,
gym: Dumbbell,
fitness: Dumbbell,
clothing: Shirt,
rent: Building2,
};
function getExpenseIcon(expenseType: string): LucideIcon {
return EXPENSE_ICON_MAP[expenseType.toLowerCase()] ?? Receipt;
}
export interface ExpensesCardItem {
expenseType: string;
/** Pre-formatted amount + frequency e.g. "$800 / Monthly" */
amountLabel: string;
}
export interface ExpensesCardProps {
items: ExpensesCardItem[];
/** Pre-formatted total monthly expenses e.g. "$2,100" */
totalMonthly?: string;
/** Remove the outer card border. Use when the card is already inside a bordered container. */
borderless?: boolean;
}
/**
* Display card for applicant expense items.
* Each expense renders as an icon-labelled row with a destructive amount value.
*/
export function ExpensesCard({
items,
totalMonthly,
borderless = false,
}: ExpensesCardProps) {
if (items.length === 0) {
return (
);
}
return (
{items.map((item, i) => {
const Icon = getExpenseIcon(item.expenseType);
return (
{item.expenseType}
{item.amountLabel}
);
})}
{totalMonthly && (
)}
);
}