feat(accounts): merge real estate accounts with linked mortgages in UI (#248)
Combine real estate and linked loan accounts into a unified card on the accounts list, showing equity as the primary balance with a dual-line sparkline (solid for market value, dashed for mortgage owed). Hide linked loans from the loan group and display mortgage bank info in the subtitle. On the detail page, render LoanDetailsCard for real estate accounts with linked loans and add header actions for editing loan details and updating owed amounts via dialogs.
This commit is contained in:
parent
8b71115afc
commit
6e976354ba
|
|
@ -27,13 +27,23 @@ class AccountController extends Controller
|
|||
|
||||
$accounts = Account::query()
|
||||
->where('user_id', $user->id)
|
||||
->with('bank:id,name,logo')
|
||||
->with(['bank:id,name,logo', 'realEstateDetail:account_id,linked_loan_account_id'])
|
||||
->orderByRaw("FIELD(type, 'checking', 'savings', 'investment', 'retirement', 'real_estate', 'loan', 'credit_card', 'others')")
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id']);
|
||||
|
||||
$accountsData = $accounts->map(function (Account $account) {
|
||||
$data = $account->only(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code', 'banking_connection_id', 'bank']);
|
||||
|
||||
if ($account->type === AccountType::RealEstate && $account->realEstateDetail?->linked_loan_account_id) {
|
||||
$data['linked_loan_account_id'] = $account->realEstateDetail->linked_loan_account_id;
|
||||
}
|
||||
|
||||
return $data;
|
||||
});
|
||||
|
||||
return Inertia::render('Accounts/Index', [
|
||||
'accounts' => $accounts,
|
||||
'accounts' => $accountsData,
|
||||
'accountMetrics' => Inertia::defer(fn () => $this->accountMetricsService->getAccountMetrics($user->currency_code, $accounts)),
|
||||
]);
|
||||
}
|
||||
|
|
@ -72,6 +82,37 @@ class AccountController extends Controller
|
|||
->where('balance_date', '<=', now()->toDateString())
|
||||
->orderByDesc('balance_date')
|
||||
->value('balance') ?? 0;
|
||||
|
||||
// Include linked loan account at top level for header actions
|
||||
$data['linked_loan_account'] = $linkedLoan->only(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank', 'banking_connection_id']);
|
||||
|
||||
// Load loan amortization details for the linked loan
|
||||
$linkedLoan->load('loanDetail');
|
||||
$loanDetail = $linkedLoan->loanDetail;
|
||||
|
||||
if ($loanDetail) {
|
||||
$remainingMonths = $this->loanAmortizationService->calculateRemainingMonths($loanDetail, now());
|
||||
|
||||
$lastLoanBalance = AccountBalance::query()
|
||||
->where('account_id', $linkedLoan->id)
|
||||
->orderBy('balance_date', 'desc')
|
||||
->value('balance');
|
||||
|
||||
$monthlyPayment = $this->loanAmortizationService->calculateMonthlyPayment(
|
||||
$lastLoanBalance ?? $loanDetail->original_amount,
|
||||
(float) $loanDetail->annual_interest_rate,
|
||||
$lastLoanBalance ? $remainingMonths : $loanDetail->loan_term_months,
|
||||
);
|
||||
|
||||
$data['loan_detail'] = [
|
||||
...$loanDetail->only([
|
||||
'id', 'annual_interest_rate', 'loan_term_months',
|
||||
'start_date', 'original_amount',
|
||||
]),
|
||||
'monthly_payment' => $monthlyPayment,
|
||||
'remaining_months' => $remainingMonths,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$data['real_estate_detail']['current_market_value'] = AccountBalance::query()
|
||||
|
|
|
|||
|
|
@ -9,25 +9,88 @@ import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
|
|||
import { formatAccountType, supportsInvestedAmount } from '@/types/account';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
import { Button } from '../ui/button';
|
||||
import { UpdateBalanceDialog } from './update-balance-dialog';
|
||||
|
||||
interface LinkedLoanMetrics {
|
||||
currentBalance: number;
|
||||
previousBalance: number;
|
||||
diff: number;
|
||||
history: Array<{
|
||||
date: string;
|
||||
value: number;
|
||||
}>;
|
||||
loanAccount?: {
|
||||
name: string;
|
||||
bank: { name: string; logo: string | null } | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface AccountListCardProps {
|
||||
account: AccountWithMetrics;
|
||||
loading?: boolean;
|
||||
onBalanceUpdated?: () => void;
|
||||
linkedLoanMetrics?: LinkedLoanMetrics;
|
||||
}
|
||||
|
||||
export function AccountListCard({
|
||||
account,
|
||||
loading,
|
||||
onBalanceUpdated,
|
||||
linkedLoanMetrics,
|
||||
}: AccountListCardProps) {
|
||||
const { accountMainLineColor, accountGainLineColor } = useChartColors();
|
||||
const { accountMainLineColor, accountGainLineColor, mortgageLineColor } =
|
||||
useChartColors();
|
||||
const [updateBalanceOpen, setUpdateBalanceOpen] = useState(false);
|
||||
|
||||
const hasMortgage = !!linkedLoanMetrics;
|
||||
|
||||
// Compute equity data when this is a real estate account with a linked mortgage
|
||||
const equityData = useMemo(() => {
|
||||
if (!hasMortgage) return null;
|
||||
|
||||
const marketValue = account.currentBalance;
|
||||
const mortgageOwed = linkedLoanMetrics.currentBalance;
|
||||
const equity = marketValue - mortgageOwed;
|
||||
|
||||
const prevMarketValue = account.previousBalance;
|
||||
const prevMortgageOwed = linkedLoanMetrics.previousBalance;
|
||||
const prevEquity = prevMarketValue - prevMortgageOwed;
|
||||
const equityDiff = equity - prevEquity;
|
||||
|
||||
// Build dual-line sparkline: market value (solid) + mortgage owed (dashed)
|
||||
const marketMap = new Map(
|
||||
account.history.map((h) => [h.date, h.value]),
|
||||
);
|
||||
const mortgageMap = new Map(
|
||||
linkedLoanMetrics.history.map((h) => [h.date, h.value]),
|
||||
);
|
||||
const allDates = [
|
||||
...new Set([...marketMap.keys(), ...mortgageMap.keys()]),
|
||||
].sort();
|
||||
|
||||
let lastMarket = 0;
|
||||
let lastMortgage = 0;
|
||||
const dualHistory = allDates.map((date) => {
|
||||
const mv = marketMap.get(date) ?? lastMarket;
|
||||
const mo = mortgageMap.get(date) ?? lastMortgage;
|
||||
lastMarket = mv;
|
||||
lastMortgage = mo;
|
||||
return { date, value: mv, mortgageOwed: mo };
|
||||
});
|
||||
|
||||
return {
|
||||
equity,
|
||||
prevEquity,
|
||||
equityDiff,
|
||||
mortgageOwed,
|
||||
marketValue,
|
||||
dualHistory,
|
||||
};
|
||||
}, [hasMortgage, account, linkedLoanMetrics]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="w-full">
|
||||
|
|
@ -53,9 +116,19 @@ export function AccountListCard({
|
|||
);
|
||||
}
|
||||
|
||||
const isPositive = account.diff >= 0;
|
||||
const displayBalance = equityData
|
||||
? equityData.equity
|
||||
: account.currentBalance;
|
||||
const displayDiff = equityData ? equityData.equityDiff : account.diff;
|
||||
const displayPreviousBalance = equityData
|
||||
? equityData.prevEquity
|
||||
: account.previousBalance;
|
||||
const isPositive = displayDiff >= 0;
|
||||
const isConnected = !!account.banking_connection_id;
|
||||
|
||||
// Choose sparkline data: dual-line history for merged, account history for normal
|
||||
const sparklineData = equityData ? equityData.dualHistory : account.history;
|
||||
|
||||
return (
|
||||
<Card className="w-full py-0">
|
||||
<CardContent className="p-4">
|
||||
|
|
@ -84,10 +157,38 @@ export function AccountListCard({
|
|||
</h3>
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>
|
||||
{account.bank?.name ||
|
||||
formatAccountType(account.type)}
|
||||
</span>
|
||||
{hasMortgage &&
|
||||
linkedLoanMetrics.loanAccount ? (
|
||||
<span className="flex items-center gap-1">
|
||||
{__('Mortgage at')}{' '}
|
||||
{linkedLoanMetrics.loanAccount
|
||||
.bank && (
|
||||
<BankLogo
|
||||
src={
|
||||
linkedLoanMetrics
|
||||
.loanAccount.bank
|
||||
.logo
|
||||
}
|
||||
name={
|
||||
linkedLoanMetrics
|
||||
.loanAccount.bank
|
||||
.name
|
||||
}
|
||||
className="size-3.5 shrink-0"
|
||||
fallback="letter"
|
||||
/>
|
||||
)}
|
||||
{linkedLoanMetrics.loanAccount.bank
|
||||
?.name ??
|
||||
linkedLoanMetrics.loanAccount
|
||||
.name}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
{account.bank?.name ||
|
||||
formatAccountType(account.type)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -95,7 +196,7 @@ export function AccountListCard({
|
|||
{isConnected ? (
|
||||
<div className="-mr-2 px-2 py-1">
|
||||
<AmountDisplay
|
||||
amountInCents={account.currentBalance}
|
||||
amountInCents={displayBalance}
|
||||
currencyCode={account.currency_code}
|
||||
size="2xl"
|
||||
weight="bold"
|
||||
|
|
@ -108,7 +209,7 @@ export function AccountListCard({
|
|||
className="-mr-2 cursor-pointer rounded-md px-2 py-1 transition-colors hover:bg-muted"
|
||||
>
|
||||
<AmountDisplay
|
||||
amountInCents={account.currentBalance}
|
||||
amountInCents={displayBalance}
|
||||
currencyCode={account.currency_code}
|
||||
size="2xl"
|
||||
weight="bold"
|
||||
|
|
@ -117,23 +218,24 @@ export function AccountListCard({
|
|||
)}
|
||||
<AmountTrendIndicator
|
||||
isPositive={isPositive}
|
||||
trend={Math.abs(account.diff)}
|
||||
trend={Math.abs(displayDiff)}
|
||||
label={__('vs last month')}
|
||||
className="text-sm"
|
||||
previousAmount={account.previousBalance}
|
||||
currentAmount={account.currentBalance}
|
||||
previousAmount={displayPreviousBalance}
|
||||
currentAmount={displayBalance}
|
||||
tooltipSide="bottom"
|
||||
currencyCode={account.currency_code}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[100px] w-full">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
initialDimension={{ width: 1, height: 1 }}
|
||||
>
|
||||
<LineChart data={account.history}>
|
||||
<LineChart data={sparklineData}>
|
||||
<Tooltip
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length)
|
||||
|
|
@ -142,7 +244,68 @@ export function AccountListCard({
|
|||
date: string;
|
||||
value: number;
|
||||
investedAmount?: number | null;
|
||||
mortgageOwed?: number;
|
||||
};
|
||||
|
||||
if (equityData) {
|
||||
const equity =
|
||||
data.value -
|
||||
(data.mortgageOwed ?? 0);
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-sm shadow-xl">
|
||||
<p className="mb-1 text-muted-foreground">
|
||||
{data.date}
|
||||
</p>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
|
||||
<span className="text-muted-foreground">
|
||||
{__('Market Value')}
|
||||
</span>
|
||||
<span className="text-right font-mono font-medium text-foreground tabular-nums">
|
||||
<AmountDisplay
|
||||
amountInCents={
|
||||
data.value
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{__(
|
||||
'Mortgage Owed',
|
||||
)}
|
||||
</span>
|
||||
<span className="text-right font-mono text-muted-foreground tabular-nums">
|
||||
<AmountDisplay
|
||||
amountInCents={
|
||||
data.mortgageOwed ??
|
||||
0
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{__('Equity')}
|
||||
</span>
|
||||
<span
|
||||
className={`text-right font-mono whitespace-nowrap tabular-nums ${equity >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
|
||||
>
|
||||
<AmountDisplay
|
||||
amountInCents={
|
||||
equity
|
||||
}
|
||||
currencyCode={
|
||||
account.currency_code
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const invested = supportsInvestedAmount(
|
||||
account,
|
||||
)
|
||||
|
|
@ -234,17 +397,29 @@ export function AccountListCard({
|
|||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
{supportsInvestedAmount(account) && (
|
||||
{equityData && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="investedAmount"
|
||||
stroke={accountGainLineColor}
|
||||
dataKey="mortgageOwed"
|
||||
stroke={mortgageLineColor}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
{!equityData &&
|
||||
supportsInvestedAmount(account) && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="investedAmount"
|
||||
stroke={accountGainLineColor}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
import { update as updateLoanDetail } from '@/actions/App/Http/Controllers/LoanDetailController';
|
||||
import InputError from '@/components/input-error';
|
||||
import { AmountInput } from '@/components/ui/amount-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import type { CurrencyCode, LoanDetail } from '@/types/account';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface EditLoanDetailDialogProps {
|
||||
loanAccountId: string;
|
||||
currencyCode: CurrencyCode;
|
||||
detail: LoanDetail | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EditLoanDetailDialog({
|
||||
loanAccountId,
|
||||
currencyCode,
|
||||
detail,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EditLoanDetailDialogProps) {
|
||||
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,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
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({});
|
||||
}
|
||||
}, [open, detail]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setErrors({});
|
||||
|
||||
router.patch(
|
||||
updateLoanDetail.url(loanAccountId),
|
||||
{
|
||||
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: () => onOpenChange(false),
|
||||
onError: (errors) => setErrors(errors),
|
||||
onFinish: () => setIsSubmitting(false),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent hasKeyboard className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{detail
|
||||
? __('Edit Loan Details')
|
||||
: __('Add Loan Details')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__(
|
||||
'Set interest rate, term, and amount to track amortization.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dialog_annual_interest_rate">
|
||||
{__('Annual Interest Rate (%)')}
|
||||
</Label>
|
||||
<Input
|
||||
id="dialog_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="dialog_loan_term_months">
|
||||
{__('Loan Term (months)')}
|
||||
</Label>
|
||||
<Input
|
||||
id="dialog_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="dialog_loan_start_date">
|
||||
{__('Start Date')}
|
||||
</Label>
|
||||
<Input
|
||||
id="dialog_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="dialog_original_amount">
|
||||
{__('Original Amount')}
|
||||
</Label>
|
||||
<AmountInput
|
||||
id="dialog_original_amount"
|
||||
value={formData.original_amount}
|
||||
onChange={(value) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
original_amount: value,
|
||||
}))
|
||||
}
|
||||
currencyCode={currencyCode}
|
||||
/>
|
||||
<InputError message={errors.original_amount} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{__('Cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? __('Saving...') : __('Save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -50,6 +50,20 @@ interface Props {
|
|||
export default function AccountsIndex({ accounts, accountMetrics }: Props) {
|
||||
const isLoading = !accountMetrics;
|
||||
|
||||
// Identify loan account IDs that are linked to a real estate account
|
||||
const linkedLoanAccountIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
accounts.forEach((account) => {
|
||||
if (
|
||||
account.type === 'real_estate' &&
|
||||
account.linked_loan_account_id
|
||||
) {
|
||||
ids.add(account.linked_loan_account_id);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}, [accounts]);
|
||||
|
||||
const accountsWithMetrics: AccountWithMetrics[] = useMemo(() => {
|
||||
return accounts.map((account) => {
|
||||
const metrics = accountMetrics?.[account.id];
|
||||
|
|
@ -78,6 +92,12 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) {
|
|||
|
||||
accountsWithMetrics.forEach((account) => {
|
||||
const type = account.type as AccountType;
|
||||
|
||||
// Hide loan accounts that are linked to a real estate account
|
||||
if (type === 'loan' && linkedLoanAccountIds.has(account.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (groups[type]) {
|
||||
groups[type].push(account);
|
||||
} else {
|
||||
|
|
@ -86,7 +106,39 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) {
|
|||
});
|
||||
|
||||
return groups;
|
||||
}, [accountsWithMetrics]);
|
||||
}, [accountsWithMetrics, linkedLoanAccountIds]);
|
||||
|
||||
// Build a map of linked loan metrics keyed by real estate account ID
|
||||
const linkedLoanMetricsMap = useMemo(() => {
|
||||
if (!accountMetrics) return {};
|
||||
const map: Record<
|
||||
string,
|
||||
AccountMetrics & {
|
||||
loanAccount?: {
|
||||
name: string;
|
||||
bank: { name: string; logo: string | null } | null;
|
||||
};
|
||||
}
|
||||
> = {};
|
||||
accounts.forEach((account) => {
|
||||
if (
|
||||
account.type === 'real_estate' &&
|
||||
account.linked_loan_account_id &&
|
||||
accountMetrics[account.linked_loan_account_id]
|
||||
) {
|
||||
const loanAccount = accounts.find(
|
||||
(a) => a.id === account.linked_loan_account_id,
|
||||
);
|
||||
map[account.id] = {
|
||||
...accountMetrics[account.linked_loan_account_id],
|
||||
loanAccount: loanAccount
|
||||
? { name: loanAccount.name, bank: loanAccount.bank }
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [accounts, accountMetrics]);
|
||||
|
||||
const handleBalanceUpdated = useCallback(() => {
|
||||
router.reload({ only: ['accountMetrics'] });
|
||||
|
|
@ -120,6 +172,9 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) {
|
|||
account={account}
|
||||
loading={isLoading}
|
||||
onBalanceUpdated={handleBalanceUpdated}
|
||||
linkedLoanMetrics={
|
||||
linkedLoanMetricsMap[account.id]
|
||||
}
|
||||
/>
|
||||
));
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { BalancesModal } from '@/components/accounts/balances-modal';
|
||||
import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog';
|
||||
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';
|
||||
|
|
@ -70,6 +71,7 @@ interface AccountWithDetails extends Account {
|
|||
real_estate_detail?: RealEstateDetail;
|
||||
available_loan_accounts?: Account[];
|
||||
loan_detail?: LoanDetail;
|
||||
linked_loan_account?: Account;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -92,11 +94,13 @@ export default function AccountShow({
|
|||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = 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 [chartComputedData, setChartComputedData] =
|
||||
useState<ChartComputedData | null>(null);
|
||||
|
||||
|
|
@ -113,6 +117,8 @@ export default function AccountShow({
|
|||
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 breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
|
|
@ -168,13 +174,96 @@ export default function AccountShow({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{isConnected ? (
|
||||
{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>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
variant="destructive"
|
||||
>
|
||||
{__('Delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</ButtonGroup>
|
||||
</ButtonGroup>
|
||||
) : (
|
||||
<ButtonGroup>
|
||||
<ButtonGroup>
|
||||
|
|
@ -265,6 +354,17 @@ export default function AccountShow({
|
|||
/>
|
||||
)}
|
||||
|
||||
{isRealEstate && hasLinkedLoan && (
|
||||
<LoanDetailsCard
|
||||
detail={loanDetail ?? null}
|
||||
account={account}
|
||||
loanAccountId={linkedLoanAccount.id}
|
||||
isEditing={editingLoanDetails}
|
||||
onEditToggle={setEditingLoanDetails}
|
||||
onEditDialogOpen={() => setEditLoanDialogOpen(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoan && (
|
||||
<LoanDetailsCard
|
||||
detail={loanDetail ?? null}
|
||||
|
|
@ -327,6 +427,27 @@ export default function AccountShow({
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -986,14 +1107,19 @@ function PropertyDetailsCard({
|
|||
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({
|
||||
|
|
@ -1011,7 +1137,7 @@ function LoanDetailsCard({
|
|||
setErrors({});
|
||||
|
||||
router.patch(
|
||||
updateLoanDetail.url(account.id),
|
||||
updateLoanDetail.url(targetAccountId),
|
||||
{
|
||||
annual_interest_rate: formData.annual_interest_rate,
|
||||
loan_term_months: Number(formData.loan_term_months),
|
||||
|
|
@ -1161,7 +1287,11 @@ function LoanDetailsCard({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEditToggle(true)}
|
||||
onClick={() =>
|
||||
onEditDialogOpen
|
||||
? onEditDialogOpen()
|
||||
: onEditToggle(true)
|
||||
}
|
||||
>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
{__('Add')}
|
||||
|
|
@ -1185,7 +1315,11 @@ function LoanDetailsCard({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEditToggle(true)}
|
||||
onClick={() =>
|
||||
onEditDialogOpen
|
||||
? onEditDialogOpen()
|
||||
: onEditToggle(true)
|
||||
}
|
||||
>
|
||||
<Pencil className="mr-1 h-3.5 w-3.5" />
|
||||
{__('Edit')}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export interface Account {
|
|||
banking_connection_id: UUID | null;
|
||||
external_account_id: string | null;
|
||||
linked_at: string | null;
|
||||
linked_loan_account_id?: UUID | null;
|
||||
}
|
||||
|
||||
export interface AccountBalance {
|
||||
|
|
|
|||
Loading…
Reference in New Issue