whisper-money/resources/js/pages/dashboard.tsx

371 lines
13 KiB
TypeScript

import {
reorder,
updateVisibility,
} from '@/actions/App/Http/Controllers/AccountController';
import { AccountBalanceCard } from '@/components/dashboard/account-balance-card';
import { AccountsManagerDialog } from '@/components/dashboard/accounts-manager-dialog';
import { CashflowSummaryCard } from '@/components/dashboard/cashflow-summary-card';
import { NetWorthChart as NetWorthChartComponent } from '@/components/dashboard/net-worth-chart';
import { TopCategoriesCard } from '@/components/dashboard/top-categories-card';
import HeadingSmall from '@/components/heading-small';
import { IntegrationRequestsDrawer } from '@/components/integration-requests/integration-requests-drawer';
import { Button } from '@/components/ui/button';
import UnlockMessageDialog from '@/components/unlock-message-dialog';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import {
type AccountWithMetrics,
type NetWorthEvolutionData,
deriveAccountMetrics,
} from '@/hooks/use-dashboard-data';
import { useLocale } from '@/hooks/use-locale';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { dashboard } from '@/routes';
import { BreadcrumbItem, SharedData } from '@/types';
import { Category } from '@/types/category';
import { __ } from '@/utils/i18n';
import { Deferred, Head, router, usePage } from '@inertiajs/react';
import { Pencil } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
interface CashflowSummary {
income: number;
expense: number;
net: number;
savings_rate: number;
}
interface DashboardProps extends SharedData {
showEncryptionPrompt: boolean;
netWorthEvolution?: NetWorthEvolutionData;
topCategories?: Array<{
category: Category | null;
category_id?: string | null;
amount: number;
previous_amount: number;
total_amount: number;
has_children?: boolean;
is_direct?: boolean;
}>;
cashflowSummary?: {
current: CashflowSummary;
previous: CashflowSummary;
};
openIntegrationRequests?: boolean;
}
export default function Dashboard() {
const { props } = usePage<DashboardProps>();
const locale = useLocale();
const { isKeySet, encryptedMessageData, fetchEncryptedMessage } =
useEncryptionKey();
const [showUnlockDialog, setShowUnlockDialog] = useState(false);
const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(
!!props.openIntegrationRequests,
);
const netWorthEvolution = useMemo(
() =>
props.netWorthEvolution ?? {
data: [],
accounts: {},
currency_code: 'USD',
},
[props.netWorthEvolution],
);
const accountMetrics = useMemo(
() => deriveAccountMetrics(netWorthEvolution, locale),
[netWorthEvolution, locale],
);
// Identify linked loan account IDs and filter them out
const linkedLoanAccountIds = useMemo(() => {
const ids = new Set<string>();
accountMetrics.forEach((a) => {
if (a.type === 'real_estate' && a.linked_loan_account_id) {
ids.add(a.linked_loan_account_id);
}
});
return ids;
}, [accountMetrics]);
const manageableAccounts = useMemo(
() => accountMetrics.filter((a) => !linkedLoanAccountIds.has(a.id)),
[accountMetrics, linkedLoanAccountIds],
);
const [editOpen, setEditOpen] = useState(false);
// 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 manageableAccounts;
}
const byId = new Map(manageableAccounts.map((a) => [a.id, a]));
const ordered = order
.map((id) => byId.get(id))
.filter((a) => a !== undefined);
const rest = manageableAccounts.filter((a) => !order.includes(a.id));
return [...ordered, ...rest];
}, [manageableAccounts, order]);
// Optimistic visibility overrides keyed by account id; falls back to the
// server flag until the next full reload.
const [hiddenOverrides, setHiddenOverrides] = useState<
Record<string, boolean>
>({});
const isHidden = useCallback(
(account: AccountWithMetrics) =>
hiddenOverrides[account.id] ?? account.hidden_on_dashboard,
[hiddenOverrides],
);
const gridAccounts = useMemo(
() => orderedAccounts.filter((a) => !isHidden(a)),
[orderedAccounts, isHidden],
);
const handleReorder = useCallback((ids: string[]) => {
setOrder(ids);
// Persist only; keep the deferred netWorthEvolution prop in place by
// requesting an unrelated cheap prop so it isn't refetched (skeleton).
router.patch(
reorder.url(),
{ ids },
{
preserveScroll: true,
preserveState: true,
only: ['showEncryptionPrompt'],
},
);
}, []);
const handleToggleVisibility = useCallback(
(id: string, hidden: boolean) => {
setHiddenOverrides((prev) => ({ ...prev, [id]: hidden }));
router.patch(
updateVisibility.url(id),
{ hidden },
{
preserveScroll: true,
preserveState: true,
only: ['showEncryptionPrompt'],
},
);
},
[],
);
// Build linked loan metrics map keyed by real estate account ID
const linkedLoanMetricsMap = useMemo(() => {
const map: Record<
string,
{
currentBalance: number;
previousBalance: number;
diff: number;
history: Array<{ date: string; value: number }>;
loanAccount?: {
name: string;
bank: { name: string; logo: string | null } | null;
};
}
> = {};
accountMetrics.forEach((a) => {
if (a.type === 'real_estate' && a.linked_loan_account_id) {
const loan = accountMetrics.find(
(l) => l.id === a.linked_loan_account_id,
);
if (loan) {
map[a.id] = {
currentBalance: Math.abs(loan.currentBalance),
previousBalance: Math.abs(loan.previousBalance),
diff: loan.diff,
history: loan.history.map((h) => ({
date: h.date,
value: Math.abs(h.value),
})),
loanAccount: {
name: loan.name,
bank: loan.bank,
},
};
}
}
});
return map;
}, [accountMetrics]);
const topCategories = props.topCategories ?? [];
const refetch = useCallback(() => {
router.reload({
only: ['netWorthEvolution', 'topCategories', 'cashflowSummary'],
});
}, []);
const breadcrumbs: BreadcrumbItem[] = [
{
title: __('Dashboard'),
href: dashboard().url,
},
];
useEffect(() => {
// Fetch encrypted message data if not already loaded
if (!encryptedMessageData) {
fetchEncryptedMessage();
}
// Auto-open the unlock dialog only if:
// 1. User just logged in (showEncryptionPrompt is true)
// 2. Encryption key is not set
// 3. Encrypted message data is available
if (props.showEncryptionPrompt && !isKeySet && encryptedMessageData) {
setShowUnlockDialog(true);
}
}, [
isKeySet,
encryptedMessageData,
fetchEncryptedMessage,
props.showEncryptionPrompt,
]);
function handleUnlock() {
setShowUnlockDialog(false);
}
return (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title={__('Dashboard')} />
<IntegrationRequestsDrawer
open={integrationDrawerOpen}
onOpenChange={(open) => {
setIntegrationDrawerOpen(open);
if (!open) {
router.visit(dashboard().url, { preserveScroll: true });
}
}}
/>
{encryptedMessageData && (
<UnlockMessageDialog
open={showUnlockDialog}
onOpenChange={setShowUnlockDialog}
onUnlock={handleUnlock}
encryptedContent={encryptedMessageData.encrypted_content}
iv={encryptedMessageData.iv}
salt={encryptedMessageData.salt}
/>
)}
<div className="space-y-6 p-6">
<HeadingSmall
title={__('Dashboard')}
description={__('Overview of your financial health')}
/>
<Deferred
data="netWorthEvolution"
fallback={
<>
<NetWorthChartComponent
data={{
data: [],
accounts: {},
currency_code: 'USD',
}}
loading={true}
/>
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<AccountBalanceCard
key={i}
// @ts-expect-error - mock data for loading state
account={{}}
loading={true}
/>
))}
</div>
</>
}
>
<NetWorthChartComponent data={netWorthEvolution} />
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
{__('Accounts')}
</h2>
{orderedAccounts.length > 0 && (
<Button
variant="ghost"
size="icon-sm"
onClick={() => setEditOpen(true)}
aria-label={__('Edit accounts')}
>
<Pencil className="size-4" />
</Button>
)}
</div>
{gridAccounts.length === 0 ? (
<p className="rounded-lg border border-dashed border-border py-8 text-center text-sm text-muted-foreground">
{__('This space has no accounts yet.')}
</p>
) : (
<div className="grid gap-4 md:grid-cols-2">
{gridAccounts.map((account) => (
<AccountBalanceCard
key={account.id}
account={account}
onBalanceUpdated={refetch}
linkedLoanMetrics={
linkedLoanMetricsMap[account.id]
}
displayCurrencyCode={
netWorthEvolution.currency_code
}
/>
))}
</div>
)}
<AccountsManagerDialog
open={editOpen}
onOpenChange={setEditOpen}
accounts={orderedAccounts}
isHidden={isHidden}
onReorder={handleReorder}
onToggleVisibility={handleToggleVisibility}
/>
</Deferred>
<div className="flex flex-col gap-6">
<Deferred
data="topCategories"
fallback={
<TopCategoriesCard categories={[]} loading={true} />
}
>
<TopCategoriesCard categories={topCategories} />
</Deferred>
{props.features.cashflow && (
<Deferred
data="cashflowSummary"
fallback={<CashflowSummaryCard loading={true} />}
>
<CashflowSummaryCard
data={props.cashflowSummary ?? null}
/>
</Deferred>
)}
</div>
</div>
</AppSidebarLayout>
);
}