232 lines
8.7 KiB
TypeScript
232 lines
8.7 KiB
TypeScript
import {
|
|
index,
|
|
reorder,
|
|
} from '@/actions/App/Http/Controllers/AccountController';
|
|
import { AccountListCard } from '@/components/accounts/account-list-card';
|
|
import { CreateAccountDialog } from '@/components/accounts/create-account-dialog';
|
|
import HeadingSmall from '@/components/heading-small';
|
|
import { SortableGrid } from '@/components/sortable-grid';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
|
|
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
|
import { netWorthContribution } from '@/lib/chart-calculations';
|
|
import { BreadcrumbItem, SharedData } from '@/types';
|
|
import { Account } from '@/types/account';
|
|
import { __ } from '@/utils/i18n';
|
|
import { Head, router, usePage } from '@inertiajs/react';
|
|
import { Plus } from 'lucide-react';
|
|
import { useCallback, useMemo, useState } from 'react';
|
|
|
|
const breadcrumbs: BreadcrumbItem[] = [
|
|
{
|
|
title: 'Accounts',
|
|
href: index().url,
|
|
},
|
|
];
|
|
|
|
interface AccountMetrics {
|
|
currentBalance: number;
|
|
previousBalance: number;
|
|
diff: number;
|
|
investedAmount: number | null;
|
|
history: Array<{
|
|
date: string;
|
|
value: number;
|
|
investedAmount?: number | null;
|
|
}>;
|
|
}
|
|
|
|
interface Props {
|
|
accounts: Account[];
|
|
accountMetrics?: Record<string, AccountMetrics>;
|
|
}
|
|
|
|
export default function AccountsIndex({ accounts, accountMetrics }: Props) {
|
|
const { auth } = usePage<SharedData>().props;
|
|
const isLoading = !accountMetrics;
|
|
|
|
// Identify loan account IDs that are linked to a real estate account
|
|
const linkedLoanAccountIds = useMemo(() => {
|
|
const ids = new Set<string>();
|
|
accounts.forEach((account) => {
|
|
if (
|
|
account.type === 'real_estate' &&
|
|
account.linked_loan_account_id
|
|
) {
|
|
ids.add(account.linked_loan_account_id);
|
|
}
|
|
});
|
|
return ids;
|
|
}, [accounts]);
|
|
|
|
const accountsWithMetrics: AccountWithMetrics[] = useMemo(() => {
|
|
return accounts.map((account) => {
|
|
const metrics = accountMetrics?.[account.id];
|
|
// Apply the liability sign so the Accounts list agrees with the
|
|
// dashboard: loans render negative, everything else keeps its value.
|
|
const currentBalance = netWorthContribution(
|
|
account.type,
|
|
metrics?.currentBalance ?? 0,
|
|
);
|
|
const previousBalance = netWorthContribution(
|
|
account.type,
|
|
metrics?.previousBalance ?? 0,
|
|
);
|
|
return {
|
|
...account,
|
|
currentBalance,
|
|
previousBalance,
|
|
diff: currentBalance - previousBalance,
|
|
history: (metrics?.history ?? []).map((point) => ({
|
|
...point,
|
|
value: netWorthContribution(account.type, point.value),
|
|
})),
|
|
investedAmount: metrics?.investedAmount ?? null,
|
|
};
|
|
});
|
|
}, [accounts, accountMetrics]);
|
|
|
|
// Flat list in the user-defined order; loan accounts linked to a real
|
|
// estate account are surfaced inside that account instead.
|
|
const visibleAccounts = useMemo(
|
|
() =>
|
|
accountsWithMetrics.filter(
|
|
(account) =>
|
|
!(
|
|
account.type === 'loan' &&
|
|
linkedLoanAccountIds.has(account.id)
|
|
),
|
|
),
|
|
[accountsWithMetrics, linkedLoanAccountIds],
|
|
);
|
|
|
|
// Optimistic ordering layered on top of the server order. Null means "use
|
|
// the server order"; a drag sets the new id order and persists it.
|
|
const [order, setOrder] = useState<string[] | null>(null);
|
|
const orderedAccounts = useMemo(() => {
|
|
if (!order) {
|
|
return visibleAccounts;
|
|
}
|
|
const byId = new Map(visibleAccounts.map((a) => [a.id, a]));
|
|
const ordered = order
|
|
.map((id) => byId.get(id))
|
|
.filter((a) => a !== undefined);
|
|
const rest = visibleAccounts.filter((a) => !order.includes(a.id));
|
|
return [...ordered, ...rest];
|
|
}, [visibleAccounts, order]);
|
|
|
|
const handleReorder = useCallback((ids: string[]) => {
|
|
setOrder(ids);
|
|
// Persist and re-sync the canonical order; the deferred accountMetrics
|
|
// prop is left untouched (kept from the current page).
|
|
router.patch(
|
|
reorder.url(),
|
|
{ ids },
|
|
{
|
|
preserveScroll: true,
|
|
preserveState: true,
|
|
only: ['accounts'],
|
|
},
|
|
);
|
|
}, []);
|
|
|
|
// Build a map of linked loan metrics keyed by real estate account ID.
|
|
// Intentionally reads the RAW (unsigned) accountMetrics prop, not the
|
|
// signed accountsWithMetrics: the equity math subtracts this owed amount
|
|
// (marketValue - mortgageOwed), so it must stay a positive magnitude.
|
|
const linkedLoanMetricsMap = useMemo(() => {
|
|
if (!accountMetrics) return {};
|
|
const map: Record<
|
|
string,
|
|
AccountMetrics & {
|
|
loanAccount?: {
|
|
name: string;
|
|
bank: { name: string; logo: string | null } | null;
|
|
};
|
|
}
|
|
> = {};
|
|
accounts.forEach((account) => {
|
|
if (
|
|
account.type === 'real_estate' &&
|
|
account.linked_loan_account_id &&
|
|
accountMetrics[account.linked_loan_account_id]
|
|
) {
|
|
const loanAccount = accounts.find(
|
|
(a) => a.id === account.linked_loan_account_id,
|
|
);
|
|
map[account.id] = {
|
|
...accountMetrics[account.linked_loan_account_id],
|
|
loanAccount: loanAccount
|
|
? { name: loanAccount.name, bank: loanAccount.bank }
|
|
: undefined,
|
|
};
|
|
}
|
|
});
|
|
return map;
|
|
}, [accounts, accountMetrics]);
|
|
|
|
const handleBalanceUpdated = useCallback(() => {
|
|
router.reload({ only: ['accountMetrics'] });
|
|
}, []);
|
|
|
|
const handleAccountCreated = useCallback(() => {
|
|
router.reload();
|
|
}, []);
|
|
|
|
return (
|
|
<AppSidebarLayout breadcrumbs={breadcrumbs}>
|
|
<Head title={__('Accounts')} />
|
|
|
|
<div className="space-y-8 p-6">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<HeadingSmall
|
|
title={__('Accounts')}
|
|
description={__('View and manage your bank accounts')}
|
|
/>
|
|
<CreateAccountDialog onSuccess={handleAccountCreated} />
|
|
</div>
|
|
|
|
<SortableGrid
|
|
className="grid gap-4 lg:grid-cols-2"
|
|
items={orderedAccounts}
|
|
getId={(account) => account.id}
|
|
onReorder={handleReorder}
|
|
renderItem={(account, dragHandle) => (
|
|
<AccountListCard
|
|
account={account}
|
|
dragHandle={dragHandle}
|
|
loading={isLoading}
|
|
onBalanceUpdated={handleBalanceUpdated}
|
|
linkedLoanMetrics={linkedLoanMetricsMap[account.id]}
|
|
displayCurrencyCode={auth.user.currency_code}
|
|
/>
|
|
)}
|
|
footer={
|
|
<CreateAccountDialog
|
|
onSuccess={handleAccountCreated}
|
|
trigger={
|
|
<Card className="cursor-pointer opacity-50 transition-opacity duration-200 hover:opacity-100">
|
|
<CardContent className="flex h-full items-center justify-center">
|
|
<div className="flex flex-row items-center justify-center gap-1">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
{__('Create Account')}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
{accounts.length === 0 && !isLoading && (
|
|
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
|
|
{__(
|
|
'No accounts found. Add your first account in Settings.',
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</AppSidebarLayout>
|
|
);
|
|
}
|