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({