Files
WealthySmart/frontend/src/pages/Dashboard.tsx
Carlos Escalante 45166f9d20
All checks were successful
Deploy to VPS / deploy (push) Successful in 14s
Add privacy mode toggle to blur sensitive financial amounts
Eye/EyeOff icon next to theme toggle. Persists in localStorage.
Applies CSS blur to all elements marked with data-sensitive attribute
across Dashboard, Budget, Pensions, Salarios, and Transactions pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:43:50 -06:00

377 lines
14 KiB
TypeScript

import { useEffect, useState, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowRight,
TrendingUp,
TrendingDown,
RefreshCw,
CreditCard,
Pencil,
Check,
X,
BellRing,
Landmark,
} from 'lucide-react';
import api, { type Account, type Transaction } from '../api';
import { useSettings } from '@/hooks/useSettings';
import { formatAmount, formatDate, formatLocalDatetime } from '@/lib/format';
import DashboardSection from '@/components/DashboardSection';
import SectionConfigDialog from '@/components/SectionConfigDialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
// --- Section definitions ---
interface SectionDef {
filterFn: (a: Account) => boolean;
totalCurrency: string; // empty string = no total
}
const SECTION_DEFS: Record<string, SectionDef> = {
crc_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'CRC', totalCurrency: 'CRC' },
usd_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'USD', totalCurrency: 'USD' },
pension: { filterFn: (a) => a.account_type === 'PENSION', totalCurrency: 'CRC' },
savings: { filterFn: (a) => a.account_type === 'SAVINGS', totalCurrency: 'CRC' },
liabilities: { filterFn: (a) => a.account_type === 'LIABILITY', totalCurrency: '' },
crypto: { filterFn: (a) => a.account_type === 'CRYPTO', totalCurrency: '' },
};
const BANK_ORDER = ['BAC', 'BCR', 'DAVIVIENDA'];
// --- AccountRow ---
interface AccountRowProps {
account: Account;
editingId: number | null;
editValue: string;
setEditValue: (v: string) => void;
startEditing: (a: Account) => void;
saveBalance: (id: number) => void;
cancelEditing: () => void;
}
function AccountRow({
account,
editingId,
editValue,
setEditValue,
startEditing,
saveBalance,
cancelEditing,
}: AccountRowProps) {
const isLiability = account.account_type === 'LIABILITY';
const isCrypto = account.account_type === 'CRYPTO';
const label = isCrypto ? account.label : (account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank);
const isEditing = editingId === account.id;
return (
<div className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/30 transition-colors group">
<span className="text-sm font-medium text-muted-foreground">{label}</span>
{isEditing ? (
<div className="flex items-center gap-2">
<Input
type="number"
step="0.01"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') saveBalance(account.id);
if (e.key === 'Escape') cancelEditing();
}}
autoFocus
className="text-sm font-bold font-mono tracking-tight h-auto py-1 w-40"
/>
<Button variant="ghost" size="icon-xs" onClick={() => saveBalance(account.id)} title="Save" aria-label="Save balance">
<Check className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" onClick={cancelEditing} title="Cancel" aria-label="Cancel editing">
<X className="w-3.5 h-3.5" />
</Button>
</div>
) : (
<div className="flex items-center gap-1.5">
<span data-sensitive className={cn('text-lg font-bold font-mono tracking-tight', isLiability && 'text-destructive')}>
{formatAmount(account.balance, account.currency)}
</span>
<button
onClick={() => startEditing(account)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
title="Edit balance"
aria-label="Edit balance"
>
<Pencil className="w-3.5 h-3.5" />
</button>
{isLiability && account.next_payment != null && (
<span data-sensitive className="text-xs font-mono text-destructive/60 ml-2">
Next: {formatAmount(account.next_payment, account.currency)}
</span>
)}
</div>
)}
</div>
);
}
// --- Dashboard ---
export default function Dashboard() {
const [accounts, setAccounts] = useState<Account[]>([]);
const [recent, setRecent] = useState<Transaction[]>([]);
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<number | null>(null);
const [editValue, setEditValue] = useState('');
const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null);
const [configSection, setConfigSection] = useState<string | null>(null);
const [testingPush, setTestingPush] = useState(false);
const { settings, patchSection } = useSettings();
const fetchData = async () => {
setLoading(true);
try {
const [accRes, txRes] = await Promise.all([
api.get('/accounts/'),
api.get('/transactions/recent?limit=5'),
]);
setAccounts(accRes.data);
setRecent(txRes.data);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
api.get('/exchange-rate/').then((r) => setExchangeRate(r.data)).catch(() => {});
};
useEffect(() => { fetchData(); }, []);
const startEditing = (account: Account) => {
setEditingId(account.id);
setEditValue(String(account.balance));
};
const cancelEditing = () => { setEditingId(null); setEditValue(''); };
const saveBalance = async (accountId: number) => {
const parsed = parseFloat(editValue);
if (isNaN(parsed)) return cancelEditing();
try {
await api.patch(`/accounts/${accountId}`, { balance: parsed });
setEditingId(null);
setEditValue('');
fetchData();
} catch (e) { console.error(e); }
};
const rowProps = { editingId, editValue, setEditValue, startEditing, saveBalance, cancelEditing };
// Sort sections by order, filter by visible
const sortedSections = useMemo(() => {
const sections = settings.dashboard.sections;
return Object.entries(sections)
.filter(([, s]) => s.visible)
.sort(([, a], [, b]) => a.order - b.order);
}, [settings]);
// Net worth calculation
const netWorthBreakdown = useMemo(() => {
if (accounts.length === 0) return null;
let assets = 0;
let liabilities = 0;
for (const a of accounts) {
const isLiability = a.account_type === 'LIABILITY';
let crcValue = 0;
if (a.currency === 'USD') {
crcValue = Math.abs(a.balance) * (exchangeRate?.sell_rate ?? 0);
} else if (a.currency === 'CRC') {
crcValue = Math.abs(a.balance);
}
if (isLiability) {
liabilities += crcValue;
} else {
assets += crcValue;
}
}
return { assets, liabilities, net: assets - liabilities };
}, [accounts, exchangeRate]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold font-heading">Dashboard</h1>
<p className="text-sm text-muted-foreground mt-1">Financial overview</p>
</div>
<Button variant="ghost" size="icon" onClick={fetchData} title="Refresh" aria-label="Refresh">
<RefreshCw className={cn('w-4 h-4', loading && 'animate-spin')} />
</Button>
</div>
{/* Net Worth */}
{netWorthBreakdown != null && (
<Card>
<CardContent className="px-4 py-3">
<div className="flex items-center justify-between text-sm font-mono text-muted-foreground">
<span>Net <span data-sensitive className="text-foreground font-bold">{netWorthBreakdown.net < 0 ? '-' : ''}{formatAmount(netWorthBreakdown.net, 'CRC')}</span></span>
<div className="flex gap-4">
<span>Assets <span data-sensitive className="text-foreground">{formatAmount(netWorthBreakdown.assets, 'CRC')}</span></span>
<span>Liabilities <span data-sensitive className="text-foreground">{formatAmount(netWorthBreakdown.liabilities, 'CRC')}</span></span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Account sections */}
{sortedSections.map(([sectionId, sectionSettings]) => {
const def = SECTION_DEFS[sectionId];
if (!def) return null;
let accts = accounts.filter(def.filterFn);
if (accts.length === 0) return null;
// Sort bank accounts by bank order
if (sectionId === 'crc_accounts' || sectionId === 'usd_accounts') {
accts = accts.sort((a, b) => BANK_ORDER.indexOf(a.bank) - BANK_ORDER.indexOf(b.bank));
}
const total = accts.reduce((s, a) => s + a.balance, 0);
return (
<DashboardSection
key={sectionId}
sectionId={sectionId}
settings={sectionSettings}
total={def.totalCurrency ? total : undefined}
totalCurrency={def.totalCurrency || undefined}
onToggleExpanded={(expanded) => patchSection(sectionId, { expanded })}
onOpenConfig={() => setConfigSection(sectionId)}
>
{accts.map((a) => (
<AccountRow key={a.id} account={a} {...rowProps} />
))}
</DashboardSection>
);
})}
{/* Exchange rate */}
{exchangeRate && (
<Card>
<CardContent className="p-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">USD/CRC Exchange Rate</span>
<div className="flex items-baseline gap-3 mt-1">
<span data-sensitive className="text-lg font-bold font-mono">Buy: {exchangeRate.buy_rate.toFixed(2)}</span>
<span data-sensitive className="text-lg font-bold font-mono text-muted-foreground">Sell: {exchangeRate.sell_rate.toFixed(2)}</span>
</div>
</CardContent>
</Card>
)}
{/* Recent transactions */}
<Card>
<CardHeader className="border-b flex-row items-center justify-between">
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4 text-muted-foreground" />
<CardTitle className="text-sm">Recent Charges</CardTitle>
</div>
<Link
to="/transactions"
className="flex items-center gap-1 text-xs font-medium text-primary hover:text-primary/80 transition-colors"
>
View all
<ArrowRight className="w-3 h-3" />
</Link>
</CardHeader>
<CardContent className="p-0">
{recent.length === 0 && !loading ? (
<div className="px-5 py-12 text-center text-muted-foreground text-sm">No transactions yet. Add your first one!</div>
) : (
<div className="divide-y divide-border">
{recent.map((tx) => (
<div key={tx.id} className="flex items-center justify-between px-5 py-3.5 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className={cn(
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
tx.transaction_type === 'COMPRA' ? 'bg-destructive/10 text-destructive' : 'bg-primary/10 text-primary'
)}>
{tx.transaction_type === 'DEPOSITO' ? <Landmark className="w-4 h-4" /> : tx.transaction_type === 'DEVOLUCION' ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{tx.merchant}</p>
<p className="text-xs text-muted-foreground">
{formatDate(tx.date)}
{tx.category && <span className="ml-2 text-muted-foreground/60">{tx.category.name}</span>}
</p>
</div>
</div>
<span data-sensitive className={cn(
'font-mono text-sm font-medium shrink-0 ml-4',
tx.transaction_type !== 'COMPRA' && 'text-primary'
)}>
{tx.transaction_type === 'COMPRA' ? '-' : '+'}{formatAmount(tx.amount, tx.currency)}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Test push notification */}
<Card className="border-dashed border-yellow-500/50">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-sm font-medium">Test Push Notification</p>
<p className="text-xs text-muted-foreground">Creates a mock transaction to trigger a push notification</p>
</div>
<Button
variant="outline"
size="sm"
disabled={testingPush}
onClick={async () => {
setTestingPush(true);
try {
const merchants = ['Walmart', 'AutoMercado', 'Uber Eats', 'Amazon', 'PriceSmart'];
const amounts = [4500, 12350, 8900, 25000, 67800];
const i = Math.floor(Math.random() * merchants.length);
await api.post('/transactions/', {
merchant: merchants[i],
amount: amounts[i],
currency: 'CRC',
date: formatLocalDatetime(new Date()),
bank: 'BAC',
source: 'CREDIT_CARD',
transaction_type: 'COMPRA',
reference: `test-push-${Date.now()}`,
notes: '[TEST] Push notification test — safe to delete',
});
fetchData();
} catch (e) {
console.error('Test push failed:', e);
} finally {
setTestingPush(false);
}
}}
>
<BellRing className="w-4 h-4 mr-2" />
{testingPush ? 'Sending...' : 'Send test'}
</Button>
</CardContent>
</Card>
{/* Section config dialog */}
{configSection && settings.dashboard.sections[configSection] && (
<SectionConfigDialog
sectionId={configSection}
settings={settings.dashboard.sections[configSection]}
open={!!configSection}
onOpenChange={(open) => { if (!open) setConfigSection(null); }}
onSave={(id, partial) => patchSection(id, partial)}
/>
)}
</div>
);
}