mirror of
https://github.com/dat515-2025/Group-8.git
synced 2026-03-22 06:57:47 +01:00
680 lines
29 KiB
TypeScript
680 lines
29 KiB
TypeScript
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<RateData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(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
|
|
<div
|
|
className="currency-rates"
|
|
style={{
|
|
padding: '0 1.5rem',
|
|
marginTop: 'auto', // Pushes to bottom
|
|
paddingBottom: '1.5rem' // Adds some spacing at the end
|
|
}}
|
|
>
|
|
<h4 style={{
|
|
margin: '1.5rem 0 0.75rem 0',
|
|
color: '#8a91b4', // Muted color to match dark sidebar
|
|
fontWeight: 500,
|
|
fontSize: '0.9em',
|
|
textTransform: 'uppercase',
|
|
}}>
|
|
Rates (vs CZK)
|
|
</h4>
|
|
{loading && <div style={{ fontSize: '0.9em', color: '#ccc' }}>Loading...</div>}
|
|
{error && <div style={{ fontSize: '0.9em', color: 'crimson' }}>{error}</div>}
|
|
{!loading && !error && (
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: '0.9em', color: '#fff' }}>
|
|
{rates.length > 0 ? rates.map(rate => (
|
|
<li key={rate.currencyCode} style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
|
|
<strong>{rate.currencyCode}</strong>
|
|
<span>{rate.rate.toFixed(3)}</span>
|
|
</li>
|
|
)) : <li style={{color: '#8a91b4'}}>No rates found.</li>}
|
|
</ul>
|
|
)}
|
|
|
|
<a
|
|
href="https://unirateapi.com"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={{
|
|
display: 'block',
|
|
marginTop: '1rem',
|
|
fontSize: '0.8em',
|
|
color: '#8a91b4', // Muted color
|
|
textDecoration: 'none'
|
|
}}
|
|
>
|
|
Exchange Rates By UniRateAPI
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|
const [current, setCurrent] = useState<'home' | 'manual' | 'account' | 'appearance'>('home');
|
|
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
|
const [categories, setCategories] = useState<Category[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(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<string>('');
|
|
const [maxAmount, setMaxAmount] = useState<string>('');
|
|
const [filterCategoryId, setFilterCategoryId] = useState<number | ''>('');
|
|
const [searchText, setSearchText] = useState('');
|
|
|
|
// Date-range filter
|
|
const [startDate, setStartDate] = useState<string>(''); // YYYY-MM-DD
|
|
const [endDate, setEndDate] = useState<string>('');
|
|
|
|
// Pagination over filtered transactions (20 per page), 0 = latest (most recent)
|
|
const pageSize = 20;
|
|
const [page, setPage] = useState<number>(0);
|
|
|
|
// Balance chart series for current date filter
|
|
const [balanceSeries, setBalanceSeries] = useState<BalancePoint[]>([]);
|
|
|
|
// Manual forms moved to ManualManagement page
|
|
|
|
// Inline edit state for transaction editing
|
|
const [editingTxId, setEditingTxId] = useState<number | null>(null);
|
|
const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]);
|
|
const [editingAmount, setEditingAmount] = useState<string>('');
|
|
const [editingDescription, setEditingDescription] = useState<string>('');
|
|
const [editingDate, setEditingDate] = useState<string>(''); // 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<number[]>([]);
|
|
const [bulkCategoryIds, setBulkCategoryIds] = useState<number[]>([]);
|
|
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 (
|
|
<div className={`app-layout ${sidebarOpen ? 'sidebar-open' : ''}`}>
|
|
<aside className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
|
|
<div>
|
|
<div className="logo">Finance Tracker</div>
|
|
<nav className="nav" onClick={() => setSidebarOpen(false)}>
|
|
<button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
|
|
<button className={current === 'manual' ? 'active' : ''} onClick={() => setCurrent('manual')}>Manual management</button>
|
|
<button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
|
|
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
|
|
</nav>
|
|
</div>
|
|
|
|
<CurrencyRates />
|
|
|
|
</aside>
|
|
<div className="content">
|
|
<div className="topbar">
|
|
<button
|
|
className="icon-btn hamburger"
|
|
aria-label="Open menu"
|
|
aria-expanded={sidebarOpen}
|
|
onClick={() => setSidebarOpen(true)}
|
|
>☰</button>
|
|
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : current === 'manual' ? 'Manual management' : current === 'account' ? 'Account' : 'Appearance'}</h2>
|
|
<div className="actions">
|
|
<span className="user muted">Signed in</span>
|
|
<button className="btn" onClick={onLogout}>Logout</button>
|
|
</div>
|
|
</div>
|
|
<main className="page space-y">
|
|
{current === 'home' && (
|
|
<>
|
|
<section className="card space-y">
|
|
<h3>Bank connections</h3>
|
|
<div className="connection-row">
|
|
<p className="muted" style={{ margin: 0 }}>Connect your CSAS (George) account.</p>
|
|
<button className="btn primary" onClick={startOauthCsas} disabled={csasConnected}>{csasConnected ? 'Successfully connected to CSAS' : 'Connect CSAS (George)'}</button>
|
|
</div>
|
|
<div className="connection-row">
|
|
<p className="muted" style={{ margin: 0 }}>Generate data from a mock bank.</p>
|
|
<button className="btn primary" onClick={() => setMockModalOpen(true)}>Connect Mock Bank</button>
|
|
</div>
|
|
</section>
|
|
|
|
|
|
|
|
<section className="card">
|
|
<h3>Filters</h3>
|
|
<div className="form-row" style={{ gap: 8, flexWrap: 'wrap' }}>
|
|
<input className="input" type="date" placeholder="Start date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
|
<input className="input" type="date" placeholder="End date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
|
<input className="input" type="number" step="0.01" placeholder="Min amount" value={minAmount} onChange={(e) => setMinAmount(e.target.value)} />
|
|
<input className="input" type="number" step="0.01" placeholder="Max amount" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} />
|
|
<select className="input" value={filterCategoryId} onChange={(e) => setFilterCategoryId(e.target.value ? Number(e.target.value) : '')}>
|
|
<option value="">All categories</option>
|
|
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
|
|
</select>
|
|
<input className="input" type="text" placeholder="Search in description" value={searchText} onChange={(e) => setSearchText(e.target.value)} />
|
|
</div>
|
|
</section>
|
|
|
|
<section className="card">
|
|
<h3>Balance over time</h3>
|
|
{loading ? (
|
|
<div>Loading…</div>
|
|
) : error ? (
|
|
<div style={{ color: 'crimson' }}>{error}</div>
|
|
) : (
|
|
<BalanceChart data={balanceSeries} />
|
|
)}
|
|
</section>
|
|
|
|
{/* 3. Add the new section for the Category Pie Chart */}
|
|
<section className="card">
|
|
{loading ? (
|
|
<div>Loading…</div>
|
|
) : error ? (
|
|
<div style={{ color: 'crimson' }}>{error}</div>
|
|
) : (
|
|
// Pass the filtered transactions to see the breakdown for the current view
|
|
<CategoryPieChart transactions={filtered} categories={categories} />
|
|
)}
|
|
</section>
|
|
|
|
<section className="card">
|
|
<h3>Transactions</h3>
|
|
{loading ? (
|
|
<div>Loading…</div>
|
|
) : error ? (
|
|
<div style={{ color: 'crimson' }}>{error}</div>
|
|
) : filtered.length === 0 ? (
|
|
<div>No transactions</div>
|
|
) : (
|
|
<>
|
|
<div className="table-controls">
|
|
<div className="muted">
|
|
Showing {visible.length} of {filtered.length} (page {Math.min(page + 1, Math.max(1, totalPages))}/{Math.max(1, totalPages)})
|
|
</div>
|
|
<div className="actions" style={{ gap: 8, alignItems: 'center' }}>
|
|
{selectedTxIds.length > 0 && (
|
|
<>
|
|
<span className="muted">Selected: {selectedTxIds.length}</span>
|
|
<select
|
|
className="input"
|
|
multiple
|
|
value={bulkCategoryIds.map(String)}
|
|
onChange={(e) => {
|
|
const ids = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value));
|
|
setBulkCategoryIds(ids);
|
|
}}
|
|
>
|
|
{categories.map(c => (
|
|
<option key={c.id} value={c.id}>{c.name}</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
className="btn primary"
|
|
onClick={async () => {
|
|
if (bulkCategoryIds.length === 0) {
|
|
alert('Pick at least one category to assign.');
|
|
return;
|
|
}
|
|
try {
|
|
// Apply selected categories to each selected transaction, replacing their categories
|
|
const updates = await Promise.allSettled(
|
|
selectedTxIds.map(id => updateTransaction(id, { category_ids: bulkCategoryIds }))
|
|
);
|
|
const fulfilled = updates.filter(u => u.status === 'fulfilled') as PromiseFulfilledResult<Transaction>[];
|
|
const updatedById = new Map<number, Transaction>(fulfilled.map(f => [f.value.id, f.value]));
|
|
setTransactions(prev => prev.map(t => updatedById.get(t.id) || t));
|
|
try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {}
|
|
if (fulfilled.length !== selectedTxIds.length) {
|
|
alert(`Assigned categories to ${fulfilled.length} of ${selectedTxIds.length} selected transactions. Some updates failed.`);
|
|
}
|
|
} catch (e: any) {
|
|
alert(e?.message || 'Failed to assign categories');
|
|
} finally {
|
|
clearSelection();
|
|
setBulkCategoryIds([]);
|
|
}
|
|
}}
|
|
>
|
|
Apply categories to selected
|
|
</button>
|
|
<button className="btn" onClick={clearSelection}>Clear selection</button>
|
|
</>
|
|
)}
|
|
<button className="btn primary" disabled={page <= 0} onClick={() => setPage(p => Math.max(0, p - 1))}>Previous</button>
|
|
<button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button>
|
|
</div>
|
|
</div>
|
|
<table className="table responsive">
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: 36 }}>
|
|
<input
|
|
type="checkbox"
|
|
aria-label="Select all on page"
|
|
checked={visible.length > 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)));
|
|
}
|
|
}}
|
|
/>
|
|
</th>
|
|
<th>Date</th>
|
|
<th style={{ textAlign: 'right' }}>Amount</th>
|
|
<th>Description</th>
|
|
<th>Categories</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visible.map(t => (
|
|
<tr key={t.id} style={{ backgroundColor: selectedTxIds.includes(t.id) ? 'rgba(88, 136, 255, 0.1)' : undefined }}>
|
|
<td>
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`Select transaction ${t.id}`}
|
|
checked={selectedTxIds.includes(t.id)}
|
|
onChange={() => toggleSelectTx(t.id)}
|
|
/>
|
|
</td>
|
|
{/* Date cell */}
|
|
<td data-label="Date">
|
|
{editingTxId === t.id ? (
|
|
<input
|
|
className="input"
|
|
type="date"
|
|
value={editingDate}
|
|
onChange={(e) => setEditingDate(e.target.value)}
|
|
/>
|
|
) : (
|
|
t.date || ''
|
|
)}
|
|
</td>
|
|
|
|
{/* Amount cell */}
|
|
<td data-label="Amount" className="amount" style={{ textAlign: 'right' }}>
|
|
{editingTxId === t.id ? (
|
|
<input
|
|
className="input"
|
|
type="number"
|
|
step="0.01"
|
|
value={editingAmount}
|
|
onChange={(e) => setEditingAmount(e.target.value)}
|
|
style={{ textAlign: 'right' }}
|
|
/>
|
|
) : (
|
|
formatAmount(t.amount)
|
|
)}
|
|
</td>
|
|
|
|
{/* Description cell */}
|
|
<td data-label="Description">
|
|
{editingTxId === t.id ? (
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
value={editingDescription}
|
|
onChange={(e) => setEditingDescription(e.target.value)}
|
|
/>
|
|
) : (
|
|
t.description || ''
|
|
)}
|
|
</td>
|
|
|
|
{/* Categories cell */}
|
|
<td data-label="Categories">
|
|
{editingTxId === t.id ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<select
|
|
multiple
|
|
className="input"
|
|
value={editingCategoryIds.map(String)}
|
|
onChange={(e) => {
|
|
const opts = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value));
|
|
setEditingCategoryIds(opts);
|
|
}}
|
|
>
|
|
{categories.map(c => (
|
|
<option key={c.id} value={c.id}>{c.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : (
|
|
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span>
|
|
)}
|
|
</td>
|
|
|
|
{/* Actions cell */}
|
|
<td data-label="Actions">
|
|
{editingTxId === t.id ? (
|
|
<div className="actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
<button className="btn small" onClick={saveEditTransaction}>Save</button>
|
|
<button className="btn small" onClick={cancelEditTransaction}>Cancel</button>
|
|
<button className="btn small" onClick={() => handleDeleteTransaction(t.id)}>Delete</button>
|
|
</div>
|
|
) : (
|
|
<div className="actions" style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
<button className="btn small" onClick={() => beginEditTransaction(t)}>Edit</button>
|
|
<button className="btn small" onClick={() => handleDeleteTransaction(t.id)}>Delete</button>
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
{current === 'account' && (
|
|
// lazy import avoided for simplicity
|
|
<AccountPage onDeleted={onLogout} />
|
|
)}
|
|
|
|
{current === 'manual' && (
|
|
<ManualManagement
|
|
categories={categories}
|
|
onTransactionAdded={(t) => setTransactions(prev => [t, ...prev])}
|
|
onCategoryCreated={(c) => setCategories(prev => [...prev, c])}
|
|
/>
|
|
)}
|
|
|
|
{current === 'appearance' && (
|
|
<AppearancePage />
|
|
)}
|
|
</main>
|
|
</div>
|
|
<MockBankModal
|
|
isOpen={isMockModalOpen}
|
|
isGenerating={isGenerating}
|
|
categories={categories}
|
|
onClose={() => setMockModalOpen(false)}
|
|
onGenerate={handleGenerateMockTransactions}
|
|
/>
|
|
{sidebarOpen && <div className="backdrop" onClick={() => setSidebarOpen(false)} />}
|
|
</div>
|
|
);
|
|
}
|