mirror of
https://github.com/dat515-2025/Group-8.git
synced 2026-03-22 06:57:47 +01:00
feat(frontend): implemented multiple transaction selections in UI
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||||
import { type Category, type Transaction, type BalancePoint, deleteTransaction, getCategories, getTransactions, createTransaction, updateTransaction, getBalanceSeries } from '../api';
|
import { type Category, type Transaction, type BalancePoint, deleteTransaction, getCategories, getTransactions, createTransaction, updateTransaction, getBalanceSeries } from '../api';
|
||||||
import AccountPage from './AccountPage';
|
import AccountPage from './AccountPage';
|
||||||
import AppearancePage from './AppearancePage';
|
import AppearancePage from './AppearancePage';
|
||||||
@@ -168,7 +168,14 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
// Sidebar toggle for mobile
|
// Sidebar toggle for mobile
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
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() {
|
async function loadAll() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -241,7 +248,7 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => { loadAll(); }, [startDate, endDate]);
|
useEffect(() => { loadAll(); clearSelection(); }, [startDate, endDate]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let arr = [...transactions];
|
let arr = [...transactions];
|
||||||
@@ -267,6 +274,9 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
const pageEnd = pageStart + pageSize;
|
const pageEnd = pageStart + pageSize;
|
||||||
const visible = sortedDesc.slice(pageStart, pageEnd);
|
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 categoryNameById(id: number) { return categories.find(c => c.id === id)?.name || `#${id}`; }
|
||||||
|
|
||||||
|
|
||||||
@@ -416,7 +426,55 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
<div className="muted">
|
<div className="muted">
|
||||||
Showing {visible.length} of {filtered.length} (page {Math.min(page + 1, Math.max(1, totalPages))}/{Math.max(1, totalPages)})
|
Showing {visible.length} of {filtered.length} (page {Math.min(page + 1, Math.max(1, totalPages))}/{Math.max(1, totalPages)})
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<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 <= 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>
|
<button className="btn primary" disabled={page >= totalPages - 1} onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}>Next</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -424,6 +482,22 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
<table className="table responsive">
|
<table className="table responsive">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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))}
|
||||||
|
indeterminate={(visible.some(v => selectedTxIds.includes(v.id)) && !visible.every(v => selectedTxIds.includes(v.id))) as any}
|
||||||
|
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>Date</th>
|
||||||
<th style={{ textAlign: 'right' }}>Amount</th>
|
<th style={{ textAlign: 'right' }}>Amount</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
@@ -433,7 +507,15 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visible.map(t => (
|
{visible.map(t => (
|
||||||
<tr key={t.id}>
|
<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 */}
|
{/* Date cell */}
|
||||||
<td data-label="Date">
|
<td data-label="Date">
|
||||||
{editingTxId === t.id ? (
|
{editingTxId === t.id ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user