import { useEffect, useMemo, useState } from 'react'; import { type Category, type Transaction, type BalancePoint, 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); } // Add this new component to your Dashboard.tsx file, above the Dashboard component // Define the structure for the rate data we care about type CnbRate = { currencyCode: string; rate: number; }; // The part of the API response structure we need type CnbApiResponse = { rates: Array<{ amount: number; 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); // Get today's date in YYYY-MM-DD format for the API const today = new Date().toISOString().split('T')[0]; const CNB_API_URL = `https://api.cnb.cz/cnbapi/exrates/daily?date=${today}&lang=EN`; try { const res = await fetch(CNB_API_URL); if (!res.ok) { // This can happen on weekends/holidays or if rates aren't posted yet throw new Error(`Rates unavailable (Status: ${res.status})`); } const data: CnbApiResponse = await res.json(); if (!data.rates) { throw new Error("Invalid API response"); } const filteredRates = data.rates .filter(rate => TARGET_CURRENCIES.includes(rate.currencyCode)) .map(rate => ({ currencyCode: rate.currencyCode, // Handle 'amount' field (e.g., JPY is per 100) rate: rate.rate / rate.amount })); setRates(filteredRates); } 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.
  • }
)}
); } 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); // 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 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); const { count, minAmount, maxAmount, startDate, endDate, categoryIds } = options; const newTransactions: Transaction[] = []; const startDateTime = new Date(startDate).getTime(); const endDateTime = new Date(endDate).getTime(); for (let i = 0; i < count; i++) { // Generate random data based on user input const amount = parseFloat((Math.random() * (maxAmount - minAmount) + minAmount).toFixed(2)); const randomTime = Math.random() * (endDateTime - startDateTime) + startDateTime; const date = new Date(randomTime); const dateString = date.toISOString().split('T')[0]; const randomCategory = categoryIds.length > 0 ? [categoryIds[Math.floor(Math.random() * categoryIds.length)]] : []; const payload = { amount, date: dateString, category_ids: randomCategory, }; try { const created = await createTransaction(payload); newTransactions.push(created); } catch (err) { console.error("Failed to create mock transaction:", err); alert('An error occurred while generating transactions. Check the console.'); break; } } setIsGenerating(false); alert(`${newTransactions.length} mock transactions were successfully generated!`); await loadAll(); } useEffect(() => { loadAll(); }, [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); 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)})
{visible.map(t => ( {/* Date cell */} {/* Amount cell */} {/* Description cell */} {/* Categories cell */} {/* Actions cell */} ))}
Date Amount Description Categories Actions
{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} />
); }