whisper-money/resources/js/pages/Accounts/Show.tsx

1432 lines
60 KiB
TypeScript

import { index, show } from '@/actions/App/Http/Controllers/AccountController';
import { update as updateLoanDetail } from '@/actions/App/Http/Controllers/LoanDetailController';
import { update as updateRealEstateDetail } from '@/actions/App/Http/Controllers/RealEstateDetailController';
import {
AccountBalanceChart,
type BalanceDataPoint,
type ChartComputedData,
} from '@/components/accounts/account-balance-chart';
import { BalancesModal } from '@/components/accounts/balances-modal';
import { EditAccountDialog } from '@/components/accounts/edit-account-dialog';
import { EditLoanDetailDialog } from '@/components/accounts/edit-loan-detail-dialog';
import { ImportBalancesDrawer } from '@/components/accounts/import-balances-drawer';
import { UpdateBalanceDialog } from '@/components/accounts/update-balance-dialog';
import { BankLogo } from '@/components/bank-logo';
import { AmountTrendIndicator } from '@/components/dashboard/amount-trend-indicator';
import HeadingSmall from '@/components/heading-small';
import InputError from '@/components/input-error';
import { MobileBackButton } from '@/components/mobile-back-button';
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
import { TransactionList } from '@/components/transactions/transaction-list';
import { AmountDisplay } from '@/components/ui/amount-display';
import { AmountInput } from '@/components/ui/amount-input';
import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { BreadcrumbItem } from '@/types';
import {
Account,
AREA_UNITS,
Bank,
formatAccountType,
formatAreaUnit,
formatPropertyType,
isTransactionalAccount,
PROPERTY_TYPES,
type AreaUnit,
type LoanDetail,
type PropertyType,
type RealEstateDetail,
} from '@/types/account';
import { AutomationRule } from '@/types/automation-rule';
import { Category } from '@/types/category';
import { Label as LabelType } from '@/types/label';
import { type Transaction } from '@/types/transaction';
import { formatDateMedium } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { Head, router } from '@inertiajs/react';
import { ChevronDown, Pencil, Plus } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
interface AccountWithDetails extends Account {
real_estate_detail?: RealEstateDetail;
available_loan_accounts?: Account[];
loan_detail?: LoanDetail;
linked_loan_account?: Account;
}
interface Props {
account: AccountWithDetails;
categories: Category[];
accounts: Account[];
banks: Bank[];
labels?: LabelType[];
automationRules?: AutomationRule[];
transactions?: Transaction[];
}
export default function AccountShow({
account,
categories,
accounts,
banks,
labels = [],
automationRules = [],
transactions = [],
}: Props) {
const [editOpen, setEditOpen] = useState(false);
const [updateBalanceOpen, setUpdateBalanceOpen] = useState(false);
const [updateLoanBalanceOpen, setUpdateLoanBalanceOpen] = useState(false);
const [importBalancesOpen, setImportBalancesOpen] = useState(false);
const [balancesOpen, setBalancesOpen] = useState(false);
const [chartRefreshKey, setChartRefreshKey] = useState(0);
const [editingDetails, setEditingDetails] = useState(false);
const [editingLoanDetails, setEditingLoanDetails] = useState(false);
const [editLoanDialogOpen, setEditLoanDialogOpen] = useState(false);
const [createTransactionOpen, setCreateTransactionOpen] = useState(false);
const [chartComputedData, setChartComputedData] =
useState<ChartComputedData | null>(null);
const handleChartDataLoaded = useCallback((data: ChartComputedData) => {
setChartComputedData(data);
}, []);
function handleBalanceUpdated() {
setChartRefreshKey((prev) => prev + 1);
}
function handleAddTransaction() {
setCreateTransactionOpen(true);
}
function handleTransactionCreated() {
router.reload({ only: ['transactions'] });
handleBalanceUpdated();
}
const isConnected = !!account.banking_connection_id;
const isLoan = account.type === 'loan';
const isRealEstate = account.type === 'real_estate';
const realEstateDetail = account.real_estate_detail;
const loanDetail = account.loan_detail;
const linkedLoanAccount = account.linked_loan_account;
const hasLinkedLoan = isRealEstate && !!linkedLoanAccount;
const canCreateTransaction =
!isConnected && isTransactionalAccount(account);
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Accounts',
href: index().url,
},
{
title: account.name,
href: show.url(account.id),
},
];
const updateBalanceLabel = isLoan
? __('Update owed amount')
: isRealEstate
? __('Update market value')
: __('Update balance');
const importBalancesLabel = isLoan
? __('Import owed amounts')
: isRealEstate
? __('Import market values')
: __('Import balances');
const seeBalancesLabel = isLoan
? __('See owed amounts')
: isRealEstate
? __('See market values')
: __('See balances');
return (
<AppSidebarLayout
breadcrumbs={breadcrumbs}
mobileLeading={<MobileBackButton href={index().url} />}
>
<Head title={__('Account Details')} />
<div className="space-y-6 p-6">
<div className="sm flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex items-center gap-4 pl-1">
{account.bank && (
<BankLogo
src={account.bank.logo}
name={account.bank.name}
className="size-12"
fallback="letter"
/>
)}
<HeadingSmall
title={account.name}
description={`${account.bank ? `${account.bank.name} · ` : ''}${formatAccountType(account.type)}`}
/>
</div>
{isConnected && !hasLinkedLoan ? (
<Button
variant="outline"
onClick={() => setEditOpen(true)}
>
{__('Edit account')}
</Button>
) : isConnected && hasLinkedLoan ? (
<ButtonGroup>
<Button
variant="outline"
onClick={() => setEditOpen(true)}
>
{__('Edit account')}
</Button>
<Button
variant="outline"
onClick={() => setEditLoanDialogOpen(true)}
>
{__('Edit loan details')}
</Button>
</ButtonGroup>
) : !isConnected && hasLinkedLoan ? (
<ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
onClick={() => setUpdateBalanceOpen(true)}
>
{__('Update market value')}
</Button>
<Button
variant="outline"
onClick={() =>
setUpdateLoanBalanceOpen(true)
}
>
{__('Update owed amount')}
</Button>
</ButtonGroup>
<ButtonGroup>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label={__('More options')}
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
setBalancesOpen(true)
}
>
{__('See market values')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setImportBalancesOpen(true)
}
>
{__('Import market values')}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setEditOpen(true)}
>
{__('Edit account')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
setEditLoanDialogOpen(true)
}
>
{__('Edit loan details')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</ButtonGroup>
) : (
<div className="flex flex-wrap gap-2">
{canCreateTransaction && (
<Button
variant="outline"
onClick={handleAddTransaction}
>
<Plus className="h-4 w-4" />
{__('Add transaction')}
</Button>
)}
<ButtonGroup>
<Button
variant="outline"
onClick={() => setUpdateBalanceOpen(true)}
>
{updateBalanceLabel}
</Button>
<Button
variant="outline"
onClick={() => setImportBalancesOpen(true)}
>
{importBalancesLabel}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label={__('More options')}
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
setBalancesOpen(true)
}
>
{seeBalancesLabel}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEditOpen(true)}
>
{__('Edit account')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</div>
)}
</div>
<AccountBalanceChart
account={account}
refreshKey={chartRefreshKey}
onBalanceClick={
isConnected
? undefined
: () => setUpdateBalanceOpen(true)
}
onDataLoaded={handleChartDataLoaded}
/>
{isRealEstate &&
realEstateDetail &&
chartComputedData?.hasMortgageData && (
<EquitySummaryCards
chartData={chartComputedData.chartData}
currentBalance={chartComputedData.currentBalance}
currentMortgageBalance={
chartComputedData.currentMortgageBalance
}
currencyCode={chartComputedData.currencyCode}
/>
)}
{isRealEstate && realEstateDetail && (
<PropertyDetailsCard
detail={realEstateDetail}
account={account}
availableLoanAccounts={
account.available_loan_accounts ?? []
}
isEditing={editingDetails}
onEditToggle={setEditingDetails}
/>
)}
{isRealEstate && hasLinkedLoan && (
<LoanDetailsCard
detail={loanDetail ?? null}
account={account}
loanAccountId={linkedLoanAccount.id}
isEditing={editingLoanDetails}
onEditToggle={setEditingLoanDetails}
onEditDialogOpen={() => setEditLoanDialogOpen(true)}
/>
)}
{isLoan && (
<LoanDetailsCard
detail={loanDetail ?? null}
account={account}
isEditing={editingLoanDetails}
onEditToggle={setEditingLoanDetails}
/>
)}
{isTransactionalAccount(account) && (
<TransactionList
categories={categories}
accounts={accounts}
banks={banks}
labels={labels}
automationRules={automationRules}
accountId={account.id}
transactions={transactions}
pageSize={50}
hideAccountFilter={true}
showActionsMenu={false}
maxHeight={600}
hideColumns={['bank', 'account']}
onBalanceUpdated={() =>
setChartRefreshKey((key) => key + 1)
}
/>
)}
</div>
<EditAccountDialog
account={account}
open={editOpen}
onOpenChange={setEditOpen}
redirectTo={show.url(account.id)}
deleteRedirectTo={isConnected ? undefined : index().url}
/>
<UpdateBalanceDialog
account={account}
open={updateBalanceOpen}
onOpenChange={setUpdateBalanceOpen}
onSuccess={handleBalanceUpdated}
/>
<EditTransactionDialog
transaction={null}
categories={categories}
accounts={accounts}
banks={banks}
labels={labels}
automationRules={automationRules}
open={createTransactionOpen}
onOpenChange={setCreateTransactionOpen}
onSuccess={handleTransactionCreated}
mode="create"
initialAccountId={account.id}
/>
<BalancesModal
account={account}
open={balancesOpen}
onOpenChange={setBalancesOpen}
onBalanceChange={handleBalanceUpdated}
/>
<ImportBalancesDrawer
open={importBalancesOpen}
onOpenChange={setImportBalancesOpen}
accounts={accounts}
account={account}
accountId={account.id}
onSuccess={handleBalanceUpdated}
/>
{hasLinkedLoan && (
<>
<EditLoanDetailDialog
loanAccountId={linkedLoanAccount.id}
currencyCode={
linkedLoanAccount.currency_code ??
account.currency_code
}
detail={loanDetail ?? null}
open={editLoanDialogOpen}
onOpenChange={setEditLoanDialogOpen}
/>
<UpdateBalanceDialog
account={linkedLoanAccount}
open={updateLoanBalanceOpen}
onOpenChange={setUpdateLoanBalanceOpen}
onSuccess={handleBalanceUpdated}
/>
</>
)}
</AppSidebarLayout>
);
}
interface EquitySummaryCardsProps {
chartData: BalanceDataPoint[];
currentBalance: number;
currentMortgageBalance: number | null;
currencyCode: string;
}
function EquitySummaryCards({
chartData,
currentBalance,
currentMortgageBalance,
currencyCode,
}: EquitySummaryCardsProps) {
const { accountMainLineColor, mortgageLineColor, equityLineColor } =
useChartColors();
const { marketHistory, mortgageHistory, equityHistory, equity } =
useMemo(() => {
const market = chartData.map((d) => ({
date: d.month,
value: d.value,
}));
const mortgage = chartData
.filter(
(d) =>
d.mortgage_balance !== null &&
d.mortgage_balance !== undefined,
)
.map((d) => ({ date: d.month, value: d.mortgage_balance! }));
const equityArr = chartData
.filter(
(d) =>
d.mortgage_balance !== null &&
d.mortgage_balance !== undefined,
)
.map((d) => ({
date: d.month,
value: d.value - d.mortgage_balance!,
}));
const currentEquity =
currentMortgageBalance !== null
? currentBalance - currentMortgageBalance
: null;
return {
marketHistory: market,
mortgageHistory: mortgage,
equityHistory: equityArr,
equity: currentEquity,
};
}, [chartData, currentBalance, currentMortgageBalance]);
const marketTrend = useMemo(() => {
if (marketHistory.length < 2) return null;
const prev = marketHistory[0].value;
const curr = marketHistory[marketHistory.length - 1].value;
if (prev === 0) return null;
return { diff: curr - prev, previous: prev, current: curr };
}, [marketHistory]);
const mortgageTrend = useMemo(() => {
if (mortgageHistory.length < 2) return null;
const prev = mortgageHistory[0].value;
const curr = mortgageHistory[mortgageHistory.length - 1].value;
if (prev === 0) return null;
return { diff: curr - prev, previous: prev, current: curr };
}, [mortgageHistory]);
const equityTrend = useMemo(() => {
if (equityHistory.length < 2) return null;
const prev = equityHistory[0].value;
const curr = equityHistory[equityHistory.length - 1].value;
if (prev === 0) return null;
return { diff: curr - prev, previous: prev, current: curr };
}, [equityHistory]);
return (
<div className="grid gap-4 md:grid-cols-3">
<SparklineCard
title={__('Market Value')}
amountInCents={currentBalance}
currencyCode={currencyCode}
history={marketHistory}
lineColor={accountMainLineColor}
trend={marketTrend}
/>
<SparklineCard
title={__('Mortgage Owed')}
amountInCents={currentMortgageBalance ?? 0}
currencyCode={currencyCode}
history={mortgageHistory}
lineColor={mortgageLineColor}
trend={mortgageTrend}
/>
<SparklineCard
title={__('Equity')}
amountInCents={equity ?? 0}
currencyCode={currencyCode}
history={equityHistory}
lineColor={equityLineColor}
trend={equityTrend}
/>
</div>
);
}
function SparklineCard({
title,
amountInCents,
currencyCode,
history,
lineColor,
trend,
}: {
title: string;
amountInCents: number;
currencyCode: string;
history: Array<{ date: string; value: number }>;
lineColor: string;
trend: { diff: number; previous: number; current: number } | null;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between gap-6">
<div className="flex flex-col gap-1">
<div className="px-0 py-1">
<AmountDisplay
amountInCents={amountInCents}
currencyCode={currencyCode}
size="2xl"
weight="medium"
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
</div>
{trend && (
<AmountTrendIndicator
isPositive={trend.diff >= 0}
trend={Math.abs(trend.diff)}
label={__('vs start')}
className="text-sm"
previousAmount={trend.previous}
currentAmount={trend.current}
tooltipSide="bottom"
currencyCode={currencyCode}
/>
)}
</div>
{history.length > 1 && (
<div className="h-[70px] w-full max-w-[250px] flex-1">
<ResponsiveContainer
width="100%"
height="100%"
initialDimension={{ width: 1, height: 1 }}
>
<LineChart data={history}>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length)
return null;
const data = payload[0].payload as {
date: string;
value: number;
};
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<p className="mb-1 text-muted-foreground">
{data.date}
</p>
<p className="font-mono font-medium text-foreground tabular-nums">
<AmountDisplay
amountInCents={
data.value
}
currencyCode={
currencyCode
}
minimumFractionDigits={
0
}
maximumFractionDigits={
0
}
/>
</p>
</div>
);
}}
/>
<Line
type="monotone"
dataKey="value"
stroke={lineColor}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</div>
</CardContent>
</Card>
);
}
function PropertyDetailsCard({
detail,
account,
availableLoanAccounts,
isEditing,
onEditToggle,
}: {
detail: RealEstateDetail;
account: AccountWithDetails;
availableLoanAccounts: Account[];
isEditing: boolean;
onEditToggle: (editing: boolean) => void;
}) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
property_type: detail.property_type,
address: detail.address ?? '',
purchase_price: detail.purchase_price ?? 0,
purchase_date: detail.purchase_date ?? '',
area_value: detail.area_value ?? '',
area_unit: detail.area_unit ?? (null as AreaUnit | null),
linked_loan_account_id:
detail.linked_loan_account_id ?? (null as string | null),
notes: detail.notes ?? '',
revaluation_percentage:
detail.revaluation_percentage != null
? String(detail.revaluation_percentage)
: '',
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsSubmitting(true);
router.patch(
updateRealEstateDetail.url(account.id),
{
property_type: formData.property_type,
address: formData.address || null,
purchase_price: formData.purchase_price || null,
purchase_date: formData.purchase_date || null,
area_value: formData.area_value || null,
area_unit: formData.area_unit,
linked_loan_account_id: formData.linked_loan_account_id,
notes: formData.notes || null,
revaluation_percentage: formData.revaluation_percentage || null,
},
{
preserveScroll: true,
onSuccess: () => onEditToggle(false),
onFinish: () => setIsSubmitting(false),
},
);
}
function handleCancel() {
setFormData({
property_type: detail.property_type,
address: detail.address ?? '',
purchase_price: detail.purchase_price ?? 0,
purchase_date: detail.purchase_date ?? '',
area_value: detail.area_value ?? '',
area_unit: detail.area_unit ?? null,
linked_loan_account_id: detail.linked_loan_account_id ?? null,
notes: detail.notes ?? '',
revaluation_percentage:
detail.revaluation_percentage != null
? String(detail.revaluation_percentage)
: '',
});
onEditToggle(false);
}
if (isEditing) {
return (
<Card>
<CardHeader>
<CardTitle>{__('Edit Property Details')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit_property_type">
{__('Property Type')}
</Label>
<Select
value={formData.property_type}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
property_type:
value as PropertyType,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROPERTY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{formatPropertyType(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="edit_purchase_date">
{__('Purchase Date')}
</Label>
<Input
id="edit_purchase_date"
type="date"
value={formData.purchase_date}
onChange={(e) =>
setFormData((prev) => ({
...prev,
purchase_date: e.target.value,
}))
}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="edit_address">
{__('Address')}
</Label>
<Input
id="edit_address"
value={formData.address}
onChange={(e) =>
setFormData((prev) => ({
...prev,
address: e.target.value,
}))
}
placeholder={__('Property address')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit_purchase_price">
{__('Purchase Price')}
</Label>
<AmountInput
id="edit_purchase_price"
value={formData.purchase_price}
onChange={(value) =>
setFormData((prev) => ({
...prev,
purchase_price: value,
}))
}
currencyCode={account.currency_code}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit_area_value">
{__('Area')}
</Label>
<Input
id="edit_area_value"
type="number"
value={formData.area_value}
onChange={(e) =>
setFormData((prev) => ({
...prev,
area_value: e.target.value,
}))
}
placeholder="0"
min="0"
step="0.01"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit_area_unit">
{__('Unit')}
</Label>
<Select
value={formData.area_unit ?? 'none'}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
area_unit:
value === 'none'
? null
: (value as AreaUnit),
}))
}
>
<SelectTrigger>
<SelectValue
placeholder={__('Select unit')}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{__('None')}
</SelectItem>
{AREA_UNITS.map((unit) => (
<SelectItem key={unit} value={unit}>
{formatAreaUnit(unit)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{availableLoanAccounts.length > 0 && (
<div className="space-y-2">
<Label htmlFor="edit_linked_loan">
{__('Linked Mortgage / Loan')}
</Label>
<Select
value={
formData.linked_loan_account_id ??
'none'
}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
linked_loan_account_id:
value === 'none' ? null : value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
{__('No linked loan')}
</SelectItem>
{availableLoanAccounts.map((loan) => (
<SelectItem
key={loan.id}
value={loan.id}
>
{loan.name}{' '}
{loan.bank
? `(${loan.bank.name})`
: ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-2">
<Label htmlFor="edit_notes">{__('Notes')}</Label>
<Textarea
id="edit_notes"
value={formData.notes}
onChange={(e) =>
setFormData((prev) => ({
...prev,
notes: e.target.value,
}))
}
placeholder={__(
'Additional notes about this property',
)}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit_revaluation_percentage">
{__('Annual Revaluation (%)')}
</Label>
<Input
id="edit_revaluation_percentage"
type="number"
value={formData.revaluation_percentage}
onChange={(e) =>
setFormData((prev) => ({
...prev,
revaluation_percentage: e.target.value,
}))
}
placeholder="0.00"
min="-100"
max="100"
step="0.01"
/>
<p className="text-xs text-muted-foreground">
{__(
'Annual percentage applied monthly. Use negative values for depreciation.',
)}
</p>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
>
{__('Cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? __('Saving...') : __('Save')}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
const linkedLoan = detail.linked_loan_account;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{__('Property Details')}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => onEditToggle(true)}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
{__('Edit')}
</Button>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-muted-foreground">
{__('Property Type')}
</dt>
<dd className="font-medium">
{formatPropertyType(detail.property_type)}
</dd>
</div>
{detail.address && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Address')}
</dt>
<dd className="font-medium">{detail.address}</dd>
</div>
)}
{detail.purchase_price !== null && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Purchase Price')}
</dt>
<dd className="font-medium">
<AmountDisplay
amountInCents={detail.purchase_price}
currencyCode={account.currency_code}
/>
</dd>
</div>
)}
{detail.purchase_date && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Purchase Date')}
</dt>
<dd className="font-medium">
{formatDateMedium(detail.purchase_date)}
</dd>
</div>
)}
{detail.area_value && detail.area_unit && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Area')}
</dt>
<dd className="font-medium">
{detail.area_value}{' '}
{formatAreaUnit(detail.area_unit)}
</dd>
</div>
)}
{linkedLoan && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Linked Mortgage / Loan')}
</dt>
<dd className="font-medium">
<a
href={show.url(linkedLoan.id)}
className="text-primary underline-offset-4 hover:underline"
>
{linkedLoan.name}
{linkedLoan.bank
? ` (${linkedLoan.bank.name})`
: ''}
</a>
</dd>
</div>
)}
{detail.revaluation_percentage != null && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Annual Revaluation')}
</dt>
<dd className="font-medium">
{Number(detail.revaluation_percentage) > 0
? '+'
: ''}
{detail.revaluation_percentage}%{__('/year')}
</dd>
</div>
)}
</dl>
{detail.notes && (
<div className="mt-4 border-t pt-4">
<dt className="text-sm text-muted-foreground">
{__('Notes')}
</dt>
<dd className="mt-1 text-sm whitespace-pre-wrap">
{detail.notes}
</dd>
</div>
)}
</CardContent>
</Card>
);
}
function LoanDetailsCard({
detail,
account,
loanAccountId,
isEditing,
onEditToggle,
onEditDialogOpen,
}: {
detail: LoanDetail | null;
account: AccountWithDetails;
loanAccountId?: string;
isEditing: boolean;
onEditToggle: (editing: boolean) => void;
onEditDialogOpen?: () => void;
}) {
const targetAccountId = loanAccountId ?? account.id;
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const [formData, setFormData] = useState({
annual_interest_rate: detail?.annual_interest_rate ?? '',
loan_term_months: detail?.loan_term_months
? String(detail.loan_term_months)
: '',
start_date: detail?.start_date?.slice(0, 10) ?? '',
original_amount: detail?.original_amount ?? 0,
});
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsSubmitting(true);
setErrors({});
router.patch(
updateLoanDetail.url(targetAccountId),
{
annual_interest_rate: formData.annual_interest_rate,
loan_term_months: Number(formData.loan_term_months),
start_date: formData.start_date,
original_amount: formData.original_amount,
},
{
preserveScroll: true,
onSuccess: () => onEditToggle(false),
onError: (errors) => setErrors(errors),
onFinish: () => setIsSubmitting(false),
},
);
}
function handleCancel() {
setFormData({
annual_interest_rate: detail?.annual_interest_rate ?? '',
loan_term_months: detail?.loan_term_months
? String(detail.loan_term_months)
: '',
start_date: detail?.start_date?.slice(0, 10) ?? '',
original_amount: detail?.original_amount ?? 0,
});
setErrors({});
onEditToggle(false);
}
if (isEditing) {
return (
<Card>
<CardHeader>
<CardTitle>{__('Edit Loan Details')}</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit_annual_interest_rate">
{__('Annual Interest Rate (%)')}
</Label>
<Input
id="edit_annual_interest_rate"
type="number"
value={formData.annual_interest_rate}
onChange={(e) =>
setFormData((prev) => ({
...prev,
annual_interest_rate:
e.target.value,
}))
}
placeholder="3.500"
min="0"
max="99.999"
step="0.001"
/>
<InputError
message={errors.annual_interest_rate}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit_loan_term_months">
{__('Loan Term (months)')}
</Label>
<Input
id="edit_loan_term_months"
type="number"
value={formData.loan_term_months}
onChange={(e) =>
setFormData((prev) => ({
...prev,
loan_term_months: e.target.value,
}))
}
placeholder="360"
min="1"
max="600"
/>
<InputError message={errors.loan_term_months} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="edit_loan_start_date">
{__('Start Date')}
</Label>
<Input
id="edit_loan_start_date"
type="date"
value={formData.start_date}
onChange={(e) =>
setFormData((prev) => ({
...prev,
start_date: e.target.value,
}))
}
/>
<InputError message={errors.start_date} />
</div>
<div className="space-y-2">
<Label htmlFor="edit_original_amount">
{__('Original Amount')}
</Label>
<AmountInput
id="edit_original_amount"
value={formData.original_amount}
onChange={(value) =>
setFormData((prev) => ({
...prev,
original_amount: value,
}))
}
currencyCode={account.currency_code}
/>
<InputError message={errors.original_amount} />
</div>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting}
>
{__('Cancel')}
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? __('Saving...') : __('Save')}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}
if (!detail) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{__('Loan Details')}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() =>
onEditDialogOpen
? onEditDialogOpen()
: onEditToggle(true)
}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
{__('Add')}
</Button>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{__(
'No loan details yet. Add interest rate, term, and amount to track amortization.',
)}
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{__('Loan Details')}</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() =>
onEditDialogOpen
? onEditDialogOpen()
: onEditToggle(true)
}
>
<Pencil className="mr-1 h-3.5 w-3.5" />
{__('Edit')}
</Button>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm text-muted-foreground">
{__('Annual Interest Rate')}
</dt>
<dd className="font-medium">
{detail.annual_interest_rate}%
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{__('Loan Term')}
</dt>
<dd className="font-medium">
{detail.loan_term_months} {__('months')}
{detail.loan_term_months >= 12 && (
<span className="ml-1 text-sm text-muted-foreground">
({Math.floor(detail.loan_term_months / 12)}{' '}
{__('years')})
</span>
)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{__('Start Date')}
</dt>
<dd className="font-medium">
{formatDateMedium(detail.start_date)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{__('Original Amount')}
</dt>
<dd className="font-medium">
<AmountDisplay
amountInCents={detail.original_amount}
currencyCode={account.currency_code}
/>
</dd>
</div>
{detail.monthly_payment !== null && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Monthly Payment')}
</dt>
<dd className="font-medium">
<AmountDisplay
amountInCents={detail.monthly_payment}
currencyCode={account.currency_code}
/>
</dd>
</div>
)}
{detail.remaining_months !== null && (
<div>
<dt className="text-sm text-muted-foreground">
{__('Remaining')}
</dt>
<dd className="font-medium">
{detail.remaining_months} {__('months')}
{detail.remaining_months >= 12 && (
<span className="ml-1 text-sm text-muted-foreground">
(
{Math.floor(
detail.remaining_months / 12,
)}{' '}
{__('years')})
</span>
)}
</dd>
</div>
)}
</dl>
</CardContent>
</Card>
);
}