mirror of
https://github.com/dat515-2025/Group-8.git
synced 2026-03-22 15:12:08 +01:00
386 lines
17 KiB
TypeScript
386 lines
17 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
|
import { type Category, type Transaction, type BalancePoint, createTransaction, getCategories, getTransactions, createCategory, updateTransaction, getBalanceSeries } from '../api';
|
|
import AccountPage from './AccountPage';
|
|
import AppearancePage from './AppearancePage';
|
|
import BalanceChart from './BalanceChart';
|
|
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);
|
|
}
|
|
|
|
export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|
const [current, setCurrent] = useState<'home' | '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.');
|
|
}
|
|
}
|
|
|
|
// New transaction form state
|
|
const [amount, setAmount] = useState<string>('');
|
|
const [description, setDescription] = useState('');
|
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | ''>('');
|
|
|
|
// 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[]>([]);
|
|
|
|
// Category creation form
|
|
const [newCatName, setNewCatName] = useState('');
|
|
const [newCatDesc, setNewCatDesc] = useState('');
|
|
|
|
// New transaction date
|
|
const [txDate, setTxDate] = useState<string>('');
|
|
|
|
// Inline edit state for transaction categories
|
|
const [editingTxId, setEditingTxId] = useState<number | null>(null);
|
|
const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]);
|
|
|
|
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}`; }
|
|
|
|
async function handleCreate(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!amount) return;
|
|
const payload = {
|
|
amount: Number(amount),
|
|
description: description || undefined,
|
|
category_ids: selectedCategoryId !== '' ? [Number(selectedCategoryId)] : undefined,
|
|
date: txDate || undefined,
|
|
};
|
|
try {
|
|
const created = await createTransaction(payload);
|
|
setTransactions(prev => [created, ...prev]);
|
|
setAmount(''); setDescription(''); setSelectedCategoryId(''); setTxDate('');
|
|
} catch (err: any) {
|
|
alert(err?.message || 'Failed to create transaction');
|
|
}
|
|
}
|
|
|
|
function beginEditCategories(t: Transaction) {
|
|
setEditingTxId(t.id);
|
|
setEditingCategoryIds([...(t.category_ids || [])]);
|
|
}
|
|
function cancelEditCategories() {
|
|
setEditingTxId(null);
|
|
setEditingCategoryIds([]);
|
|
}
|
|
async function saveEditCategories() {
|
|
if (editingTxId == null) return;
|
|
try {
|
|
const updated = await updateTransaction(editingTxId, { category_ids: editingCategoryIds });
|
|
setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p)));
|
|
cancelEditCategories();
|
|
} catch (err: any) {
|
|
alert(err?.message || 'Failed to update transaction categories');
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="app-layout">
|
|
<aside className="sidebar">
|
|
<div className="logo">7Project</div>
|
|
<nav className="nav">
|
|
<button className={current === 'home' ? 'active' : ''} onClick={() => setCurrent('home')}>Home</button>
|
|
<button className={current === 'account' ? 'active' : ''} onClick={() => setCurrent('account')}>Account</button>
|
|
<button className={current === 'appearance' ? 'active' : ''} onClick={() => setCurrent('appearance')}>Appearance</button>
|
|
</nav>
|
|
</aside>
|
|
<div className="content">
|
|
<div className="topbar">
|
|
<h2 style={{ margin: 0 }}>{current === 'home' ? 'Dashboard' : 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>Add Transaction</h3>
|
|
<form onSubmit={handleCreate} className="form-row">
|
|
<input className="input" type="number" step="0.01" placeholder="Amount" value={amount} onChange={(e) => setAmount(e.target.value)} required />
|
|
<input className="input" type="date" placeholder="Date (optional)" value={txDate} onChange={(e) => setTxDate(e.target.value)} />
|
|
<input className="input" type="text" placeholder="Description (optional)" value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
<select className="input" value={selectedCategoryId} onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : '')}>
|
|
<option value="">No category</option>
|
|
{categories.map(c => (<option key={c.id} value={c.id}>{c.name}</option>))}
|
|
</select>
|
|
<button className="btn primary" type="submit">Add</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section className="card">
|
|
<h3>Categories</h3>
|
|
<form className="form-row" onSubmit={async (e) => { e.preventDefault(); if (!newCatName.trim()) return; try { const cat = await createCategory({ name: newCatName.trim(), description: newCatDesc || undefined }); setCategories(prev => [...prev, cat]); setNewCatName(''); setNewCatDesc(''); } catch (err: any) { alert(err?.message || 'Failed to create category'); } }}>
|
|
<input className="input" type="text" placeholder="New category name" value={newCatName} onChange={(e) => setNewCatName(e.target.value)} />
|
|
<input className="input" type="text" placeholder="Description (optional)" value={newCatDesc} onChange={(e) => setNewCatDesc(e.target.value)} />
|
|
<button className="btn primary" type="submit">Create category</button>
|
|
</form>
|
|
</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>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visible.map(t => (
|
|
<tr key={t.id}>
|
|
<td>{t.date || ''}</td>
|
|
<td className="amount">{formatAmount(t.amount)}</td>
|
|
<td>{t.description || ''}</td>
|
|
<td>
|
|
{editingTxId === t.id ? (
|
|
<div className="space-y" 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>
|
|
<button className="btn small" onClick={saveEditCategories}>Save</button>
|
|
<button className="btn small" onClick={cancelEditCategories}>Cancel</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-x" style={{ display: 'flex', alignItems: 'center', gap: 8, justifyContent: 'space-between' }}>
|
|
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span>
|
|
<button className="btn small" onClick={() => beginEditCategories(t)}>Change</button>
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</>
|
|
)}
|
|
</section>
|
|
</>
|
|
)}
|
|
|
|
{current === 'account' && (
|
|
// lazy import avoided for simplicity
|
|
<AccountPage onDeleted={onLogout} />
|
|
)}
|
|
|
|
{current === 'appearance' && (
|
|
<AppearancePage />
|
|
)}
|
|
</main>
|
|
</div>
|
|
<MockBankModal
|
|
isOpen={isMockModalOpen}
|
|
isGenerating={isGenerating}
|
|
categories={categories}
|
|
onClose={() => setMockModalOpen(false)}
|
|
onGenerate={handleGenerateMockTransactions}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|