mirror of
https://github.com/dat515-2025/Group-8.git
synced 2026-03-22 06:57:47 +01:00
545 lines
22 KiB
TypeScript
545 lines
22 KiB
TypeScript
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<CnbRate[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(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
|
|
<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>
|
|
)}
|
|
</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);
|
|
|
|
// 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
|
|
|
|
|
|
|
|
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 (
|
|
<div className="app-layout">
|
|
<aside className="sidebar" style={{ display: 'flex', flexDirection: 'column' }}>
|
|
<div>
|
|
<div className="logo">7Project</div>
|
|
<nav className="nav">
|
|
<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">
|
|
<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}>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">
|
|
<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">
|
|
<thead>
|
|
<tr>
|
|
<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}>
|
|
{/* Date cell */}
|
|
<td>
|
|
{editingTxId === t.id ? (
|
|
<input
|
|
className="input"
|
|
type="date"
|
|
value={editingDate}
|
|
onChange={(e) => setEditingDate(e.target.value)}
|
|
/>
|
|
) : (
|
|
t.date || ''
|
|
)}
|
|
</td>
|
|
|
|
{/* Amount cell */}
|
|
<td 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>
|
|
{editingTxId === t.id ? (
|
|
<input
|
|
className="input"
|
|
type="text"
|
|
value={editingDescription}
|
|
onChange={(e) => setEditingDescription(e.target.value)}
|
|
/>
|
|
) : (
|
|
t.description || ''
|
|
)}
|
|
</td>
|
|
|
|
{/* Categories cell */}
|
|
<td>
|
|
{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>
|
|
{editingTxId === t.id ? (
|
|
<div 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 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}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|