import { useEffect, useMemo, useState, useCallback } from 'react'; import { type Category, type Transaction, type BalancePoint, getMe, deleteTransaction, getCategories, getTransactions, createTransaction, updateTransaction, getBalanceSeries } from '../api'; import AccountPage from './AccountPage'; import AppearancePage from './AppearancePage'; import BalanceChart from './BalanceChart'; import ManualManagement from './ManualManagement'; import CategoryPieChart from './CategoryPieChart'; import MockBankModal, { type MockGenerationOptions } from './MockBankModal'; import { BACKEND_URL } from '../config'; function formatAmount(n: number) { return new Intl.NumberFormat(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n); } //https://unirateapi.com/ // Define the structure for the rate data we care about type RateData = { currencyCode: string; rate: number; }; // The currencies you want to display const TARGET_CURRENCIES = ['EUR', 'USD', 'NOK']; function CurrencyRates() { const [rates, setRates] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { async function fetchRates() { setLoading(true); setError(null); try { const base = BACKEND_URL.replace(/\/$/, ''); const url = `${base}/exchange-rates?symbols=${TARGET_CURRENCIES.join(',')}`; const token = localStorage.getItem('token'); const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : undefined, credentials: 'include', }); if (!res.ok) { const text = await res.text(); throw new Error(text || `Failed to load rates (${res.status})`); } const data: RateData[] = await res.json(); setRates(data); } catch (err: any) { setError(err.message || 'Could not load rates'); } finally { setLoading(false); } } fetchRates(); }, []); // Runs once on component mount return ( // This component will push itself to the bottom of the sidebar

Rates (vs CZK)

{loading &&
Loading...
} {error &&
{error}
} {!loading && !error && (
    {rates.length > 0 ? rates.map(rate => (
  • {rate.currencyCode} {rate.rate.toFixed(3)}
  • )) :
  • No rates found.
  • }
)} Exchange Rates By UniRateAPI
); } export default function Dashboard({ onLogout }: { onLogout: () => void }) { const [current, setCurrent] = useState<'home' | 'manual' | 'account' | 'appearance'>('home'); const [transactions, setTransactions] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isMockModalOpen, setMockModalOpen] = useState(false); const [isGenerating, setIsGenerating] = useState(false); // Current user and CSAS connection status const [csasConnected, setCsasConnected] = useState(false); useEffect(() => { (async () => { try { const u = await getMe(); // Determine CSAS connection validity const csas = (u as any)?.config?.csas; let obj: any = null; if (csas) { if (typeof csas === 'string') { try { obj = JSON.parse(csas); } catch {} } else if (typeof csas === 'object') { obj = csas; } } let exp: number | null = null; const raw = obj?.expires_at; if (typeof raw === 'number') { exp = raw; } else if (typeof raw === 'string') { const asNum = Number(raw); if (!Number.isNaN(asNum)) { exp = asNum; } else { const ms = Date.parse(raw); if (!Number.isNaN(ms)) exp = Math.floor(ms / 1000); } } if (exp && exp > Math.floor(Date.now() / 1000)) { setCsasConnected(true); } else { setCsasConnected(false); } } catch (e) { // ignore, user may not be loaded; keep button enabled } })(); }, []); // Start CSAS (George) OAuth after login async function startOauthCsas() { const base = BACKEND_URL.replace(/\/$/, ''); const url = `${base}/auth/csas/authorize`; try { const token = localStorage.getItem('token'); const res = await fetch(url, { credentials: 'include', headers: token ? { Authorization: `Bearer ${token}` } : undefined, }); const data = await res.json(); if (data && typeof data.authorization_url === 'string') { window.location.assign(data.authorization_url); } else { alert('Cannot start CSAS OAuth.'); } } catch (e) { alert('Cannot start CSAS OAuth.'); } } // Filters const [minAmount, setMinAmount] = useState(''); const [maxAmount, setMaxAmount] = useState(''); const [filterCategoryId, setFilterCategoryId] = useState(''); const [searchText, setSearchText] = useState(''); // Date-range filter const [startDate, setStartDate] = useState(''); // YYYY-MM-DD const [endDate, setEndDate] = useState(''); // Pagination over filtered transactions (20 per page), 0 = latest (most recent) const pageSize = 20; const [page, setPage] = useState(0); // Balance chart series for current date filter const [balanceSeries, setBalanceSeries] = useState([]); // Manual forms moved to ManualManagement page // Inline edit state for transaction editing const [editingTxId, setEditingTxId] = useState(null); const [editingCategoryIds, setEditingCategoryIds] = useState([]); const [editingAmount, setEditingAmount] = useState(''); const [editingDescription, setEditingDescription] = useState(''); const [editingDate, setEditingDate] = useState(''); // YYYY-MM-DD // Sidebar toggle for mobile const [sidebarOpen, setSidebarOpen] = useState(false); // Multi-select state for transactions and bulk category assignment const [selectedTxIds, setSelectedTxIds] = useState([]); const [bulkCategoryIds, setBulkCategoryIds] = useState([]); const toggleSelectTx = useCallback((id: number) => { setSelectedTxIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); }, []); const clearSelection = useCallback(() => setSelectedTxIds([]), []); const selectAllVisible = useCallback((ids: number[]) => setSelectedTxIds(ids), []); async function loadAll() { setLoading(true); setError(null); try { const [txs, cats, series] = await Promise.all([ getTransactions(startDate || undefined, endDate || undefined), getCategories(), getBalanceSeries(startDate || undefined, endDate || undefined), ]); setTransactions(txs); setCategories(cats); setBalanceSeries(series); // reset paging to most recent setPage(0); } catch (err: any) { setError(err?.message || 'Failed to load data'); } finally { setLoading(false); } } async function handleGenerateMockTransactions(options: MockGenerationOptions) { setIsGenerating(true); setMockModalOpen(false); try { const base = BACKEND_URL.replace(/\/$/, ''); const url = `${base}/mock-bank/generate`; const token = localStorage.getItem('token'); const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, credentials: 'include', body: JSON.stringify(options), }); if (!res.ok) { const text = await res.text(); throw new Error(text || `Failed to generate mock transactions (${res.status})`); } const generated: Array<{ amount: number; date: string; category_ids: number[]; description?: string | null }> = await res.json(); const newTransactions: Transaction[] = []; for (const g of generated) { try { const created = await createTransaction({ amount: g.amount, date: g.date, category_ids: g.category_ids || [], description: g.description || undefined, }); newTransactions.push(created); } catch (err) { console.error('Failed to create mock transaction:', err); // continue creating others } } alert(`${newTransactions.length} mock transactions were successfully generated!`); } catch (err: any) { console.error(err); alert(err?.message || 'Failed to generate mock transactions'); } finally { setIsGenerating(false); await loadAll(); } } useEffect(() => { loadAll(); clearSelection(); }, [startDate, endDate]); const filtered = useMemo(() => { let arr = [...transactions]; const min = minAmount !== '' ? Number(minAmount) : undefined; const max = maxAmount !== '' ? Number(maxAmount) : undefined; if (min !== undefined) arr = arr.filter(t => t.amount >= min); if (max !== undefined) arr = arr.filter(t => t.amount <= max); if (filterCategoryId !== '') arr = arr.filter(t => t.category_ids.includes(filterCategoryId as number)); if (searchText.trim()) arr = arr.filter(t => (t.description || '').toLowerCase().includes(searchText.toLowerCase())); return arr; }, [transactions, minAmount, maxAmount, filterCategoryId, searchText]); const sortedDesc = useMemo(() => { return [...filtered].sort((a, b) => { const ad = (a.date || '') > (b.date || '') ? 1 : (a.date || '') < (b.date || '') ? -1 : 0; if (ad !== 0) return -ad; // date desc return b.id - a.id; // fallback id desc }); }, [filtered]); const totalPages = Math.ceil(sortedDesc.length / pageSize); const pageStart = page * pageSize; const pageEnd = pageStart + pageSize; const visible = sortedDesc.slice(pageStart, pageEnd); // Reset selection when page or filters impacting visible set change useEffect(() => { clearSelection(); }, [page, minAmount, maxAmount, filterCategoryId, searchText]); function categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; } function beginEditTransaction(t: Transaction) { setEditingTxId(t.id); setEditingCategoryIds([...(t.category_ids || [])]); setEditingAmount(String(t.amount)); setEditingDescription(t.description || ''); setEditingDate(t.date || ''); } function cancelEditTransaction() { setEditingTxId(null); setEditingCategoryIds([]); setEditingAmount(''); setEditingDescription(''); setEditingDate(''); } async function saveEditTransaction() { if (editingTxId == null) return; const amountNum = Number(editingAmount); if (Number.isNaN(amountNum)) { alert('Amount must be a number.'); return; } try { const updated = await updateTransaction(editingTxId, { amount: amountNum, description: editingDescription, date: editingDate || undefined, category_ids: editingCategoryIds, }); setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p))); // Optionally refresh balance series to reflect changes immediately try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {} cancelEditTransaction(); } catch (err: any) { alert(err?.message || 'Failed to update transaction'); } } async function handleDeleteTransaction(id: number) { if (!confirm('Delete this transaction? This cannot be undone.')) return; try { await deleteTransaction(id); setTransactions(prev => prev.filter(t => t.id !== id)); try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {} } catch (err: any) { alert(err?.message || 'Failed to delete transaction'); } } return (

{current === 'home' ? 'Dashboard' : current === 'manual' ? 'Manual management' : current === 'account' ? 'Account' : 'Appearance'}

Signed in
{current === 'home' && ( <>

Bank connections

Connect your CSAS (George) account.

Generate data from a mock bank.

Filters

setStartDate(e.target.value)} /> setEndDate(e.target.value)} /> setMinAmount(e.target.value)} /> setMaxAmount(e.target.value)} /> setSearchText(e.target.value)} />

Balance over time

{loading ? (
Loading…
) : error ? (
{error}
) : ( )}
{/* 3. Add the new section for the Category Pie Chart */}
{loading ? (
Loading…
) : error ? (
{error}
) : ( // Pass the filtered transactions to see the breakdown for the current view )}

Transactions

{loading ? (
Loading…
) : error ? (
{error}
) : filtered.length === 0 ? (
No transactions
) : ( <>
Showing {visible.length} of {filtered.length} (page {Math.min(page + 1, Math.max(1, totalPages))}/{Math.max(1, totalPages)})
{selectedTxIds.length > 0 && ( <> Selected: {selectedTxIds.length} )}
{visible.map(t => ( {/* Date cell */} {/* Amount cell */} {/* Description cell */} {/* Categories cell */} {/* Actions cell */} ))}
0 && visible.every(v => selectedTxIds.includes(v.id))} onChange={(e) => { if (e.currentTarget.checked) { selectAllVisible(visible.map(v => v.id)); } else { // remove only currently visible from selection setSelectedTxIds(prev => prev.filter(id => !visible.some(v => v.id === id))); } }} /> Date Amount Description Categories Actions
toggleSelectTx(t.id)} /> {editingTxId === t.id ? ( setEditingDate(e.target.value)} /> ) : ( t.date || '' )} {editingTxId === t.id ? ( setEditingAmount(e.target.value)} style={{ textAlign: 'right' }} /> ) : ( formatAmount(t.amount) )} {editingTxId === t.id ? ( setEditingDescription(e.target.value)} /> ) : ( t.description || '' )} {editingTxId === t.id ? (
) : ( {t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'} )}
{editingTxId === t.id ? (
) : (
)}
)}
)} {current === 'account' && ( // lazy import avoided for simplicity )} {current === 'manual' && ( setTransactions(prev => [t, ...prev])} onCategoryCreated={(c) => setCategories(prev => [...prev, c])} /> )} {current === 'appearance' && ( )}
setMockModalOpen(false)} onGenerate={handleGenerateMockTransactions} /> {sidebarOpen &&
setSidebarOpen(false)} />}
); }