feat(accounts): show mortgage data and equity on real estate account page (#243)
## Summary - Add mortgage balance as a dashed line overlay on the property value chart when a real estate account has a linked loan - Display equity (market value - mortgage owed) in the chart header and as a 3-column summary strip (Market Value | Mortgage Owed | Equity) in the property details card - Extend balance evolution API endpoints to include `mortgage_balance` data points for linked loan accounts - Add 6 new tests covering mortgage/equity display in account show page and balance evolution endpoints
This commit is contained in:
parent
8ac6ed4d83
commit
973243277a
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Services\AccountMetricsService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -45,16 +46,33 @@ class AccountController extends Controller
|
|||
$realEstateDetail = $account->realEstateDetail;
|
||||
|
||||
if ($realEstateDetail) {
|
||||
$linkedLoan = $realEstateDetail->linkedLoanAccount;
|
||||
|
||||
$data['real_estate_detail'] = [
|
||||
...$realEstateDetail->only([
|
||||
'id', 'property_type', 'address', 'purchase_price',
|
||||
'purchase_date', 'area_value', 'area_unit', 'notes',
|
||||
'linked_loan_account_id',
|
||||
]),
|
||||
'linked_loan_account' => $realEstateDetail->linkedLoanAccount
|
||||
? $realEstateDetail->linkedLoanAccount->only(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank'])
|
||||
'linked_loan_account' => $linkedLoan
|
||||
? $linkedLoan->only(['id', 'name', 'name_iv', 'encrypted', 'type', 'currency_code', 'bank'])
|
||||
: null,
|
||||
];
|
||||
|
||||
// Include current balances for equity calculation
|
||||
if ($linkedLoan) {
|
||||
$data['real_estate_detail']['current_loan_balance'] = AccountBalance::query()
|
||||
->where('account_id', $linkedLoan->id)
|
||||
->where('balance_date', '<=', now()->toDateString())
|
||||
->orderByDesc('balance_date')
|
||||
->value('balance') ?? 0;
|
||||
}
|
||||
|
||||
$data['real_estate_detail']['current_market_value'] = AccountBalance::query()
|
||||
->where('account_id', $account->id)
|
||||
->where('balance_date', '<=', now()->toDateString())
|
||||
->orderByDesc('balance_date')
|
||||
->value('balance') ?? 0;
|
||||
}
|
||||
|
||||
// Provide available loan accounts for linking
|
||||
|
|
|
|||
|
|
@ -109,7 +109,10 @@ class DashboardAnalyticsController extends Controller
|
|||
$start = Carbon::parse($validated['from']);
|
||||
$end = Carbon::parse($validated['to']);
|
||||
|
||||
$lookup = BalanceLookup::forAccounts([$account->id], $start->copy()->startOfMonth(), $end);
|
||||
$linkedLoanId = $this->getLinkedLoanAccountId($account);
|
||||
$accountIds = $linkedLoanId ? [$account->id, $linkedLoanId] : [$account->id];
|
||||
|
||||
$lookup = BalanceLookup::forAccounts($accountIds, $start->copy()->startOfMonth(), $end);
|
||||
|
||||
$points = [];
|
||||
$current = $start->copy()->startOfMonth();
|
||||
|
|
@ -127,6 +130,10 @@ class DashboardAnalyticsController extends Controller
|
|||
$point['invested_amount'] = $lookup->getInvestedAmountAt($account->id, $date);
|
||||
}
|
||||
|
||||
if ($linkedLoanId) {
|
||||
$point['mortgage_balance'] = $lookup->getBalanceAt($linkedLoanId, $date);
|
||||
}
|
||||
|
||||
$points[] = $point;
|
||||
$current->addMonth();
|
||||
}
|
||||
|
|
@ -158,7 +165,10 @@ class DashboardAnalyticsController extends Controller
|
|||
$start = Carbon::parse($validated['from']);
|
||||
$end = Carbon::parse($validated['to']);
|
||||
|
||||
$lookup = BalanceLookup::forAccounts([$account->id], $start, $end);
|
||||
$linkedLoanId = $this->getLinkedLoanAccountId($account);
|
||||
$accountIds = $linkedLoanId ? [$account->id, $linkedLoanId] : [$account->id];
|
||||
|
||||
$lookup = BalanceLookup::forAccounts($accountIds, $start, $end);
|
||||
|
||||
$points = [];
|
||||
$current = $start->copy();
|
||||
|
|
@ -175,6 +185,10 @@ class DashboardAnalyticsController extends Controller
|
|||
$point['invested_amount'] = $lookup->getInvestedAmountAt($account->id, $date);
|
||||
}
|
||||
|
||||
if ($linkedLoanId) {
|
||||
$point['mortgage_balance'] = $lookup->getBalanceAt($linkedLoanId, $date);
|
||||
}
|
||||
|
||||
$points[] = $point;
|
||||
$current->addDay();
|
||||
}
|
||||
|
|
@ -336,4 +350,16 @@ class DashboardAnalyticsController extends Controller
|
|||
'expense' => abs($expense),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the linked loan account ID for a real estate account, if any.
|
||||
*/
|
||||
private function getLinkedLoanAccountId(Account $account): ?string
|
||||
{
|
||||
if ($account->type !== \App\Enums\AccountType::RealEstate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $account->realEstateDetail?->linked_loan_account_id;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -485,6 +485,7 @@
|
|||
"Entertainment (Movies, Sports, Hobbies)": "Entretenimiento (Pel\u00edculas, Deportes, Hobbies)",
|
||||
"Entertainment \u00b7 Only $8 remaining this month": "Entretenimiento \u00b7 Solo $8 restante este mes",
|
||||
"Entertainment \u2192 $100": "Entretenimiento \u2192 $100",
|
||||
"Equity": "Patrimonio neto",
|
||||
"Error": "Error",
|
||||
"Errors (": "Errores (",
|
||||
"Espa\u00f1ol": "Espa\u00f1ol",
|
||||
|
|
@ -763,6 +764,8 @@
|
|||
"Map Columns": "Mapear Columnas",
|
||||
"Market Value": "Valor de Mercado",
|
||||
"Market Values": "Valores de Mercado",
|
||||
"Market value": "Valor de mercado",
|
||||
"Market value evolution": "Evolución del valor de mercado",
|
||||
"Match your file columns to transaction fields": "Asocia las columnas de tu archivo con los campos de transacci\u00f3n",
|
||||
"Max": "M\u00e1x",
|
||||
"Maybe later": "Quiz\u00e1s m\u00e1s tarde",
|
||||
|
|
@ -780,6 +783,8 @@
|
|||
"More": "M\u00e1s",
|
||||
"More actions": "M\u00e1s acciones",
|
||||
"More options": "M\u00e1s opciones",
|
||||
"Mortgage Owed": "Hipoteca pendiente",
|
||||
"Mortgage owed": "Hipoteca pendiente",
|
||||
"Mortgages and loans": "Hipotecas y pr\u00e9stamos",
|
||||
"Most Popular": "M\u00e1s Popular",
|
||||
"Move Up \u2014 Share Your Personal Link": "Sube puestos \u2014 Comparte Tu Enlace Personal",
|
||||
|
|
@ -830,6 +835,7 @@
|
|||
"No labels found.": "No se encontraron etiquetas.",
|
||||
"No limits on bank accounts, transactions, or categories.": "Sin l\u00edmites en cuentas bancarias, transacciones o categor\u00edas.",
|
||||
"No linked loan": "Sin pr\u00e9stamo vinculado",
|
||||
"No market value data available": "No hay datos de valor de mercado disponibles",
|
||||
"No owed amount data available": "No hay datos de monto adeudado disponibles",
|
||||
"No owed amount records found.": "No se encontraron registros de monto adeudado.",
|
||||
"No results found": "No se encontraron resultados",
|
||||
|
|
@ -1653,6 +1659,7 @@
|
|||
"violet": "violeta",
|
||||
"vs last month": "vs mes anterior",
|
||||
"vs last period": "vs per\u00edodo anterior",
|
||||
"vs start": "vs inicio",
|
||||
"will be updated or created.": "ser\u00e1 actualizado o creado.",
|
||||
"yellow": "amarillo",
|
||||
"\u2014 $9/month": "\u2014 $9/mes",
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ import {
|
|||
} from '@/hooks/use-chart-views';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { Account, supportsInvestedAmount } from '@/types/account';
|
||||
import {
|
||||
Account,
|
||||
isRealEstateAccount,
|
||||
supportsInvestedAmount,
|
||||
} from '@/types/account';
|
||||
import { formatCurrency } from '@/utils/currency';
|
||||
import { formatDayFromDate, formatMonthFromYearMonth } from '@/utils/date';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
|
@ -120,11 +124,106 @@ function InvestmentTooltipContent({
|
|||
);
|
||||
}
|
||||
|
||||
interface BalanceDataPoint {
|
||||
function RealEstateTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
valueFormatter,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
dataKey?: string | number;
|
||||
name?: string;
|
||||
value?: number | string;
|
||||
color?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}>;
|
||||
valueFormatter: (value: number) => string;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
const marketValueItem = payload.find((p) => p.dataKey === 'value');
|
||||
const mortgageItem = payload.find((p) => p.dataKey === 'mortgage_balance');
|
||||
|
||||
const marketValue =
|
||||
typeof marketValueItem?.value === 'number'
|
||||
? marketValueItem.value
|
||||
: null;
|
||||
const mortgageOwed =
|
||||
typeof mortgageItem?.value === 'number' ? mortgageItem.value : null;
|
||||
const equity =
|
||||
marketValue !== null && mortgageOwed !== null
|
||||
? marketValue - mortgageOwed
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
|
||||
<div className="grid gap-1.5">
|
||||
{marketValue !== null && (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div
|
||||
className="size-2.5 rounded-xs"
|
||||
style={{
|
||||
backgroundColor:
|
||||
marketValueItem?.color ??
|
||||
'var(--color-chart-2)',
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-1 justify-between gap-4">
|
||||
<span className="text-muted-foreground">
|
||||
{__('Market value')}
|
||||
</span>
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{valueFormatter(marketValue)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mortgageOwed !== null && (
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div
|
||||
className="size-2.5 rounded-xs"
|
||||
style={{
|
||||
backgroundColor:
|
||||
mortgageItem?.color ??
|
||||
'var(--color-chart-5)',
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-1 justify-between gap-4">
|
||||
<span className="text-muted-foreground">
|
||||
{__('Mortgage owed')}
|
||||
</span>
|
||||
<span className="font-mono font-medium text-foreground tabular-nums">
|
||||
{valueFormatter(mortgageOwed)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{equity !== null && (
|
||||
<div className="flex w-full items-center gap-2 border-t border-border/50 pt-1.5">
|
||||
<div className="size-2.5" />
|
||||
<div className="flex flex-1 justify-between gap-4">
|
||||
<span className="text-muted-foreground">
|
||||
{__('Equity')}
|
||||
</span>
|
||||
<span
|
||||
className={`font-mono font-medium whitespace-nowrap tabular-nums ${equity >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
|
||||
>
|
||||
{valueFormatter(equity)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface BalanceDataPoint {
|
||||
month: string;
|
||||
timestamp: number;
|
||||
value: number;
|
||||
invested_amount?: number | null;
|
||||
mortgage_balance?: number | null;
|
||||
}
|
||||
|
||||
interface AccountBalanceData {
|
||||
|
|
@ -143,6 +242,7 @@ interface DailyBalanceDataPoint {
|
|||
timestamp: number;
|
||||
value: number;
|
||||
invested_amount?: number | null;
|
||||
mortgage_balance?: number | null;
|
||||
}
|
||||
|
||||
interface AccountDailyBalanceData {
|
||||
|
|
@ -156,11 +256,21 @@ interface AccountDailyBalanceData {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ChartComputedData {
|
||||
chartData: BalanceDataPoint[];
|
||||
currentBalance: number;
|
||||
currentMortgageBalance: number | null;
|
||||
hasMortgageData: boolean;
|
||||
shortTrend: ReturnType<typeof calculateTrend>;
|
||||
longTrend: ReturnType<typeof calculateTrend>;
|
||||
}
|
||||
|
||||
interface AccountBalanceChartProps {
|
||||
account: Account;
|
||||
loading?: boolean;
|
||||
refreshKey?: number;
|
||||
onBalanceClick?: () => void;
|
||||
onDataLoaded?: (data: ChartComputedData) => void;
|
||||
}
|
||||
|
||||
function createXAxisFormatter(locale: string, granularity: ChartGranularity) {
|
||||
|
|
@ -216,6 +326,7 @@ function normalizeDailyData(data: DailyBalanceDataPoint[]): BalanceDataPoint[] {
|
|||
timestamp: point.timestamp,
|
||||
value: point.value,
|
||||
invested_amount: point.invested_amount,
|
||||
mortgage_balance: point.mortgage_balance,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
@ -224,10 +335,12 @@ export function AccountBalanceChart({
|
|||
loading: initialLoading,
|
||||
refreshKey,
|
||||
onBalanceClick,
|
||||
onDataLoaded,
|
||||
}: AccountBalanceChartProps) {
|
||||
const locale = useLocale();
|
||||
const isMobile = useIsMobile();
|
||||
const isLoan = account.type === 'loan';
|
||||
const isRealEstate = isRealEstateAccount(account);
|
||||
const [granularity, setGranularity] = useState<ChartGranularity>('monthly');
|
||||
const [balanceData, setBalanceData] = useState<AccountBalanceData | null>(
|
||||
null,
|
||||
|
|
@ -282,6 +395,8 @@ export function AccountBalanceChart({
|
|||
chartData,
|
||||
currentBalance,
|
||||
currentInvestedAmount,
|
||||
currentMortgageBalance,
|
||||
hasMortgageData,
|
||||
shortTrend,
|
||||
longTrend,
|
||||
} = useMemo(() => {
|
||||
|
|
@ -290,6 +405,8 @@ export function AccountBalanceChart({
|
|||
chartData: [],
|
||||
currentBalance: 0,
|
||||
currentInvestedAmount: null as number | null,
|
||||
currentMortgageBalance: null as number | null,
|
||||
hasMortgageData: false,
|
||||
shortTrend: null,
|
||||
longTrend: null,
|
||||
};
|
||||
|
|
@ -310,15 +427,58 @@ export function AccountBalanceChart({
|
|||
}
|
||||
}
|
||||
|
||||
// Check if any data point has mortgage data
|
||||
const hasMortgage = data.some(
|
||||
(d) =>
|
||||
d.mortgage_balance !== null && d.mortgage_balance !== undefined,
|
||||
);
|
||||
|
||||
// Find the most recent mortgage balance
|
||||
let mortgage: number | null = null;
|
||||
if (hasMortgage) {
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
data[i].mortgage_balance !== null &&
|
||||
data[i].mortgage_balance !== undefined
|
||||
) {
|
||||
mortgage = data[i].mortgage_balance!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
chartData: data,
|
||||
currentBalance: current,
|
||||
currentInvestedAmount: invested,
|
||||
currentMortgageBalance: mortgage,
|
||||
hasMortgageData: hasMortgage,
|
||||
shortTrend: calculateTrend(data, 1),
|
||||
longTrend: calculateTrend(data, data.length - 1),
|
||||
};
|
||||
}, [balanceData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onDataLoaded && chartData.length > 0) {
|
||||
onDataLoaded({
|
||||
chartData,
|
||||
currentBalance,
|
||||
currentMortgageBalance,
|
||||
hasMortgageData,
|
||||
shortTrend,
|
||||
longTrend,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
chartData,
|
||||
currentBalance,
|
||||
currentMortgageBalance,
|
||||
hasMortgageData,
|
||||
shortTrend,
|
||||
longTrend,
|
||||
onDataLoaded,
|
||||
]);
|
||||
|
||||
// Convert data for useChartViews hook
|
||||
const { data: hookData, accounts: hookAccounts } = useMemo(() => {
|
||||
return convertSingleAccountData(
|
||||
|
|
@ -352,6 +512,14 @@ export function AccountBalanceChart({
|
|||
},
|
||||
}
|
||||
: {}),
|
||||
...(hasMortgageData
|
||||
? {
|
||||
mortgage_balance: {
|
||||
label: __('Mortgage owed'),
|
||||
color: 'var(--color-chart-5)',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const formatXAxisLabel = useMemo(
|
||||
|
|
@ -381,15 +549,26 @@ export function AccountBalanceChart({
|
|||
? __('for the last 30 days')
|
||||
: __('for the last 12 months');
|
||||
|
||||
const chartTitle = isRealEstate
|
||||
? __('Market value evolution')
|
||||
: isLoan
|
||||
? __('Owed amount evolution')
|
||||
: __('Balance evolution');
|
||||
const emptyMessage = isRealEstate
|
||||
? __('No market value data available')
|
||||
: isLoan
|
||||
? __('No owed amount data available')
|
||||
: __('No balance data available');
|
||||
const currentEquity =
|
||||
hasMortgageData && currentMortgageBalance !== null
|
||||
? currentBalance - currentMortgageBalance
|
||||
: null;
|
||||
|
||||
if (initialLoading || isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isLoan
|
||||
? __('Owed amount evolution')
|
||||
: __('Balance evolution')}
|
||||
</CardTitle>
|
||||
<CardTitle>{chartTitle}</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
</CardDescription>
|
||||
|
|
@ -405,17 +584,11 @@ export function AccountBalanceChart({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isLoan
|
||||
? __('Owed amount evolution')
|
||||
: __('Balance evolution')}
|
||||
</CardTitle>
|
||||
<CardTitle>{chartTitle}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||
{isLoan
|
||||
? __('No owed amount data available')
|
||||
: __('No balance data available')}
|
||||
{emptyMessage}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -427,11 +600,7 @@ export function AccountBalanceChart({
|
|||
<CardHeader>
|
||||
<div className="flex flex-row items-start justify-between">
|
||||
<div className="flex flex-col gap-1 sm:gap-2">
|
||||
<CardTitle>
|
||||
{isLoan
|
||||
? __('Owed amount evolution')
|
||||
: __('Balance evolution')}
|
||||
</CardTitle>
|
||||
<CardTitle>{chartTitle}</CardTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBalanceClick}
|
||||
|
|
@ -444,6 +613,23 @@ export function AccountBalanceChart({
|
|||
maximumFractionDigits={0}
|
||||
/>
|
||||
</button>
|
||||
{currentEquity !== null && (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{__('Equity')}
|
||||
</span>
|
||||
<span
|
||||
className={`text-lg font-semibold tabular-nums ${currentEquity >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
|
||||
>
|
||||
<AmountDisplay
|
||||
amountInCents={currentEquity}
|
||||
currencyCode={account.currency_code}
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<CardDescription className="flex flex-col gap-1 text-sm">
|
||||
<PercentageTrendIndicator
|
||||
trend={shortTrend?.percentage ?? null}
|
||||
|
|
@ -499,7 +685,67 @@ export function AccountBalanceChart({
|
|||
className="h-full w-full"
|
||||
style={{ minWidth: `${minChartWidth}px` }}
|
||||
>
|
||||
{granularity === 'daily' ? (
|
||||
{hasMortgageData ? (
|
||||
<ComposedChart
|
||||
accessibilityLayer
|
||||
data={chartData.slice(1)}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="fillBalance"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="var(--color-chart-2)"
|
||||
stopOpacity={0.3}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="var(--color-chart-2)"
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis
|
||||
dataKey="month"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
tickFormatter={formatXAxisLabel}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<RealEstateTooltipContent
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
dataKey="value"
|
||||
type="monotone"
|
||||
fill="url(#fillBalance)"
|
||||
stroke="var(--color-chart-2)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 5 }}
|
||||
fillOpacity={1}
|
||||
/>
|
||||
<Line
|
||||
dataKey="mortgage_balance"
|
||||
type="monotone"
|
||||
stroke="var(--color-chart-5)"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="4 3"
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls
|
||||
/>
|
||||
</ComposedChart>
|
||||
) : granularity === 'daily' ? (
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={chartData.slice(1)}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,17 @@
|
|||
import { index, show } from '@/actions/App/Http/Controllers/AccountController';
|
||||
import { update as updateRealEstateDetail } from '@/actions/App/Http/Controllers/RealEstateDetailController';
|
||||
import { AccountBalanceChart } from '@/components/accounts/account-balance-chart';
|
||||
import {
|
||||
AccountBalanceChart,
|
||||
type BalanceDataPoint,
|
||||
type ChartComputedData,
|
||||
} from '@/components/accounts/account-balance-chart';
|
||||
import { BalancesModal } from '@/components/accounts/balances-modal';
|
||||
import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog';
|
||||
import { EditAccountDialog } from '@/components/accounts/edit-account-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 { MobileBackButton } from '@/components/mobile-back-button';
|
||||
import { TransactionList } from '@/components/transactions/transaction-list';
|
||||
|
|
@ -32,6 +37,7 @@ import {
|
|||
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 {
|
||||
|
|
@ -54,7 +60,8 @@ import { formatDateMedium } from '@/utils/date';
|
|||
import { __ } from '@/utils/i18n';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { ChevronDown, Pencil } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
interface AccountWithRealEstate extends Account {
|
||||
real_estate_detail?: RealEstateDetail;
|
||||
|
|
@ -85,6 +92,12 @@ export default function AccountShow({
|
|||
const [balancesOpen, setBalancesOpen] = useState(false);
|
||||
const [chartRefreshKey, setChartRefreshKey] = useState(0);
|
||||
const [editingDetails, setEditingDetails] = useState(false);
|
||||
const [chartComputedData, setChartComputedData] =
|
||||
useState<ChartComputedData | null>(null);
|
||||
|
||||
const handleChartDataLoaded = useCallback((data: ChartComputedData) => {
|
||||
setChartComputedData(data);
|
||||
}, []);
|
||||
|
||||
function handleBalanceUpdated() {
|
||||
setChartRefreshKey((prev) => prev + 1);
|
||||
|
|
@ -218,8 +231,22 @@ export default function AccountShow({
|
|||
? undefined
|
||||
: () => setUpdateBalanceOpen(true)
|
||||
}
|
||||
onDataLoaded={handleChartDataLoaded}
|
||||
/>
|
||||
|
||||
{isRealEstate &&
|
||||
realEstateDetail &&
|
||||
chartComputedData?.hasMortgageData && (
|
||||
<EquitySummaryCards
|
||||
chartData={chartComputedData.chartData}
|
||||
currentBalance={chartComputedData.currentBalance}
|
||||
currentMortgageBalance={
|
||||
chartComputedData.currentMortgageBalance
|
||||
}
|
||||
currencyCode={account.currency_code}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRealEstate && realEstateDetail && (
|
||||
<PropertyDetailsCard
|
||||
detail={realEstateDetail}
|
||||
|
|
@ -289,6 +316,219 @@ export default function AccountShow({
|
|||
);
|
||||
}
|
||||
|
||||
interface EquitySummaryCardsProps {
|
||||
chartData: BalanceDataPoint[];
|
||||
currentBalance: number;
|
||||
currentMortgageBalance: number | null;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
function EquitySummaryCards({
|
||||
chartData,
|
||||
currentBalance,
|
||||
currentMortgageBalance,
|
||||
currencyCode,
|
||||
}: EquitySummaryCardsProps) {
|
||||
const { accountMainLineColor } = 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]);
|
||||
|
||||
const equityLineColor = 'var(--color-emerald-500)';
|
||||
const mortgageLineColor = 'var(--color-amber-500)';
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ export interface RealEstateDetail {
|
|||
notes: string | null;
|
||||
linked_loan_account_id: UUID | null;
|
||||
linked_loan_account: Account | null;
|
||||
current_market_value: number | null;
|
||||
current_loan_balance: number | null;
|
||||
}
|
||||
|
||||
export function formatPropertyType(type: PropertyType): string {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use App\Models\AutomationRule;
|
|||
use App\Models\Bank;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\RealEstateDetail;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
|
|
@ -334,3 +335,196 @@ test('account show includes bank information', function () {
|
|||
->where('account.bank.name', 'Test Bank')
|
||||
);
|
||||
});
|
||||
|
||||
test('real estate account show includes real estate detail with linked loan', function () {
|
||||
$loanAccount = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Loan,
|
||||
]);
|
||||
|
||||
$realEstateAccount = Account::factory()->realEstate()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
RealEstateDetail::factory()->create([
|
||||
'account_id' => $realEstateAccount->id,
|
||||
'linked_loan_account_id' => $loanAccount->id,
|
||||
]);
|
||||
|
||||
$response = $this->withoutVite()->get(route('accounts.show', $realEstateAccount));
|
||||
|
||||
$response->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Accounts/Show')
|
||||
->has('account.real_estate_detail')
|
||||
->where('account.real_estate_detail.linked_loan_account_id', $loanAccount->id)
|
||||
->has('account.real_estate_detail.linked_loan_account')
|
||||
->where('account.real_estate_detail.linked_loan_account.id', $loanAccount->id)
|
||||
->has('account.available_loan_accounts')
|
||||
);
|
||||
});
|
||||
|
||||
test('real estate account show includes current market value and loan balance for equity', function () {
|
||||
$loanAccount = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Loan,
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $loanAccount->id,
|
||||
'balance_date' => now(),
|
||||
'balance' => 20000000, // $200,000
|
||||
]);
|
||||
|
||||
$realEstateAccount = Account::factory()->realEstate()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $realEstateAccount->id,
|
||||
'balance_date' => now(),
|
||||
'balance' => 35000000, // $350,000
|
||||
]);
|
||||
|
||||
RealEstateDetail::factory()->create([
|
||||
'account_id' => $realEstateAccount->id,
|
||||
'linked_loan_account_id' => $loanAccount->id,
|
||||
]);
|
||||
|
||||
$response = $this->withoutVite()->get(route('accounts.show', $realEstateAccount));
|
||||
|
||||
$response->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Accounts/Show')
|
||||
->where('account.real_estate_detail.current_market_value', 35000000)
|
||||
->where('account.real_estate_detail.current_loan_balance', 20000000)
|
||||
);
|
||||
});
|
||||
|
||||
test('real estate account show includes market value without linked loan', function () {
|
||||
$realEstateAccount = Account::factory()->realEstate()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $realEstateAccount->id,
|
||||
'balance_date' => now(),
|
||||
'balance' => 35000000,
|
||||
]);
|
||||
|
||||
RealEstateDetail::factory()->create([
|
||||
'account_id' => $realEstateAccount->id,
|
||||
]);
|
||||
|
||||
$response = $this->withoutVite()->get(route('accounts.show', $realEstateAccount));
|
||||
|
||||
$response->assertOk()
|
||||
->assertInertia(fn ($page) => $page
|
||||
->component('Accounts/Show')
|
||||
->where('account.real_estate_detail.current_market_value', 35000000)
|
||||
->missing('account.real_estate_detail.current_loan_balance')
|
||||
);
|
||||
});
|
||||
|
||||
test('real estate balance evolution includes mortgage balance data', function () {
|
||||
$loanAccount = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Loan,
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $loanAccount->id,
|
||||
'balance_date' => now()->subMonthNoOverflow()->endOfMonth(),
|
||||
'balance' => 22000000,
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $loanAccount->id,
|
||||
'balance_date' => now()->endOfMonth(),
|
||||
'balance' => 21500000,
|
||||
]);
|
||||
|
||||
$realEstateAccount = Account::factory()->realEstate()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $realEstateAccount->id,
|
||||
'balance_date' => now()->subMonthNoOverflow()->endOfMonth(),
|
||||
'balance' => 35000000,
|
||||
]);
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $realEstateAccount->id,
|
||||
'balance_date' => now()->endOfMonth(),
|
||||
'balance' => 36000000,
|
||||
]);
|
||||
|
||||
RealEstateDetail::factory()->create([
|
||||
'account_id' => $realEstateAccount->id,
|
||||
'linked_loan_account_id' => $loanAccount->id,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/account/'.$realEstateAccount->id.'/balance-evolution?'.http_build_query([
|
||||
'from' => now()->subMonthsNoOverflow(2)->startOfMonth()->toDateString(),
|
||||
'to' => now()->endOfMonth()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
expect($data['data'])->toHaveCount(3);
|
||||
expect($data['data'][0])->toHaveKey('mortgage_balance');
|
||||
|
||||
// The last point should have the most recent mortgage balance
|
||||
$lastPoint = end($data['data']);
|
||||
expect($lastPoint['mortgage_balance'])->toBe(21500000);
|
||||
expect($lastPoint['value'])->toBe(36000000);
|
||||
});
|
||||
|
||||
test('real estate balance evolution without linked loan has no mortgage data', function () {
|
||||
$realEstateAccount = Account::factory()->realEstate()->create([
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $realEstateAccount->id,
|
||||
'balance_date' => now()->endOfMonth(),
|
||||
'balance' => 35000000,
|
||||
]);
|
||||
|
||||
RealEstateDetail::factory()->create([
|
||||
'account_id' => $realEstateAccount->id,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/account/'.$realEstateAccount->id.'/balance-evolution?'.http_build_query([
|
||||
'from' => now()->subMonthsNoOverflow(2)->startOfMonth()->toDateString(),
|
||||
'to' => now()->endOfMonth()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
expect($data['data'][0])->not->toHaveKey('mortgage_balance');
|
||||
});
|
||||
|
||||
test('non-real-estate account balance evolution has no mortgage data', function () {
|
||||
$account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Checking,
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $account->id,
|
||||
'balance_date' => now()->endOfMonth(),
|
||||
'balance' => 100000,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([
|
||||
'from' => now()->subMonthsNoOverflow(2)->startOfMonth()->toDateString(),
|
||||
'to' => now()->endOfMonth()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
expect($data['data'][0])->not->toHaveKey('mortgage_balance');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue