fix(dashboard): treat loans as debt in net worth (#238)
## Summary - fix dashboard net worth math so loan balances reduce totals and trends instead of showing as positive assets - add a per-user toggle in the net worth chart settings to include or exclude loans from the dashboard net worth card - cover the new preference and liability handling with feature and frontend tests ## Screenshots <img width="1121" height="509" alt="image" src="https://github.com/user-attachments/assets/ab6a7cde-1052-4dab-aa14-34f6d9528829" /> <img width="1122" height="506" alt="image" src="https://github.com/user-attachments/assets/e48a0369-16c4-4294-ae00-4eba56480264" />
This commit is contained in:
parent
6dda5f56ad
commit
f140b5df7f
|
|
@ -19,4 +19,9 @@ enum AccountType: string
|
|||
{
|
||||
return in_array($this, [self::Investment, self::Retirement, self::Savings], true);
|
||||
}
|
||||
|
||||
public function reducesNetWorth(): bool
|
||||
{
|
||||
return in_array($this, [self::CreditCard, self::Loan], true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -282,12 +282,16 @@ class DashboardAnalyticsController extends Controller
|
|||
->orderBy('balance_date', 'desc')
|
||||
->value('balance') ?? 0;
|
||||
|
||||
$total += $this->exchangeRateService->convert(
|
||||
$convertedBalance = $this->exchangeRateService->convert(
|
||||
$account->currency_code,
|
||||
$userCurrency,
|
||||
$balance,
|
||||
$date->toDateString(),
|
||||
);
|
||||
|
||||
$total += $account->type->reducesNetWorth()
|
||||
? -abs($convertedBalance)
|
||||
: $convertedBalance;
|
||||
}
|
||||
|
||||
return $total;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\UpdateNetWorthChartLoanPreferenceRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class NetWorthChartLoanPreferenceController extends Controller
|
||||
{
|
||||
public function update(UpdateNetWorthChartLoanPreferenceRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->setting()->updateOrCreate(
|
||||
['user_id' => $request->user()->id],
|
||||
['include_loans_in_net_worth_chart' => $request->boolean('include_loans_in_net_worth_chart')]
|
||||
);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
|
|
@ -85,6 +85,7 @@ class HandleInertiaRequests extends Middleware
|
|||
'currency' => strtoupper(config('cashier.currency', 'eur')),
|
||||
],
|
||||
'chartColorScheme' => $user?->setting?->chart_color_scheme->value ?? 'colorful',
|
||||
'includeLoansInNetWorthChart' => $user?->setting->include_loans_in_net_worth_chart ?? true,
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'features' => [
|
||||
'cashflow' => true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateNetWorthChartLoanPreferenceRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'include_loans_in_net_worth_chart' => ['required', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
|
||||
/**
|
||||
* @property \App\Enums\ChartColorScheme $chart_color_scheme
|
||||
* @property bool $include_loans_in_net_worth_chart
|
||||
*/
|
||||
class UserSetting extends Model
|
||||
{
|
||||
|
|
@ -19,12 +20,14 @@ class UserSetting extends Model
|
|||
protected $fillable = [
|
||||
'user_id',
|
||||
'chart_color_scheme',
|
||||
'include_loans_in_net_worth_chart',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'chart_color_scheme' => ChartColorScheme::class,
|
||||
'include_loans_in_net_worth_chart' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class UserSettingFactory extends Factory
|
|||
return [
|
||||
'user_id' => User::factory(),
|
||||
'chart_color_scheme' => ChartColorScheme::Colorful,
|
||||
'include_loans_in_net_worth_chart' => true,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('user_settings', function (Blueprint $table) {
|
||||
$table->boolean('include_loans_in_net_worth_chart')->default(true);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('user_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('include_loans_in_net_worth_chart');
|
||||
});
|
||||
}
|
||||
};
|
||||
32
lang/es.json
32
lang/es.json
|
|
@ -163,6 +163,7 @@
|
|||
"Are you overspending? Know exactly where you stand.": "\u00bfEst\u00e1s gastando de m\u00e1s? Conoce exactamente d\u00f3nde est\u00e1s parado.",
|
||||
"Are you sure you want to delete \"": "\u00bfEst\u00e1s seguro de que deseas eliminar \"",
|
||||
"Are you sure you want to delete this balance record? This action cannot be undone.": "\u00bfEst\u00e1s seguro de que deseas eliminar este registro de balance? Esta acci\u00f3n no se puede deshacer.",
|
||||
"Are you sure you want to delete this owed amount record? This action cannot be undone.": "\u00bfEst\u00e1s seguro de que deseas eliminar este registro de monto adeudado? Esta acci\u00f3n no se puede deshacer.",
|
||||
"Are you sure you want to delete this transaction? This action cannot be undone.": "\u00bfEst\u00e1s seguro de que quieres eliminar esta transacci\u00f3n? Esta acci\u00f3n no se puede deshacer.",
|
||||
"Are you sure you want to delete your account?": "\u00bfEst\u00e1s seguro de que deseas eliminar tu cuenta?",
|
||||
"As a developer, I appreciate the security architecture. This is how finance apps should be built.": "Como desarrollador, aprecio la arquitectura de seguridad. As\u00ed es como deber\u00edan construirse las apps de finanzas.",
|
||||
|
|
@ -196,6 +197,7 @@
|
|||
"Balance Date": "Fecha del Balance",
|
||||
"Balance History": "Historial de Balance",
|
||||
"Balance Tracking": "Seguimiento de Balance",
|
||||
"Balances": "Balances",
|
||||
"Balance evolution": "Evoluci\u00f3n del balance",
|
||||
"Bank": "Banco",
|
||||
"Bank Account": "Cuenta Bancaria",
|
||||
|
|
@ -394,6 +396,7 @@
|
|||
"Delete account": "Eliminar cuenta",
|
||||
"Delete accounts": "Eliminar cuentas",
|
||||
"Delete balance": "Eliminar balance",
|
||||
"Delete owed amount": "Eliminar monto adeudado",
|
||||
"Delete budget": "Eliminar presupuesto",
|
||||
"Delete your account and all of its resources": "Elimina tu cuenta y todos sus recursos",
|
||||
"Deleting...": "Eliminando...",
|
||||
|
|
@ -426,6 +429,7 @@
|
|||
"Edit Balance": "Editar Balance",
|
||||
"Edit Budget": "Editar Presupuesto",
|
||||
"Edit Category": "Editar Categor\u00eda",
|
||||
"Edit Owed Amount": "Editar Monto Adeudado",
|
||||
"Edit Label": "Editar Etiqueta",
|
||||
"Edit Transaction": "Editar transacci\u00f3n",
|
||||
"Edit account": "Editar cuenta",
|
||||
|
|
@ -623,6 +627,7 @@
|
|||
"Import a year's worth of transactions in under 10 seconds. Simply export a CSV or XLS file from your bank and drag it into Whisper Money. All data is encrypted locally before upload.": "Importa transacciones de un a\u00f1o completo en menos de 10 segundos. Simplemente exporta un archivo CSV o XLS desde tu banco y arr\u00e1stralo a Whisper Money. Todos los datos se encriptan localmente antes de subirse.",
|
||||
"Import balance history from CSV files": "Importar historial de balances desde archivos CSV",
|
||||
"Import balances": "Importar balances",
|
||||
"Import owed amounts": "Importar montos adeudados",
|
||||
"Import in Seconds": "Importa en Segundos",
|
||||
"Import in seconds": "Importa en segundos",
|
||||
"Import transactions": "Importar transacciones",
|
||||
|
|
@ -635,6 +640,8 @@
|
|||
"Importing Transactions": "Importando Transacciones",
|
||||
"Importing your balances...": "Importando tus saldos...",
|
||||
"Imported": "Importado",
|
||||
"Include loan balances in the net worth totals and chart": "Incluir los saldos de pr\u00e9stamos en los totales y el gr\u00e1fico del patrimonio neto",
|
||||
"Include loans": "Incluir pr\u00e9stamos",
|
||||
"Income": "Ingresos",
|
||||
"Income (Salary, Freelance, Investments)": "Ingresos (Salario, Freelance, Inversiones)",
|
||||
"Income Sources": "Fuentes de Ingresos",
|
||||
|
|
@ -697,9 +704,11 @@
|
|||
"Load more": "Cargar m\u00e1s",
|
||||
"Loading": "Cargando",
|
||||
"Loading last balance...": "Cargando \u00faltimo balance...",
|
||||
"Loading last owed amount...": "Cargando \u00faltimo monto adeudado...",
|
||||
"Loading recovery codes": "Cargando c\u00f3digos de recuperaci\u00f3n",
|
||||
"Loading...": "Cargando...",
|
||||
"Loan": "Pr\u00e9stamo",
|
||||
"Loans": "Pr\u00e9stamos",
|
||||
"Lock encryption key": "Bloquear clave de encriptaci\u00f3n",
|
||||
"Log in": "Iniciar sesi\u00f3n",
|
||||
"Log in to your account": "Inicia sesi\u00f3n en tu cuenta",
|
||||
|
|
@ -802,11 +811,15 @@
|
|||
"No income this period": "Sin ingresos en este per\u00edodo",
|
||||
"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 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",
|
||||
"No spending data this month": "Sin datos de gasto este mes",
|
||||
"No third-party sharing, no AI snooping. Your financial data belongs to you and only you.": "Sin compartir con terceros, sin espionaje de IA. Tus datos financieros te pertenecen a ti y solo a ti.",
|
||||
"No tracking": "Sin seguimiento",
|
||||
"No transactions found.": "No se encontraron transacciones.",
|
||||
"No valid balances found": "No se encontraron balances v\u00e1lidos",
|
||||
"No valid owed amounts found": "No se encontraron montos adeudados v\u00e1lidos",
|
||||
"No worries! Your encryption key is automatically generated and stored securely on your device. You don't need to remember anything - I've made it as simple as possible.": "\u00a1No te preocupes! Tu clave de encriptaci\u00f3n se genera autom\u00e1ticamente y se almacena de forma segura en tu dispositivo. No necesitas recordar nada, lo he hecho lo m\u00e1s simple posible.",
|
||||
"No. We never ask for your bank credentials. You import transactions by exporting a CSV or XLS file from your bank and uploading it to Whisper Money. This keeps your bank account secure.": "No. Nunca pedimos tus credenciales bancarias. Importas transacciones exportando un archivo CSV o XLS de tu banco y subi\u00e9ndolo a Whisper Money. Esto mantiene tu cuenta bancaria segura.",
|
||||
"None": "Ninguno",
|
||||
|
|
@ -835,6 +848,10 @@
|
|||
"Our service is not intended for users under the age of 16. We do not knowingly collect personal information from children. If you believe we have collected information from a child, please contact us immediately, and we will delete it.": "Nuestro servicio no est\u00e1 destinado a usuarios menores de 16 a\u00f1os. No recopilamos intencionalmente informaci\u00f3n personal de ni\u00f1os. Si crees que hemos recopilado informaci\u00f3n de un ni\u00f1o, por favor cont\u00e1ctanos inmediatamente y la eliminaremos.",
|
||||
"Our service is not intended for users under the\\n age of 16. We do not knowingly collect personal\\n information from children. If you believe we\\n have collected information from a child, please\\n contact us immediately, and we will delete it.": "Nuestro servicio no est\u00e1 destinado a usuarios menores de 16 a\u00f1os. No recopilamos intencionalmente informaci\u00f3n personal de ni\u00f1os. Si crees que hemos recopilado informaci\u00f3n de un ni\u00f1o, cont\u00e1ctanos de inmediato y la eliminaremos.",
|
||||
"Overview of your financial health": "Vista general de tu salud financiera",
|
||||
"Owed Amount": "Monto Adeudado",
|
||||
"Owed Amount History": "Historial de Monto Adeudado",
|
||||
"Owed Amounts": "Montos Adeudados",
|
||||
"Owed amount evolution": "Evoluci\u00f3n del monto adeudado",
|
||||
"Page": "P\u00e1gina",
|
||||
"Password": "Contrase\u00f1a",
|
||||
"Password changes are disabled on the demo account.": "Los cambios de contrase\u00f1a est\u00e1n deshabilitados en la cuenta demo.",
|
||||
|
|
@ -879,6 +896,7 @@
|
|||
"Prev": "Ant",
|
||||
"Preview (first 3 rows)": "Vista previa (primeras 3 filas)",
|
||||
"Preview Balances": "Vista Previa de Balances",
|
||||
"Preview Owed Amounts": "Vista Previa de Montos Adeudados",
|
||||
"Preview Transactions": "Vista Previa de Transacciones",
|
||||
"Previous": "Anterior",
|
||||
"Previous month": "Mes anterior",
|
||||
|
|
@ -980,6 +998,7 @@
|
|||
"Secure upload": "Carga segura",
|
||||
"See balances": "Ver balances",
|
||||
"See every account in one place. Track balances, monitor changes, and always know where you stand.": "Ve todas tus cuentas en un solo lugar. Sigue los saldos, monitoriza los cambios y sabe siempre c\u00f3mo est\u00e1s.",
|
||||
"See owed amounts": "Ver montos adeudados",
|
||||
"See where you spend": "Ve en qu\u00e9 gastas",
|
||||
"See where you stand in real-time. Visual progress bars show spending vs. budget.": "Ve tu situaci\u00f3n en tiempo real. Las barras de progreso visuales muestran el gasto frente al presupuesto.",
|
||||
"Select Account": "Seleccionar Cuenta",
|
||||
|
|
@ -1000,6 +1019,7 @@
|
|||
"Select balance column": "Seleccionar columna de balance",
|
||||
"Select balance column (optional)": "Seleccionar columna de balance (opcional)",
|
||||
"Select bank...": "Seleccionar banco...",
|
||||
"Select owed amount column": "Seleccionar columna de monto adeudado",
|
||||
"Select categories...": "Seleccionar categor\u00edas...",
|
||||
"Select country": "Seleccionar pa\u00eds",
|
||||
"Select currency": "Seleccionar moneda",
|
||||
|
|
@ -1027,6 +1047,7 @@
|
|||
"Set spending limits and get real-time updates on your financial goals.": "Establece l\u00edmites de gasto y obt\u00e9n actualizaciones en tiempo real sobre tus metas financieras.",
|
||||
"Set the balance for this account on a specific date.": "Actualiza el balance de esta cuenta.",
|
||||
"Set the current balance for this account to start tracking.": "Establece el balance actual de esta cuenta para empezar a rastrear.",
|
||||
"Set the owed amount for this account on a specific date.": "Establece el monto adeudado de esta cuenta en una fecha espec\u00edfica.",
|
||||
"Set up a spending limit for a category or label.": "Configura un l\u00edmite de gasto para un categor\u00eda o etiqueta.",
|
||||
"Set your budget goals": "Establece tus objetivos de presupuesto",
|
||||
"Setting things up...": "Configurando...",
|
||||
|
|
@ -1245,12 +1266,14 @@
|
|||
"Update account balance": "Actualizar balance de cuenta",
|
||||
"Update balance": "Actualizar balance",
|
||||
"Update balances periodically to track growth": "Actualiza los balances peri\u00f3dicamente para rastrear el crecimiento",
|
||||
"Update owed amount": "Actualizar monto adeudado",
|
||||
"Update password": "Actualizar contrase\u00f1a",
|
||||
"Update the account information.": "Actualiza la informaci\u00f3n de la cuenta.",
|
||||
"Update the balance record.": "Actualiza el registro de balance.",
|
||||
"Update the category and notes for this transaction.": "Actualiza la categor\u00eda y notas de esta transacci\u00f3n.",
|
||||
"Update the category information.": "Actualiza la informaci\u00f3n de la categor\u00eda.",
|
||||
"Update the label information.": "Actualiza la informaci\u00f3n de la etiqueta.",
|
||||
"Update the owed amount record.": "Actualiza el registro de monto adeudado.",
|
||||
"Update the rule to automatically categorize transactions and add labels.": "Actualiza la regla para categorizar autom\u00e1ticamente transacciones y agregar etiquetas.",
|
||||
"Update the rule to automatically categorize transactions.": "Actualiza la regla para categorizar transacciones autom\u00e1ticamente.",
|
||||
"Update your account's appearance settings": "Actualiza la configuraci\u00f3n de apariencia de tu cuenta",
|
||||
|
|
@ -1282,6 +1305,7 @@
|
|||
"View Details": "Ver Detalles",
|
||||
"View Transactions": "Ver Transacciones",
|
||||
"View and manage balance records for this account.": "Ver y gestionar registros de balance para esta cuenta.",
|
||||
"View and manage owed amount records for this account.": "Ver y gestionar registros de monto adeudado para esta cuenta.",
|
||||
"View and manage your bank accounts": "Ver y administrar tus cuentas bancarias",
|
||||
"View and manage your transactions": "Ver y gestionar tus transacciones",
|
||||
"View balance evolution over time": "Ver evoluci\u00f3n del balance a lo largo del tiempo",
|
||||
|
|
@ -1504,8 +1528,10 @@
|
|||
"accounts": "cuentas",
|
||||
"after 3 months with Whisper Money": "despu\u00e9s de 3 meses con Whisper Money",
|
||||
"amber": "\u00e1mbar",
|
||||
"balance": "balance",
|
||||
"balance record": "registro de balance",
|
||||
"balance records": "registros de balance",
|
||||
"balances": "balances",
|
||||
"balances imported": "balances importados",
|
||||
"blue": "azul",
|
||||
"button in your browser toolbar": "en la barra de herramientas de tu navegador",
|
||||
|
|
@ -1542,6 +1568,11 @@
|
|||
"or you can": "o puedes",
|
||||
"or, enter the code manually": "o, ingresa el c\u00f3digo manualmente",
|
||||
"orange": "naranja",
|
||||
"owed amount": "monto adeudado",
|
||||
"owed amount record": "registro de monto adeudado",
|
||||
"owed amount records": "registros de monto adeudado",
|
||||
"owed amounts": "montos adeudados",
|
||||
"owed amounts imported": "montos adeudados importados",
|
||||
"per month": "por mes",
|
||||
"per year": "por a\u00f1o",
|
||||
"pink": "rosa",
|
||||
|
|
@ -1570,7 +1601,6 @@
|
|||
"vs last month": "vs mes anterior",
|
||||
"vs last period": "vs per\u00edodo anterior",
|
||||
"will be updated or created.": "ser\u00e1 actualizado o creado.",
|
||||
"will be updated or\\n created.": "ser\u00e1n actualizadas o creadas.",
|
||||
"yellow": "amarillo",
|
||||
"\u2014 $9/month": "\u2014 $9/mes",
|
||||
"\u2190 Back to home": "\u2190 Volver al inicio",
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ export function AccountBalanceChart({
|
|||
}: AccountBalanceChartProps) {
|
||||
const locale = useLocale();
|
||||
const isMobile = useIsMobile();
|
||||
const isLoan = account.type === 'loan';
|
||||
const [granularity, setGranularity] = useState<ChartGranularity>('monthly');
|
||||
const [balanceData, setBalanceData] = useState<AccountBalanceData | null>(
|
||||
null,
|
||||
|
|
@ -384,7 +385,11 @@ export function AccountBalanceChart({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('Balance evolution')}</CardTitle>
|
||||
<CardTitle>
|
||||
{isLoan
|
||||
? __('Owed amount evolution')
|
||||
: __('Balance evolution')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
</CardDescription>
|
||||
|
|
@ -400,11 +405,17 @@ export function AccountBalanceChart({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('Balance evolution')}</CardTitle>
|
||||
<CardTitle>
|
||||
{isLoan
|
||||
? __('Owed amount evolution')
|
||||
: __('Balance evolution')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
||||
{__('No balance data available')}
|
||||
{isLoan
|
||||
? __('No owed amount data available')
|
||||
: __('No balance data available')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -416,7 +427,11 @@ export function AccountBalanceChart({
|
|||
<CardHeader>
|
||||
<div className="flex flex-row items-start justify-between">
|
||||
<div className="flex flex-col gap-1 sm:gap-2">
|
||||
<CardTitle>{__('Balance evolution')}</CardTitle>
|
||||
<CardTitle>
|
||||
{isLoan
|
||||
? __('Owed amount evolution')
|
||||
: __('Balance evolution')}
|
||||
</CardTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBalanceClick}
|
||||
|
|
|
|||
|
|
@ -252,7 +252,9 @@ export function AccountListCard({
|
|||
variant="secondary"
|
||||
onClick={() => setUpdateBalanceOpen(true)}
|
||||
>
|
||||
{__('Update balance')}
|
||||
{account.type === 'loan'
|
||||
? __('Update owed amount')
|
||||
: __('Update balance')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ interface AccountNameProps {
|
|||
length?: Length;
|
||||
}
|
||||
|
||||
export function AccountName({
|
||||
account,
|
||||
className = '',
|
||||
}: Omit<AccountNameProps, 'length'>) {
|
||||
export function AccountName({ account, className = '' }: AccountNameProps) {
|
||||
return <span className={className}>{account.name}</span>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,10 @@ import {
|
|||
} from '@/components/ui/table';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import type { Account, AccountBalance } from '@/types/account';
|
||||
import { supportsInvestedAmount } from '@/types/account';
|
||||
import {
|
||||
balanceTermCapitalized,
|
||||
supportsInvestedAmount,
|
||||
} from '@/types/account';
|
||||
import { formatCurrency } from '@/utils/currency';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
|
|
@ -87,6 +90,7 @@ export function BalancesModal({
|
|||
formatCurrency(valueInCents, account.currency_code, locale);
|
||||
|
||||
const showInvestedAmount = supportsInvestedAmount(account);
|
||||
const isLoan = account.type === 'loan';
|
||||
|
||||
const fetchBalances = useCallback(
|
||||
async (page: number) => {
|
||||
|
|
@ -236,11 +240,19 @@ export function BalancesModal({
|
|||
}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Balance History')}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isLoan
|
||||
? __('Owed Amount History')
|
||||
: __('Balance History')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__(
|
||||
'View and manage balance records for this account.',
|
||||
)}
|
||||
{isLoan
|
||||
? __(
|
||||
'View and manage owed amount records for this account.',
|
||||
)
|
||||
: __(
|
||||
'View and manage balance records for this account.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
|
@ -257,7 +269,9 @@ export function BalancesModal({
|
|||
<TableRow>
|
||||
<TableHead>{__('Date')}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{__('Balance')}
|
||||
{balanceTermCapitalized(
|
||||
account.type,
|
||||
)}
|
||||
</TableHead>
|
||||
{showInvestedAmount && (
|
||||
<TableHead className="text-right">
|
||||
|
|
@ -289,9 +303,13 @@ export function BalancesModal({
|
|||
}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{__(
|
||||
'No balance records found.',
|
||||
)}
|
||||
{isLoan
|
||||
? __(
|
||||
'No owed amount records found.',
|
||||
)
|
||||
: __(
|
||||
'No balance records found.',
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -356,8 +374,12 @@ export function BalancesModal({
|
|||
<span className="text-sm text-muted-foreground">
|
||||
{total}{' '}
|
||||
{total === 1
|
||||
? __('balance record')
|
||||
: __('balance records')}
|
||||
? isLoan
|
||||
? __('owed amount record')
|
||||
: __('balance record')
|
||||
: isLoan
|
||||
? __('owed amount records')
|
||||
: __('balance records')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
|
@ -398,15 +420,23 @@ export function BalancesModal({
|
|||
>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Edit Balance')}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isLoan
|
||||
? __('Edit Owed Amount')
|
||||
: __('Edit Balance')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Update the balance record.')}
|
||||
{isLoan
|
||||
? __('Update the owed amount record.')
|
||||
: __('Update the balance record.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleEditSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-amount">{__('Balance')}</Label>
|
||||
<Label htmlFor="edit-amount">
|
||||
{balanceTermCapitalized(account.type)}
|
||||
</Label>
|
||||
<AmountInput
|
||||
id="edit-amount"
|
||||
className="mt-1"
|
||||
|
|
@ -472,12 +502,18 @@ export function BalancesModal({
|
|||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{__('Delete balance')}
|
||||
{isLoan
|
||||
? __('Delete owed amount')
|
||||
: __('Delete balance')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{__(
|
||||
'Are you sure you want to delete this balance record? This action cannot be undone.',
|
||||
)}
|
||||
{isLoan
|
||||
? __(
|
||||
'Are you sure you want to delete this owed amount record? This action cannot be undone.',
|
||||
)
|
||||
: __(
|
||||
'Are you sure you want to delete this balance record? This action cannot be undone.',
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ interface ImportBalancesDrawerProps {
|
|||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
accounts?: Account[];
|
||||
account?: Account;
|
||||
accountId?: UUID;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
|
@ -59,6 +60,7 @@ export function ImportBalancesDrawer({
|
|||
open,
|
||||
onOpenChange,
|
||||
accounts = [],
|
||||
account: providedAccount,
|
||||
accountId,
|
||||
onSuccess,
|
||||
}: ImportBalancesDrawerProps) {
|
||||
|
|
@ -94,15 +96,17 @@ export function ImportBalancesDrawer({
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (state.selectedAccountId) {
|
||||
const account = accounts.find(
|
||||
(a) => a.id === state.selectedAccountId,
|
||||
);
|
||||
if (open && state.selectedAccountId) {
|
||||
const account =
|
||||
(providedAccount?.id === state.selectedAccountId
|
||||
? providedAccount
|
||||
: undefined) ??
|
||||
accounts.find((a) => a.id === state.selectedAccountId);
|
||||
if (account) {
|
||||
setSelectedAccount(account);
|
||||
}
|
||||
}
|
||||
}, [state.selectedAccountId, accounts]);
|
||||
}, [open, state.selectedAccountId, accounts, providedAccount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -504,10 +508,13 @@ export function ImportBalancesDrawer({
|
|||
|
||||
const successCount = createdBalances.length;
|
||||
const errorCount = errors.length;
|
||||
const isLoan = selectedAccount?.type === 'loan';
|
||||
const term = isLoan ? 'owed amount' : 'balance';
|
||||
const termPlural = isLoan ? 'owed amounts' : 'balances';
|
||||
|
||||
if (errorCount === 0 && successCount > 0) {
|
||||
toast.success(
|
||||
`${successCount} balance${successCount !== 1 ? 's' : ''} imported successfully`,
|
||||
`${successCount} ${successCount !== 1 ? termPlural : term} imported successfully`,
|
||||
{
|
||||
icon: <Check className="h-4 w-4" />,
|
||||
},
|
||||
|
|
@ -516,12 +523,12 @@ export function ImportBalancesDrawer({
|
|||
onOpenChange(false);
|
||||
} else if (successCount > 0 && errorCount > 0) {
|
||||
toast.warning(
|
||||
`${successCount} balance${successCount !== 1 ? 's' : ''} imported, ${errorCount} failed`,
|
||||
`${successCount} ${successCount !== 1 ? termPlural : term} imported, ${errorCount} failed`,
|
||||
);
|
||||
onSuccess?.();
|
||||
} else if (successCount > 0) {
|
||||
toast.success(
|
||||
`${successCount} balance${successCount !== 1 ? 's' : ''} imported successfully`,
|
||||
`${successCount} ${successCount !== 1 ? termPlural : term} imported successfully`,
|
||||
{
|
||||
icon: <Check className="h-4 w-4" />,
|
||||
},
|
||||
|
|
@ -529,7 +536,11 @@ export function ImportBalancesDrawer({
|
|||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error('All balances failed to import');
|
||||
toast.error(
|
||||
isLoan
|
||||
? 'All owed amounts failed to import'
|
||||
: 'All balances failed to import',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -538,12 +549,15 @@ export function ImportBalancesDrawer({
|
|||
};
|
||||
|
||||
const getStepInfo = () => {
|
||||
const isLoan = selectedAccount?.type === 'loan';
|
||||
|
||||
switch (state.step) {
|
||||
case BalanceImportStep.SelectAccount:
|
||||
return {
|
||||
title: 'Select Account',
|
||||
description:
|
||||
'Choose the account where balances will be imported',
|
||||
description: isLoan
|
||||
? 'Choose the account where owed amounts will be imported'
|
||||
: 'Choose the account where balances will be imported',
|
||||
};
|
||||
case BalanceImportStep.UploadFile:
|
||||
return {
|
||||
|
|
@ -554,25 +568,34 @@ export function ImportBalancesDrawer({
|
|||
case BalanceImportStep.MapColumns:
|
||||
return {
|
||||
title: 'Map Columns',
|
||||
description: 'Match your file columns to balance fields',
|
||||
description: isLoan
|
||||
? 'Match your file columns to owed amount fields'
|
||||
: 'Match your file columns to balance fields',
|
||||
};
|
||||
case BalanceImportStep.Preview:
|
||||
return {
|
||||
title: 'Preview Balances',
|
||||
description: 'Review balances before importing',
|
||||
title: isLoan ? 'Preview Owed Amounts' : 'Preview Balances',
|
||||
description: isLoan
|
||||
? 'Review owed amounts before importing'
|
||||
: 'Review balances before importing',
|
||||
};
|
||||
default:
|
||||
if (isImporting) {
|
||||
return {
|
||||
title: 'Importing Balances',
|
||||
description:
|
||||
'Please wait while we import your balances',
|
||||
title: isLoan
|
||||
? 'Importing Owed Amounts'
|
||||
: 'Importing Balances',
|
||||
description: isLoan
|
||||
? 'Please wait while we import your owed amounts'
|
||||
: 'Please wait while we import your balances',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Import Balances',
|
||||
description: 'Import balances from CSV or Excel files',
|
||||
title: isLoan ? 'Import Owed Amounts' : 'Import Balances',
|
||||
description: isLoan
|
||||
? 'Import owed amounts from CSV or Excel files'
|
||||
: 'Import balances from CSV or Excel files',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
@ -624,6 +647,7 @@ export function ImportBalancesDrawer({
|
|||
parsedData={state.parsedData}
|
||||
currencyCode={selectedAccount?.currency_code || 'USD'}
|
||||
showInvestedAmount={showInvestedAmount}
|
||||
isLoan={selectedAccount?.type === 'loan'}
|
||||
onMappingChange={handleMappingChange}
|
||||
onDateFormatChange={handleDateFormatChange}
|
||||
onNext={handlePreviewBalances}
|
||||
|
|
@ -637,6 +661,7 @@ export function ImportBalancesDrawer({
|
|||
balances={state.balances}
|
||||
currencyCode={selectedAccount?.currency_code || 'USD'}
|
||||
showInvestedAmount={showInvestedAmount}
|
||||
isLoan={selectedAccount?.type === 'loan'}
|
||||
onConfirm={handleConfirmImport}
|
||||
onBack={handleBack}
|
||||
isImporting={isImporting}
|
||||
|
|
@ -651,6 +676,7 @@ export function ImportBalancesDrawer({
|
|||
const renderImportProgress = () => {
|
||||
const percentage =
|
||||
importTotal > 0 ? (importProgress / importTotal) * 100 : 0;
|
||||
const isLoan = selectedAccount?.type === 'loan';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
|
|
@ -658,7 +684,9 @@ export function ImportBalancesDrawer({
|
|||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{importProgress} of {importTotal}
|
||||
{__('balances imported')}
|
||||
{isLoan
|
||||
? __('owed amounts imported')
|
||||
: __('balances imported')}
|
||||
</span>
|
||||
<span>{Math.round(percentage)}%</span>
|
||||
</div>
|
||||
|
|
@ -684,7 +712,9 @@ export function ImportBalancesDrawer({
|
|||
{__('Date')}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium">
|
||||
{__('Balance')}
|
||||
{isLoan
|
||||
? __('Owed Amount')
|
||||
: __('Balance')}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left font-medium">
|
||||
{__('Error')}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from '@/components/ui/select';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { parseAmount, parseDate } from '@/lib/file-parser';
|
||||
import { balanceTermCapitalized } from '@/types/account';
|
||||
import type {
|
||||
BalanceColumnMapping,
|
||||
ColumnOption,
|
||||
|
|
@ -26,6 +27,7 @@ interface ImportBalanceStepMappingProps {
|
|||
parsedData: ParsedRow[];
|
||||
currencyCode: string;
|
||||
showInvestedAmount: boolean;
|
||||
isLoan?: boolean;
|
||||
onMappingChange: (field: keyof BalanceColumnMapping, value: string) => void;
|
||||
onDateFormatChange: (format: DateFormat) => void;
|
||||
onNext: () => void;
|
||||
|
|
@ -40,6 +42,7 @@ export function ImportBalanceStepMapping({
|
|||
parsedData,
|
||||
currencyCode,
|
||||
showInvestedAmount,
|
||||
isLoan = false,
|
||||
onMappingChange,
|
||||
onDateFormatChange,
|
||||
onNext,
|
||||
|
|
@ -128,7 +131,7 @@ export function ImportBalanceStepMapping({
|
|||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="balance-column">
|
||||
{__('Balance')}
|
||||
{balanceTermCapitalized(isLoan ? 'loan' : 'checking')}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
|
|
@ -139,7 +142,11 @@ export function ImportBalanceStepMapping({
|
|||
>
|
||||
<SelectTrigger id="balance-column">
|
||||
<SelectValue
|
||||
placeholder={__('Select balance column')}
|
||||
placeholder={
|
||||
isLoan
|
||||
? __('Select owed amount column')
|
||||
: __('Select balance column')
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -275,7 +282,9 @@ export function ImportBalanceStepMapping({
|
|||
{__('Back')}
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!isValid}>
|
||||
{__('Preview Balances')}
|
||||
{isLoan
|
||||
? __('Preview Owed Amounts')
|
||||
: __('Preview Balances')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { balanceTermCapitalized } from '@/types/account';
|
||||
import { type ParsedBalance } from '@/types/balance-import';
|
||||
import { formatCurrency } from '@/utils/currency';
|
||||
import { formatDateMedium } from '@/utils/date';
|
||||
|
|
@ -17,6 +18,7 @@ interface ImportBalanceStepPreviewProps {
|
|||
balances: ParsedBalance[];
|
||||
currencyCode: string;
|
||||
showInvestedAmount: boolean;
|
||||
isLoan?: boolean;
|
||||
onConfirm: () => void;
|
||||
onBack: () => void;
|
||||
isImporting: boolean;
|
||||
|
|
@ -26,6 +28,7 @@ export function ImportBalanceStepPreview({
|
|||
balances,
|
||||
currencyCode,
|
||||
showInvestedAmount,
|
||||
isLoan = false,
|
||||
onConfirm,
|
||||
onBack,
|
||||
isImporting,
|
||||
|
|
@ -44,8 +47,15 @@ export function ImportBalanceStepPreview({
|
|||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border bg-muted/50 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{total} balance{total !== 1 ? 's' : ''}
|
||||
{__('will be updated or\n created.')}
|
||||
{total}{' '}
|
||||
{isLoan
|
||||
? total !== 1
|
||||
? __('owed amounts')
|
||||
: __('owed amount')
|
||||
: total !== 1
|
||||
? __('balances')
|
||||
: __('balance')}{' '}
|
||||
{__('will be updated or created.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -60,7 +70,9 @@ export function ImportBalanceStepPreview({
|
|||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-right">
|
||||
{__('Balance')}
|
||||
{balanceTermCapitalized(
|
||||
isLoan ? 'loan' : 'checking',
|
||||
)}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
|
@ -71,7 +83,9 @@ export function ImportBalanceStepPreview({
|
|||
colSpan={colSpan}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
No valid balances found
|
||||
{isLoan
|
||||
? __('No valid owed amounts found')
|
||||
: __('No valid balances found')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
|
@ -116,7 +130,9 @@ export function ImportBalanceStepPreview({
|
|||
>
|
||||
{isImporting
|
||||
? 'Importing...'
|
||||
: `Import ${total} Balance${total !== 1 ? 's' : ''}`}
|
||||
: isLoan
|
||||
? `Import ${total} Owed Amount${total !== 1 ? 's' : ''}`
|
||||
: `Import ${total} Balance${total !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { Label } from '@/components/ui/label';
|
|||
import {
|
||||
type Account,
|
||||
type AccountBalance,
|
||||
balanceTermCapitalized,
|
||||
supportsInvestedAmount,
|
||||
} from '@/types/account';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
|
@ -52,6 +53,7 @@ export function UpdateBalanceDialog({
|
|||
const [isLoadingLastBalance, setIsLoadingLastBalance] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const showInvestedAmount = supportsInvestedAmount(account);
|
||||
const isLoan = account.type === 'loan';
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchLastBalance() {
|
||||
|
|
@ -162,20 +164,32 @@ export function UpdateBalanceDialog({
|
|||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent hasKeyboard className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Update balance')}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{isLoan
|
||||
? __('Update owed amount')
|
||||
: __('Update balance')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__(
|
||||
'Set the balance for this account on a specific date.',
|
||||
)}
|
||||
{isLoan
|
||||
? __(
|
||||
'Set the owed amount for this account on a specific date.',
|
||||
)
|
||||
: __(
|
||||
'Set the balance for this account on a specific date.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="balance-amount">{__('Balance')}</Label>
|
||||
<Label htmlFor="balance-amount">
|
||||
{balanceTermCapitalized(account.type)}
|
||||
</Label>
|
||||
{isLoadingLastBalance ? (
|
||||
<div className="flex h-10 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{__('Loading last balance...')}
|
||||
{isLoan
|
||||
? __('Loading last owed amount...')
|
||||
: __('Loading last balance...')}
|
||||
</div>
|
||||
) : (
|
||||
<AmountInput
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
@ -7,6 +9,7 @@ import {
|
|||
import { ChartViewType } from '@/hooks/use-chart-views';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { Separator } from '../ui/separator';
|
||||
import {
|
||||
type ChartGranularity,
|
||||
ChartGranularityToggle,
|
||||
|
|
@ -19,6 +22,10 @@ interface ChartSettingsPopoverProps {
|
|||
currentView: ChartViewType;
|
||||
onViewChange: (value: ChartViewType) => void;
|
||||
availableViews: ChartViewType[];
|
||||
showChartControls?: boolean;
|
||||
includeLoansLabel?: string;
|
||||
includeLoans?: boolean;
|
||||
onIncludeLoansChange?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function ChartSettingsPopover({
|
||||
|
|
@ -27,6 +34,10 @@ export function ChartSettingsPopover({
|
|||
currentView,
|
||||
onViewChange,
|
||||
availableViews,
|
||||
showChartControls = true,
|
||||
includeLoansLabel,
|
||||
includeLoans,
|
||||
onIncludeLoansChange,
|
||||
}: ChartSettingsPopoverProps) {
|
||||
return (
|
||||
<Popover>
|
||||
|
|
@ -35,30 +46,64 @@ export function ChartSettingsPopover({
|
|||
<Settings2 className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-auto">
|
||||
<PopoverContent align="end" className="w-72">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{__('Period')}
|
||||
</span>
|
||||
<ChartGranularityToggle
|
||||
value={granularity}
|
||||
onValueChange={onGranularityChange}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{__('Chart type')}
|
||||
</span>
|
||||
<ChartViewToggle
|
||||
value={currentView}
|
||||
onValueChange={onViewChange}
|
||||
availableViews={availableViews}
|
||||
granularity={granularity}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
{showChartControls ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{__('Period')}
|
||||
</span>
|
||||
<ChartGranularityToggle
|
||||
value={granularity}
|
||||
onValueChange={onGranularityChange}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm font-medium">
|
||||
{__('Chart type')}
|
||||
</span>
|
||||
<ChartViewToggle
|
||||
value={currentView}
|
||||
onValueChange={onViewChange}
|
||||
availableViews={availableViews}
|
||||
granularity={granularity}
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
</>
|
||||
) : null}
|
||||
{onIncludeLoansChange &&
|
||||
typeof includeLoans === 'boolean' &&
|
||||
includeLoansLabel ? (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="include-loans-in-net-worth-chart"
|
||||
className="text-sm leading-5 font-medium"
|
||||
>
|
||||
{includeLoansLabel}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Include loan balances in the net worth totals and chart',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="include-loans-in-net-worth-chart"
|
||||
checked={includeLoans}
|
||||
onCheckedChange={(checked) =>
|
||||
onIncludeLoansChange(checked === true)
|
||||
}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -25,9 +25,15 @@ import {
|
|||
} from '@/hooks/use-dashboard-data';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { AccountInfo } from '@/lib/chart-calculations';
|
||||
import {
|
||||
AccountInfo,
|
||||
getAccountSign,
|
||||
isLiabilityType,
|
||||
} from '@/lib/chart-calculations';
|
||||
import { SharedData } from '@/types';
|
||||
import { formatDayFromDate } from '@/utils/date';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { router, usePage } from '@inertiajs/react';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { PercentageTrendIndicator } from './percentage-trend-indicator';
|
||||
|
|
@ -69,7 +75,7 @@ function formatXAxisLabel(
|
|||
|
||||
function calculateTrend(
|
||||
data: Array<Record<string, string | number | OriginalAmount>>,
|
||||
accountIds: string[],
|
||||
accounts: Record<string, AccountInfo>,
|
||||
periodsBack: number,
|
||||
): TrendData | null {
|
||||
if (data.length < 2) return null;
|
||||
|
|
@ -79,14 +85,28 @@ function calculateTrend(
|
|||
|
||||
if (currentIndex === previousIndex) return null;
|
||||
|
||||
const accountIds = Object.keys(accounts);
|
||||
|
||||
const currentTotal = accountIds.reduce((sum, id) => {
|
||||
const value = data[currentIndex][id];
|
||||
return sum + (typeof value === 'number' ? value : 0);
|
||||
if (typeof value !== 'number') {
|
||||
return sum;
|
||||
}
|
||||
|
||||
const account = accounts[id];
|
||||
|
||||
return sum + getAccountSign(account.type) * Math.abs(value);
|
||||
}, 0);
|
||||
|
||||
const previousTotal = accountIds.reduce((sum, id) => {
|
||||
const value = data[previousIndex][id];
|
||||
return sum + (typeof value === 'number' ? value : 0);
|
||||
if (typeof value !== 'number') {
|
||||
return sum;
|
||||
}
|
||||
|
||||
const account = accounts[id];
|
||||
|
||||
return sum + getAccountSign(account.type) * Math.abs(value);
|
||||
}, 0);
|
||||
|
||||
if (previousTotal === 0) return null;
|
||||
|
|
@ -112,6 +132,7 @@ export function NetWorthChart({
|
|||
loading,
|
||||
showLegend = false,
|
||||
}: NetWorthChartProps) {
|
||||
const { props } = usePage<SharedData>();
|
||||
const locale = useLocale();
|
||||
const isMobile = useIsMobile();
|
||||
const [granularity, setGranularity] = useState<ChartGranularity>('monthly');
|
||||
|
|
@ -119,6 +140,8 @@ export function NetWorthChart({
|
|||
null,
|
||||
);
|
||||
const [isDailyLoading, setIsDailyLoading] = useState(false);
|
||||
const includeLoansInNetWorthChart =
|
||||
props.includeLoansInNetWorthChart ?? true;
|
||||
|
||||
const fetchDailyData = useCallback(async () => {
|
||||
setIsDailyLoading(true);
|
||||
|
|
@ -165,7 +188,8 @@ export function NetWorthChart({
|
|||
const userCurrency = activeData.currency_code || 'USD';
|
||||
|
||||
const {
|
||||
chartData,
|
||||
scaledChartData,
|
||||
rawChartData,
|
||||
dataKeys,
|
||||
chartConfig,
|
||||
shortTrend,
|
||||
|
|
@ -173,15 +197,32 @@ export function NetWorthChart({
|
|||
totalAmount,
|
||||
accountCurrencies,
|
||||
accountsForHook,
|
||||
hasLiabilities,
|
||||
} = useMemo(() => {
|
||||
const accounts = activeData.accounts || {};
|
||||
const chartDataArray = activeData.data || [];
|
||||
|
||||
// Sort accounts by descending average balance so the largest accounts
|
||||
// are at the bottom of the stacked chart and smallest are on top.
|
||||
// Using the average across all data points ensures a consistent order
|
||||
// across every period (month or day).
|
||||
const accountIds = Object.keys(accounts).sort((a, b) => {
|
||||
// All accounts included based on the loan toggle – used for totals & trends.
|
||||
const includedAccounts = Object.fromEntries(
|
||||
Object.entries(accounts).filter(([, account]) => {
|
||||
return includeLoansInNetWorthChart || account.type !== 'loan';
|
||||
}),
|
||||
);
|
||||
|
||||
// Split into assets (chart segments) and liabilities (affect totals only).
|
||||
const assetAccounts = Object.fromEntries(
|
||||
Object.entries(includedAccounts).filter(
|
||||
([, account]) => !isLiabilityType(account.type),
|
||||
),
|
||||
);
|
||||
const liabilityAccountIds = Object.keys(includedAccounts).filter((id) =>
|
||||
isLiabilityType(includedAccounts[id].type),
|
||||
);
|
||||
const hasLiabs = liabilityAccountIds.length > 0;
|
||||
|
||||
// Sort asset accounts by descending average balance so the largest
|
||||
// accounts are at the bottom of the stacked chart and smallest on top.
|
||||
const chartAccountIds = Object.keys(assetAccounts).sort((a, b) => {
|
||||
const valuesA = chartDataArray
|
||||
.map((p) => p[a])
|
||||
.filter((v): v is number => typeof v === 'number');
|
||||
|
|
@ -205,8 +246,9 @@ export function NetWorthChart({
|
|||
const currencies: Record<string, string> = {};
|
||||
const hookAccounts: Record<string, AccountInfo> = {};
|
||||
|
||||
accountIds.forEach((id) => {
|
||||
const account = accounts[id];
|
||||
// Build config and currencies only for asset accounts (chart segments).
|
||||
chartAccountIds.forEach((id) => {
|
||||
const account = assetAccounts[id];
|
||||
config[id] = {
|
||||
label: account ? <EncryptedLabel account={account} /> : id,
|
||||
};
|
||||
|
|
@ -222,41 +264,156 @@ export function NetWorthChart({
|
|||
}
|
||||
});
|
||||
|
||||
// All values are now in the user's currency, so compute a single total
|
||||
// Also register liability accounts so the useChartViews MoM/percent
|
||||
// series and calculateTrend include them in net worth.
|
||||
const allIncludedIds = Object.keys(includedAccounts);
|
||||
const allHookAccounts: Record<string, AccountInfo> = {
|
||||
...hookAccounts,
|
||||
};
|
||||
allIncludedIds.forEach((id) => {
|
||||
if (!allHookAccounts[id]) {
|
||||
const account = includedAccounts[id];
|
||||
if (account) {
|
||||
allHookAccounts[id] = {
|
||||
id: account.id,
|
||||
type: account.type,
|
||||
currency_code: account.currency_code,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Build scaled chart data:
|
||||
// - Each asset value is proportionally scaled so total bar height = net worth
|
||||
// - Original values stored as `${id}_display` for tooltip display
|
||||
// - Liability total and net worth stored as metadata
|
||||
const scaled = chartDataArray.map((point) => {
|
||||
const newPoint: Record<string, string | number | OriginalAmount> =
|
||||
{};
|
||||
|
||||
// Copy non-account fields
|
||||
if (point.month !== undefined) newPoint.month = point.month;
|
||||
if (point.timestamp !== undefined)
|
||||
newPoint.timestamp = point.timestamp;
|
||||
|
||||
// Compute totals for this data point
|
||||
let totalAssets = 0;
|
||||
let totalLiabilities = 0;
|
||||
|
||||
chartAccountIds.forEach((id) => {
|
||||
const value = point[id];
|
||||
if (typeof value === 'number') {
|
||||
totalAssets += Math.abs(value);
|
||||
}
|
||||
});
|
||||
|
||||
liabilityAccountIds.forEach((id) => {
|
||||
const value = point[id];
|
||||
if (typeof value === 'number') {
|
||||
totalLiabilities += Math.abs(value);
|
||||
}
|
||||
});
|
||||
|
||||
const netWorth = totalAssets - totalLiabilities;
|
||||
const scaleFactor =
|
||||
hasLiabs && totalAssets > 0
|
||||
? Math.max(0, netWorth / totalAssets)
|
||||
: 1;
|
||||
|
||||
// Asset values: scaled for rendering, original for tooltip
|
||||
chartAccountIds.forEach((id) => {
|
||||
const value = point[id];
|
||||
if (typeof value === 'number') {
|
||||
newPoint[id] = value * scaleFactor;
|
||||
if (hasLiabs) {
|
||||
newPoint[`${id}_display`] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy _original entries for multi-currency display
|
||||
const originalKey = `${id}_original`;
|
||||
if (point[originalKey] !== undefined) {
|
||||
newPoint[originalKey] = point[originalKey];
|
||||
}
|
||||
});
|
||||
|
||||
// Per-liability account data for tooltip (one row per loan)
|
||||
if (hasLiabs) {
|
||||
const liabilities: Array<{ name: string; amount: number }> = [];
|
||||
liabilityAccountIds.forEach((id) => {
|
||||
const value = point[id];
|
||||
if (typeof value === 'number' && Math.abs(value) > 0) {
|
||||
const account = includedAccounts[id];
|
||||
liabilities.push({
|
||||
name: account.name,
|
||||
amount: Math.abs(value),
|
||||
});
|
||||
}
|
||||
});
|
||||
// Store as JSON string since data points only hold primitives
|
||||
newPoint.__liabilities = JSON.stringify(liabilities);
|
||||
newPoint.__liabilities_total = totalLiabilities;
|
||||
newPoint.__net_worth = netWorth;
|
||||
}
|
||||
|
||||
return newPoint;
|
||||
});
|
||||
|
||||
// Compute the signed total across ALL included accounts.
|
||||
let total = 0;
|
||||
if (chartDataArray.length > 0) {
|
||||
const lastDataPoint = chartDataArray[chartDataArray.length - 1];
|
||||
accountIds.forEach((id) => {
|
||||
allIncludedIds.forEach((id) => {
|
||||
const value = lastDataPoint[id];
|
||||
if (typeof value === 'number') {
|
||||
total += value;
|
||||
const account = includedAccounts[id];
|
||||
total += getAccountSign(account.type) * Math.abs(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
chartData: chartDataArray,
|
||||
dataKeys: accountIds,
|
||||
scaledChartData: scaled,
|
||||
rawChartData: chartDataArray,
|
||||
dataKeys: chartAccountIds,
|
||||
chartConfig: config,
|
||||
shortTrend: calculateTrend(chartDataArray, accountIds, 1),
|
||||
shortTrend: calculateTrend(chartDataArray, allHookAccounts, 1),
|
||||
longTrend: calculateTrend(
|
||||
chartDataArray,
|
||||
accountIds,
|
||||
allHookAccounts,
|
||||
chartDataArray.length - 1,
|
||||
),
|
||||
totalAmount: total,
|
||||
accountCurrencies: currencies,
|
||||
accountsForHook: hookAccounts,
|
||||
accountsForHook: allHookAccounts,
|
||||
hasLiabilities: hasLiabs,
|
||||
};
|
||||
}, [activeData]);
|
||||
}, [activeData, includeLoansInNetWorthChart]);
|
||||
|
||||
const chartViews = useChartViews({
|
||||
data: chartData as Array<Record<string, string | number>>,
|
||||
data: rawChartData as Array<Record<string, string | number>>,
|
||||
accounts: accountsForHook,
|
||||
initialView: 'stacked',
|
||||
hasStackedView: true,
|
||||
netWorthOptions: {
|
||||
includeLoanAccounts: includeLoansInNetWorthChart,
|
||||
},
|
||||
});
|
||||
|
||||
const handleIncludeLoansChange = useCallback((includeLoans: boolean) => {
|
||||
router.patch(
|
||||
'/settings/net-worth-chart-loan-preference',
|
||||
{
|
||||
include_loans_in_net_worth_chart: includeLoans,
|
||||
},
|
||||
{
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
only: ['includeLoansInNetWorthChart'],
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
const valueFormatter = useMemo(() => {
|
||||
return (value: number): React.ReactNode => {
|
||||
return (
|
||||
|
|
@ -353,6 +510,9 @@ export function NetWorthChart({
|
|||
currentView={chartViews.currentView}
|
||||
onViewChange={chartViews.setCurrentView}
|
||||
availableViews={chartViews.availableViews}
|
||||
includeLoansLabel={__('Include loans')}
|
||||
includeLoans={includeLoansInNetWorthChart}
|
||||
onIncludeLoansChange={handleIncludeLoansChange}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -366,6 +526,19 @@ export function NetWorthChart({
|
|||
availableViews={chartViews.availableViews}
|
||||
granularity={granularity}
|
||||
/>
|
||||
<ChartSettingsPopover
|
||||
granularity={granularity}
|
||||
onGranularityChange={setGranularity}
|
||||
currentView={chartViews.currentView}
|
||||
onViewChange={chartViews.setCurrentView}
|
||||
availableViews={chartViews.availableViews}
|
||||
showChartControls={false}
|
||||
includeLoansLabel={__('Include loans')}
|
||||
includeLoans={includeLoansInNetWorthChart}
|
||||
onIncludeLoansChange={
|
||||
handleIncludeLoansChange
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -375,7 +548,7 @@ export function NetWorthChart({
|
|||
{chartViews.currentView === 'stacked' &&
|
||||
(granularity === 'daily' ? (
|
||||
<StackedAreaChart
|
||||
data={chartData.slice(1)}
|
||||
data={scaledChartData.slice(1)}
|
||||
dataKeys={dataKeys}
|
||||
config={chartConfig}
|
||||
xAxisKey="month"
|
||||
|
|
@ -385,10 +558,15 @@ export function NetWorthChart({
|
|||
displayCurrency={userCurrency}
|
||||
className="h-[300px] w-full"
|
||||
showLegend={showLegend}
|
||||
netWorthMode={
|
||||
hasLiabilities
|
||||
? { liabilityTypeLabel: __('Loan') }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<StackedBarChart
|
||||
data={chartData.slice(1)}
|
||||
data={scaledChartData.slice(1)}
|
||||
dataKeys={dataKeys}
|
||||
config={chartConfig}
|
||||
xAxisKey="month"
|
||||
|
|
@ -398,6 +576,11 @@ export function NetWorthChart({
|
|||
displayCurrency={userCurrency}
|
||||
className="h-[300px] w-full"
|
||||
showLegend={showLegend}
|
||||
netWorthMode={
|
||||
hasLiabilities
|
||||
? { liabilityTypeLabel: __('Loan') }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{chartViews.currentView === 'mom' && (
|
||||
|
|
|
|||
|
|
@ -134,6 +134,10 @@ interface ChartTooltipContentProps {
|
|||
valueFormatter?: (value: number, accountId?: string) => React.ReactNode;
|
||||
accountCurrencies?: Record<string, string>;
|
||||
displayCurrency?: string;
|
||||
/** When set, tooltip shows liability rows and net-worth total instead of simple sum. */
|
||||
netWorthMode?: {
|
||||
liabilityTypeLabel: string;
|
||||
};
|
||||
}
|
||||
|
||||
function formatCurrencyWithCode(value: number, currencyCode: string, locale: string): string {
|
||||
|
|
@ -160,6 +164,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||
valueFormatter,
|
||||
accountCurrencies,
|
||||
displayCurrency,
|
||||
netWorthMode,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
|
|
@ -210,6 +215,14 @@ const ChartTooltipContent = React.forwardRef<
|
|||
return null;
|
||||
}
|
||||
|
||||
// In net worth mode, use pre-computed net worth from data point
|
||||
if (netWorthMode && displayCurrency) {
|
||||
const netWorth = payload[0]?.payload?.__net_worth as number | undefined;
|
||||
if (netWorth !== undefined) {
|
||||
return [[displayCurrency, netWorth] as [string, number]];
|
||||
}
|
||||
}
|
||||
|
||||
// When displayCurrency is set, all values are in a single currency
|
||||
if (displayCurrency) {
|
||||
const total = payload.reduce((sum, item) => {
|
||||
|
|
@ -232,7 +245,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||
});
|
||||
|
||||
return Object.entries(totals).sort((a, b) => b[1] - a[1]);
|
||||
}, [payload, accountCurrencies, displayCurrency]);
|
||||
}, [payload, accountCurrencies, displayCurrency, netWorthMode]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
|
|
@ -264,6 +277,12 @@ const ChartTooltipContent = React.forwardRef<
|
|||
item.dataKey || item.name || '',
|
||||
);
|
||||
|
||||
// In net worth mode, use the original unscaled
|
||||
// value stored in the data point for display.
|
||||
const displayValue = netWorthMode
|
||||
? ((item.payload?.[`${accountId}_display`] as number | undefined) ?? item.value)
|
||||
: item.value;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={String(item.dataKey)}
|
||||
|
|
@ -320,13 +339,13 @@ const ChartTooltipContent = React.forwardRef<
|
|||
})()}
|
||||
{valueFormatter
|
||||
? valueFormatter(
|
||||
item.value as number,
|
||||
displayValue as number,
|
||||
accountId,
|
||||
)
|
||||
: typeof item.value ===
|
||||
: typeof displayValue ===
|
||||
'number'
|
||||
? item.value.toLocaleString(locale)
|
||||
: item.value}
|
||||
? displayValue.toLocaleString(locale)
|
||||
: displayValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -336,58 +355,91 @@ const ChartTooltipContent = React.forwardRef<
|
|||
);
|
||||
},
|
||||
)}
|
||||
{payload.length > 1 && (
|
||||
<div className="border-border/50 flex flex-col gap-1 border-t pt-1.5">
|
||||
{hasMultipleCurrencies ? (
|
||||
currencyTotals.map(([currency, total]) => (
|
||||
<div
|
||||
key={currency}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Total {currency}
|
||||
</span>
|
||||
{(() => {
|
||||
const liabilitiesTotal = netWorthMode
|
||||
? (payload[0]?.payload?.__liabilities_total as number | undefined)
|
||||
: undefined;
|
||||
const liabilitiesJson = netWorthMode
|
||||
? (payload[0]?.payload?.__liabilities as string | undefined)
|
||||
: undefined;
|
||||
const liabilities: Array<{ name: string; amount: number }> = liabilitiesJson
|
||||
? (JSON.parse(liabilitiesJson) as Array<{ name: string; amount: number }>)
|
||||
: [];
|
||||
const hasLiabilities = typeof liabilitiesTotal === 'number' && liabilitiesTotal > 0;
|
||||
const showTotalSection = payload.length > 1 || hasLiabilities;
|
||||
|
||||
if (!showTotalSection) return null;
|
||||
|
||||
const totalLabel = hasLiabilities ? 'Net Worth' : 'Total';
|
||||
|
||||
return (
|
||||
<div className="border-border/50 flex flex-col gap-1 border-t pt-1.5">
|
||||
{hasLiabilities && displayCurrency && liabilities.map((liability, index) => (
|
||||
<div key={index} className="flex justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="size-2.5 rounded-xs bg-destructive" />
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{netWorthMode?.liabilityTypeLabel}: {liability.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{isPrivacyModeEnabled
|
||||
? formatCurrencyWithCode(total, currency, locale).replace(/\d/g, '*')
|
||||
: formatCurrencyWithCode(total, currency, locale)}
|
||||
? formatCurrencyWithCode(-liability.amount, displayCurrency, locale).replace(/\d/g, '*')
|
||||
: formatCurrencyWithCode(-liability.amount, displayCurrency, locale)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground font-medium">
|
||||
Total
|
||||
</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{currencyTotals && currencyTotals[0]
|
||||
? isPrivacyModeEnabled
|
||||
? formatCurrencyWithCode(currencyTotals[0][1], currencyTotals[0][0], locale).replace(/\d/g, '*')
|
||||
: formatCurrencyWithCode(currencyTotals[0][1], currencyTotals[0][0], locale)
|
||||
: payload
|
||||
.reduce(
|
||||
(
|
||||
sum: number,
|
||||
item: TooltipPayloadItem,
|
||||
) => {
|
||||
const value =
|
||||
item.value;
|
||||
return (
|
||||
sum +
|
||||
(typeof value ===
|
||||
'number'
|
||||
? value
|
||||
: 0)
|
||||
);
|
||||
},
|
||||
0,
|
||||
)
|
||||
.toLocaleString(locale)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
{hasMultipleCurrencies ? (
|
||||
currencyTotals.map(([currency, total]) => (
|
||||
<div
|
||||
key={currency}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{totalLabel} {currency}
|
||||
</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{isPrivacyModeEnabled
|
||||
? formatCurrencyWithCode(total, currency, locale).replace(/\d/g, '*')
|
||||
: formatCurrencyWithCode(total, currency, locale)}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{totalLabel}
|
||||
</span>
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{currencyTotals && currencyTotals[0]
|
||||
? isPrivacyModeEnabled
|
||||
? formatCurrencyWithCode(currencyTotals[0][1], currencyTotals[0][0], locale).replace(/\d/g, '*')
|
||||
: formatCurrencyWithCode(currencyTotals[0][1], currencyTotals[0][0], locale)
|
||||
: payload
|
||||
.reduce(
|
||||
(
|
||||
sum: number,
|
||||
item: TooltipPayloadItem,
|
||||
) => {
|
||||
const value =
|
||||
item.value;
|
||||
return (
|
||||
sum +
|
||||
(typeof value ===
|
||||
'number'
|
||||
? value
|
||||
: 0)
|
||||
);
|
||||
},
|
||||
0,
|
||||
)
|
||||
.toLocaleString(locale)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export interface StackedAreaChartProps<T extends Record<string, unknown>> {
|
|||
className?: string;
|
||||
showLegend?: boolean;
|
||||
minBarWidth?: number;
|
||||
netWorthMode?: { liabilityTypeLabel: string };
|
||||
}
|
||||
|
||||
export function StackedAreaChart<T extends Record<string, unknown>>({
|
||||
|
|
@ -50,6 +51,7 @@ export function StackedAreaChart<T extends Record<string, unknown>>({
|
|||
className,
|
||||
showLegend = true,
|
||||
minBarWidth = 20,
|
||||
netWorthMode,
|
||||
}: StackedAreaChartProps<T>) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -124,6 +126,7 @@ export function StackedAreaChart<T extends Record<string, unknown>>({
|
|||
valueFormatter={valueFormatter}
|
||||
accountCurrencies={accountCurrencies}
|
||||
displayCurrency={displayCurrency}
|
||||
netWorthMode={netWorthMode}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export interface StackedBarChartProps<T extends Record<string, unknown>> {
|
|||
className?: string;
|
||||
showLegend?: boolean;
|
||||
minBarWidth?: number;
|
||||
netWorthMode?: { liabilityTypeLabel: string };
|
||||
}
|
||||
|
||||
export function StackedBarChart<T extends Record<string, unknown>>({
|
||||
|
|
@ -142,6 +143,7 @@ export function StackedBarChart<T extends Record<string, unknown>>({
|
|||
className,
|
||||
showLegend = true,
|
||||
minBarWidth = 50,
|
||||
netWorthMode,
|
||||
}: StackedBarChartProps<T>) {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -206,6 +208,7 @@ export function StackedBarChart<T extends Record<string, unknown>>({
|
|||
valueFormatter={valueFormatter}
|
||||
accountCurrencies={accountCurrencies}
|
||||
displayCurrency={displayCurrency}
|
||||
netWorthMode={netWorthMode}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
computeMoMPercent,
|
||||
computeNetWorthSeries,
|
||||
MonthDataPoint,
|
||||
NetWorthSeriesOptions,
|
||||
PercentDataPoint,
|
||||
} from '@/lib/chart-calculations';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
|
@ -20,6 +21,8 @@ export interface UseChartViewsOptions {
|
|||
initialView?: ChartViewType;
|
||||
/** Whether to show stacked view option (false for single account charts) */
|
||||
hasStackedView?: boolean;
|
||||
/** Options for computing net worth series */
|
||||
netWorthOptions?: NetWorthSeriesOptions;
|
||||
}
|
||||
|
||||
export interface UseChartViewsReturn {
|
||||
|
|
@ -43,6 +46,7 @@ export function useChartViews({
|
|||
accounts,
|
||||
initialView = 'stacked',
|
||||
hasStackedView = true,
|
||||
netWorthOptions,
|
||||
}: UseChartViewsOptions): UseChartViewsReturn {
|
||||
// View state
|
||||
const [currentView, setCurrentViewState] = useState<ChartViewType>(
|
||||
|
|
@ -75,7 +79,11 @@ export function useChartViews({
|
|||
// The first month is needed for MoM calculations but shouldn't be displayed,
|
||||
// so we slice(1) to show only 12 bars
|
||||
const { netWorthSeries, deltaSeries, momPercentSeries } = useMemo(() => {
|
||||
const fullNetWorth = computeNetWorthSeries(data, accounts);
|
||||
const fullNetWorth = computeNetWorthSeries(
|
||||
data,
|
||||
accounts,
|
||||
netWorthOptions,
|
||||
);
|
||||
const fullDelta = computeDeltaSeries(fullNetWorth);
|
||||
const fullMomPercent = computeMoMPercent(fullNetWorth);
|
||||
|
||||
|
|
@ -84,7 +92,7 @@ export function useChartViews({
|
|||
deltaSeries: fullDelta.slice(1),
|
||||
momPercentSeries: fullMomPercent.slice(1),
|
||||
};
|
||||
}, [data, accounts]);
|
||||
}, [data, accounts, netWorthOptions]);
|
||||
|
||||
return {
|
||||
// View state
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
deriveAccountMetrics,
|
||||
type NetWorthEvolutionData,
|
||||
} from './use-dashboard-data';
|
||||
|
||||
describe('deriveAccountMetrics', () => {
|
||||
it('returns loan balances and diffs as negative net worth contributions', () => {
|
||||
const netWorthEvolution: NetWorthEvolutionData = {
|
||||
currency_code: 'EUR',
|
||||
accounts: {
|
||||
loan_1: {
|
||||
id: 'loan_1',
|
||||
name: 'Mortgage',
|
||||
name_iv: null,
|
||||
encrypted: false,
|
||||
type: 'loan',
|
||||
currency_code: 'EUR',
|
||||
bank: {
|
||||
id: 'bank_1',
|
||||
user_id: null,
|
||||
name: 'Bank',
|
||||
logo: null,
|
||||
},
|
||||
banking_connection_id: null,
|
||||
},
|
||||
},
|
||||
data: [
|
||||
{ month: '2025-01', loan_1: 120000 },
|
||||
{ month: '2025-02', loan_1: 100000 },
|
||||
],
|
||||
};
|
||||
|
||||
const [account] = deriveAccountMetrics(netWorthEvolution, 'en-US');
|
||||
|
||||
expect(account.currentBalance).toBe(-100000);
|
||||
expect(account.previousBalance).toBe(-120000);
|
||||
expect(account.diff).toBe(20000);
|
||||
expect(account.history).toEqual([
|
||||
expect.objectContaining({ value: -120000 }),
|
||||
expect.objectContaining({ value: -100000 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { getAccountSign } from '@/lib/chart-calculations';
|
||||
import { Account, AccountType, Bank } from '@/types/account';
|
||||
import { Category } from '@/types/category';
|
||||
import { format, subDays, subMonths } from 'date-fns';
|
||||
|
|
@ -65,7 +66,11 @@ export function deriveAccountMetrics(
|
|||
const investedKey = account.id + '_invested';
|
||||
const history = data.map((point) => ({
|
||||
date: formatMonth(point.month as string, locale),
|
||||
value: (point[account.id] as number) ?? 0,
|
||||
value:
|
||||
typeof point[account.id] === 'number'
|
||||
? getAccountSign(account.type) *
|
||||
Math.abs(point[account.id] as number)
|
||||
: 0,
|
||||
investedAmount:
|
||||
investedKey in point
|
||||
? (point[investedKey] as number | null)
|
||||
|
|
|
|||
|
|
@ -131,6 +131,26 @@ describe('computeNetWorthSeries', () => {
|
|||
expect(result[0].value).toBe(-40000); // 10000 - 50000
|
||||
});
|
||||
|
||||
it('normalizes negative liability balances before subtracting them', () => {
|
||||
const data = [{ month: '2025-01', checking: 10000, loan: -50000 }];
|
||||
const accounts = createAccounts({ checking: 'checking', loan: 'loan' });
|
||||
|
||||
const result = computeNetWorthSeries(data, accounts);
|
||||
|
||||
expect(result[0].value).toBe(-40000);
|
||||
});
|
||||
|
||||
it('can exclude loan accounts from net worth calculations', () => {
|
||||
const data = [{ month: '2025-01', checking: 10000, loan: 50000 }];
|
||||
const accounts = createAccounts({ checking: 'checking', loan: 'loan' });
|
||||
|
||||
const result = computeNetWorthSeries(data, accounts, {
|
||||
includeLoanAccounts: false,
|
||||
});
|
||||
|
||||
expect(result[0].value).toBe(10000);
|
||||
});
|
||||
|
||||
it('preserves timestamp if present', () => {
|
||||
const data = [{ month: '2025-01', timestamp: 1704067200, acc1: 10000 }];
|
||||
const accounts = createAccounts({ acc1: 'checking' });
|
||||
|
|
|
|||
|
|
@ -52,6 +52,10 @@ export interface AccountInfo {
|
|||
currency_code: string;
|
||||
}
|
||||
|
||||
export interface NetWorthSeriesOptions {
|
||||
includeLoanAccounts?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute net worth series from raw balance data
|
||||
* Handles sign correctly: assets add to NW, liabilities subtract
|
||||
|
|
@ -59,12 +63,17 @@ export interface AccountInfo {
|
|||
export function computeNetWorthSeries(
|
||||
data: Array<Record<string, string | number>>,
|
||||
accounts: Record<string, AccountInfo>,
|
||||
options: NetWorthSeriesOptions = {},
|
||||
): MonthDataPoint[] {
|
||||
if (!data || data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const accountIds = Object.keys(accounts);
|
||||
const includeLoanAccounts = options.includeLoanAccounts ?? true;
|
||||
|
||||
const accountIds = Object.entries(accounts)
|
||||
.filter(([, account]) => includeLoanAccounts || account.type !== 'loan')
|
||||
.map(([id]) => id);
|
||||
|
||||
return data.map((point) => {
|
||||
const month = point.month as string;
|
||||
|
|
@ -78,7 +87,9 @@ export function computeNetWorthSeries(
|
|||
if (!account) return sum + balance;
|
||||
|
||||
const sign = getAccountSign(account.type);
|
||||
return sum + sign * balance;
|
||||
const normalizedBalance = sign === -1 ? Math.abs(balance) : balance;
|
||||
|
||||
return sum + sign * normalizedBalance;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export default function AccountShow({
|
|||
}
|
||||
|
||||
const isConnected = !!account.banking_connection_id;
|
||||
const isLoan = account.type === 'loan';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
|
|
@ -112,7 +113,9 @@ export default function AccountShow({
|
|||
variant="outline"
|
||||
onClick={() => setUpdateBalanceOpen(true)}
|
||||
>
|
||||
{__('Update balance')}
|
||||
{isLoan
|
||||
? __('Update owed amount')
|
||||
: __('Update balance')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup>
|
||||
|
|
@ -120,7 +123,9 @@ export default function AccountShow({
|
|||
variant="outline"
|
||||
onClick={() => setImportBalancesOpen(true)}
|
||||
>
|
||||
{__('Import balances')}
|
||||
{isLoan
|
||||
? __('Import owed amounts')
|
||||
: __('Import balances')}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
|
@ -138,7 +143,9 @@ export default function AccountShow({
|
|||
setBalancesOpen(true)
|
||||
}
|
||||
>
|
||||
{__('See balances')}
|
||||
{isLoan
|
||||
? __('See owed amounts')
|
||||
: __('See balances')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setEditOpen(true)}
|
||||
|
|
@ -218,6 +225,7 @@ export default function AccountShow({
|
|||
open={importBalancesOpen}
|
||||
onOpenChange={setImportBalancesOpen}
|
||||
accounts={accounts}
|
||||
account={account}
|
||||
accountId={account.id}
|
||||
onSuccess={handleBalanceUpdated}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -124,3 +124,35 @@ export function filterTransactionalAccounts<T extends { type: AccountType }>(
|
|||
(account) => !NON_TRANSACTIONAL_ACCOUNT_TYPES.includes(account.type),
|
||||
);
|
||||
}
|
||||
|
||||
export function isLoanAccount(account: Pick<Account, 'type'>): boolean {
|
||||
return account.type === 'loan';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate term for "balance" based on account type.
|
||||
* Loan accounts use "owed amount" instead of "balance".
|
||||
*/
|
||||
export function balanceTerm(
|
||||
type: AccountType,
|
||||
variant: 'singular' | 'plural' = 'singular',
|
||||
): string {
|
||||
if (type === 'loan') {
|
||||
return variant === 'plural' ? __('owed amounts') : __('owed amount');
|
||||
}
|
||||
return variant === 'plural' ? __('balances') : __('balance');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate capitalized term for "Balance" based on account type.
|
||||
* Loan accounts use "Owed Amount" instead of "Balance".
|
||||
*/
|
||||
export function balanceTermCapitalized(
|
||||
type: AccountType,
|
||||
variant: 'singular' | 'plural' = 'singular',
|
||||
): string {
|
||||
if (type === 'loan') {
|
||||
return variant === 'plural' ? __('Owed Amounts') : __('Owed Amount');
|
||||
}
|
||||
return variant === 'plural' ? __('Balances') : __('Balance');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export interface SharedData {
|
|||
auth: Auth;
|
||||
flash: Flash;
|
||||
chartColorScheme: ChartColorScheme;
|
||||
includeLoansInNetWorthChart: boolean;
|
||||
subscriptionsEnabled: boolean;
|
||||
pricing: PricingConfig;
|
||||
sidebarOpen: boolean;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Http\Controllers\Settings\BankController;
|
|||
use App\Http\Controllers\Settings\CategoryController;
|
||||
use App\Http\Controllers\Settings\ChartColorSchemeController;
|
||||
use App\Http\Controllers\Settings\LabelController;
|
||||
use App\Http\Controllers\Settings\NetWorthChartLoanPreferenceController;
|
||||
use App\Http\Controllers\Settings\PasswordController;
|
||||
use App\Http\Controllers\Settings\ProfileController;
|
||||
use App\Http\Controllers\Settings\TwoFactorAuthenticationController;
|
||||
|
|
@ -61,6 +62,9 @@ Route::middleware('auth')->group(function () {
|
|||
Route::patch('settings/chart-color-scheme', [ChartColorSchemeController::class, 'update'])
|
||||
->name('chart-color-scheme.update');
|
||||
|
||||
Route::patch('settings/net-worth-chart-loan-preference', [NetWorthChartLoanPreferenceController::class, 'update'])
|
||||
->name('net-worth-chart-loan-preference.update');
|
||||
|
||||
Route::get('settings/billing', [SubscriptionController::class, 'billing'])->name('settings.billing');
|
||||
Route::get('settings/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('settings.billing.portal');
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,56 @@ test('net worth response includes currency_code', function () {
|
|||
->assertJson(['currency_code' => 'USD']);
|
||||
});
|
||||
|
||||
test('net worth treats positive loan balances as liabilities', function () {
|
||||
$checking = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Checking,
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
|
||||
$loan = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => AccountType::Loan,
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $checking->id,
|
||||
'balance_date' => now(),
|
||||
'balance' => 500000,
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $loan->id,
|
||||
'balance_date' => now(),
|
||||
'balance' => 100000,
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $checking->id,
|
||||
'balance_date' => now()->subDays(30),
|
||||
'balance' => 450000,
|
||||
]);
|
||||
|
||||
AccountBalance::factory()->create([
|
||||
'account_id' => $loan->id,
|
||||
'balance_date' => now()->subDays(30),
|
||||
'balance' => 150000,
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/net-worth?'.http_build_query([
|
||||
'from' => now()->subDays(29)->toDateString(),
|
||||
'to' => now()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson([
|
||||
'current' => 400000,
|
||||
'previous' => 300000,
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
});
|
||||
|
||||
test('monthly spending calculates expenses correctly', function () {
|
||||
$category = Category::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\UserSetting;
|
||||
|
||||
test('net worth chart loan preference can be updated', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch(route('net-worth-chart-loan-preference.update'), [
|
||||
'include_loans_in_net_worth_chart' => false,
|
||||
]);
|
||||
|
||||
$response->assertSessionHasNoErrors()->assertRedirect();
|
||||
|
||||
expect($user->fresh()->setting->include_loans_in_net_worth_chart)->toBeFalse();
|
||||
});
|
||||
|
||||
test('net worth chart loan preference rejects invalid values', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch(route('net-worth-chart-loan-preference.update'), [
|
||||
'include_loans_in_net_worth_chart' => 'sometimes',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('include_loans_in_net_worth_chart');
|
||||
});
|
||||
|
||||
test('net worth chart loan preference requires authentication', function () {
|
||||
$response = $this->patch(route('net-worth-chart-loan-preference.update'), [
|
||||
'include_loans_in_net_worth_chart' => false,
|
||||
]);
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('net worth chart loan preference creates setting when none exists', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect(UserSetting::where('user_id', $user->id)->exists())->toBeFalse();
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('net-worth-chart-loan-preference.update'), [
|
||||
'include_loans_in_net_worth_chart' => false,
|
||||
]);
|
||||
|
||||
expect(UserSetting::where('user_id', $user->id)->exists())->toBeTrue();
|
||||
expect($user->fresh()->setting->include_loans_in_net_worth_chart)->toBeFalse();
|
||||
});
|
||||
|
||||
test('net worth chart loan preference updates existing setting', function () {
|
||||
$user = User::factory()->create();
|
||||
UserSetting::factory()->for($user)->create([
|
||||
'include_loans_in_net_worth_chart' => false,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->patch(route('net-worth-chart-loan-preference.update'), [
|
||||
'include_loans_in_net_worth_chart' => true,
|
||||
]);
|
||||
|
||||
expect($user->fresh()->setting->include_loans_in_net_worth_chart)->toBeTrue();
|
||||
});
|
||||
|
||||
test('net worth chart loan preference defaults to true when no setting exists', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect($user->setting)->toBeNull();
|
||||
expect($user->setting?->include_loans_in_net_worth_chart ?? true)->toBeTrue();
|
||||
});
|
||||
|
||||
test('net worth chart loan preference is shared via inertia', function () {
|
||||
$user = User::factory()->create();
|
||||
UserSetting::factory()->for($user)->create([
|
||||
'include_loans_in_net_worth_chart' => false,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('appearance.edit'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertInertia(fn ($page) => $page->where('includeLoansInNetWorthChart', false));
|
||||
});
|
||||
Loading…
Reference in New Issue