From 6e976354ba2e673d5b183bacc3e9a896937ee54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Thu, 26 Mar 2026 20:54:12 +0100 Subject: [PATCH] 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. --- app/Http/Controllers/AccountController.php | 45 +++- .../components/accounts/account-list-card.tsx | 207 ++++++++++++++++-- .../accounts/edit-loan-detail-dialog.tsx | 199 +++++++++++++++++ resources/js/pages/Accounts/Index.tsx | 57 ++++- resources/js/pages/Accounts/Show.tsx | 142 +++++++++++- resources/js/types/account.ts | 1 + 6 files changed, 628 insertions(+), 23 deletions(-) create mode 100644 resources/js/components/accounts/edit-loan-detail-dialog.tsx diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 1dd1e423..c21dd571 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -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() diff --git a/resources/js/components/accounts/account-list-card.tsx b/resources/js/components/accounts/account-list-card.tsx index cccbfabd..0cd9d0a0 100644 --- a/resources/js/components/accounts/account-list-card.tsx +++ b/resources/js/components/accounts/account-list-card.tsx @@ -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 ( @@ -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 ( @@ -84,10 +157,38 @@ export function AccountListCard({
- - {account.bank?.name || - formatAccountType(account.type)} - + {hasMortgage && + linkedLoanMetrics.loanAccount ? ( + + {__('Mortgage at')}{' '} + {linkedLoanMetrics.loanAccount + .bank && ( + + )} + {linkedLoanMetrics.loanAccount.bank + ?.name ?? + linkedLoanMetrics.loanAccount + .name} + + ) : ( + + {account.bank?.name || + formatAccountType(account.type)} + + )}
@@ -95,7 +196,7 @@ export function AccountListCard({ {isConnected ? (
+
- + { 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 ( +
+

+ {data.date} +

+
+ + {__('Market Value')} + + + + + + {__( + 'Mortgage Owed', + )} + + + + + + {__('Equity')} + + = 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`} + > + + +
+
+ ); + } + const invested = supportsInvestedAmount( account, ) @@ -234,17 +397,29 @@ export function AccountListCard({ strokeWidth={2} dot={false} /> - {supportsInvestedAmount(account) && ( + {equityData && ( )} + {!equityData && + supportsInvestedAmount(account) && ( + + )}
diff --git a/resources/js/components/accounts/edit-loan-detail-dialog.tsx b/resources/js/components/accounts/edit-loan-detail-dialog.tsx new file mode 100644 index 00000000..17a40801 --- /dev/null +++ b/resources/js/components/accounts/edit-loan-detail-dialog.tsx @@ -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>({}); + 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 ( + + + + + {detail + ? __('Edit Loan Details') + : __('Add Loan Details')} + + + {__( + 'Set interest rate, term, and amount to track amortization.', + )} + + + +
+
+
+ + + setFormData((prev) => ({ + ...prev, + annual_interest_rate: e.target.value, + })) + } + placeholder="3.500" + min="0" + max="99.999" + step="0.001" + /> + +
+ +
+ + + setFormData((prev) => ({ + ...prev, + loan_term_months: e.target.value, + })) + } + placeholder="360" + min="1" + max="600" + /> + +
+
+ +
+
+ + + setFormData((prev) => ({ + ...prev, + start_date: e.target.value, + })) + } + /> + +
+ +
+ + + setFormData((prev) => ({ + ...prev, + original_amount: value, + })) + } + currencyCode={currencyCode} + /> + +
+
+ + + + + +
+
+
+ ); +} diff --git a/resources/js/pages/Accounts/Index.tsx b/resources/js/pages/Accounts/Index.tsx index 2e526b71..84e275bd 100644 --- a/resources/js/pages/Accounts/Index.tsx +++ b/resources/js/pages/Accounts/Index.tsx @@ -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(); + 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] + } /> )); })} diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx index 35b93974..d2229b66 100644 --- a/resources/js/pages/Accounts/Show.tsx +++ b/resources/js/pages/Accounts/Show.tsx @@ -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(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({ /> - {isConnected ? ( + {isConnected && !hasLinkedLoan ? ( + ) : isConnected && hasLinkedLoan ? ( + + + + + ) : !isConnected && hasLinkedLoan ? ( + + + + + + + + + + + + + setBalancesOpen(true) + } + > + {__('See market values')} + + + setImportBalancesOpen(true) + } + > + {__('Import market values')} + + + setEditOpen(true)} + > + {__('Edit account')} + + + setEditLoanDialogOpen(true) + } + > + {__('Edit loan details')} + + + setDeleteOpen(true)} + variant="destructive" + > + {__('Delete')} + + + + + ) : ( @@ -265,6 +354,17 @@ export default function AccountShow({ /> )} + {isRealEstate && hasLinkedLoan && ( + setEditLoanDialogOpen(true)} + /> + )} + {isLoan && ( + + {hasLinkedLoan && ( + <> + + + + )} ); } @@ -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>({}); 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({