mirror of
https://github.com/dat515-2025/Group-8.git
synced 2026-03-22 23:20:56 +01:00
feat(frontend): improved Dashboard.tsx, added transaction date
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { type Category, type Transaction, createTransaction, getCategories, getTransactions } from '../api';
|
||||
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 { BACKEND_URL } from '../config';
|
||||
|
||||
function formatAmount(n: number) {
|
||||
@@ -47,13 +49,42 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
||||
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] = await Promise.all([getTransactions(), getCategories()]);
|
||||
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 {
|
||||
@@ -61,15 +92,10 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadAll(); }, []);
|
||||
|
||||
const last10 = useMemo(() => {
|
||||
const sorted = [...transactions].sort((a, b) => b.id - a.id);
|
||||
return sorted.slice(0, 10);
|
||||
}, [transactions]);
|
||||
useEffect(() => { loadAll(); }, [startDate, endDate]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let arr = last10;
|
||||
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);
|
||||
@@ -77,7 +103,20 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
||||
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;
|
||||
}, [last10, minAmount, maxAmount, filterCategoryId, searchText]);
|
||||
}, [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}`; }
|
||||
|
||||
@@ -88,16 +127,36 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
||||
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('');
|
||||
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">
|
||||
@@ -129,6 +188,7 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
||||
<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>
|
||||
@@ -138,9 +198,20 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
||||
</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" type="submit">Create category</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3>Filters</h3>
|
||||
<div className="form-row">
|
||||
<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) : '')}>
|
||||
@@ -152,7 +223,30 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3>Latest Transactions (last 10)</h3>
|
||||
<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 ? (
|
||||
@@ -160,26 +254,57 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
||||
) : filtered.length === 0 ? (
|
||||
<div>No transactions</div>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th style={{ textAlign: 'right' }}>Amount</th>
|
||||
<th>Description</th>
|
||||
<th>Categories</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(t => (
|
||||
<tr key={t.id}>
|
||||
<td>{t.id}</td>
|
||||
<td className="amount">{formatAmount(t.amount)}</td>
|
||||
<td>{t.description || ''}</td>
|
||||
<td>{t.category_ids.map(id => categoryNameById(id)).join(', ')}</td>
|
||||
<>
|
||||
<div className="form-row" style={{ justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div className="space-x">
|
||||
<button className="btn" disabled={page <= 0} onClick={() => setPage(p => Math.max(0, p - 1))}><- Load recent</button>
|
||||
<button className="btn" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Load older -></button>
|
||||
</div>
|
||||
<div className="muted">Showing {visible.length} of {filtered.length} (page {Math.min(page + 1, Math.max(1, totalPages))}/{Math.max(1, totalPages)})</div>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Date</th>
|
||||
<th style={{ textAlign: 'right' }}>Amount</th>
|
||||
<th>Description</th>
|
||||
<th>Categories</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map(t => (
|
||||
<tr key={t.id}>
|
||||
<td>{t.id}</td>
|
||||
<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" onClick={saveEditCategories}>Save</button>
|
||||
<button className="btn" onClick={cancelEditCategories}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-x" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span>
|
||||
<button className="btn" onClick={() => beginEditCategories(t)}>Change</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user