mirror of
https://github.com/escalante29/WealthySmart.git
synced 2026-05-19 10:28:48 +02:00
All checks were successful
Deploy to VPS / deploy (push) Successful in 21s
New TransactionType.DEPOSITO for salary deposits from n8n/Gmail flow. New /salarios endpoint with summary. New top-level Salarios page with DataTable and summary cards. Push notifications link to /salarios for deposits. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
377 lines
14 KiB
TypeScript
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 } 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 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 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 className="text-foreground font-bold">{netWorthBreakdown.net < 0 ? '-' : ''}{formatAmount(netWorthBreakdown.net, 'CRC')}</span></span>
|
|
<div className="flex gap-4">
|
|
<span>Assets <span className="text-foreground">{formatAmount(netWorthBreakdown.assets, 'CRC')}</span></span>
|
|
<span>Liabilities <span 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 className="text-lg font-bold font-mono">Buy: ₡{exchangeRate.buy_rate.toFixed(2)}</span>
|
|
<span 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 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: new Date().toISOString(),
|
|
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>
|
|
);
|
|
}
|