feat(frontend): implemented multiple transaction selections in UI

This commit is contained in:
ribardej
2025-11-12 15:10:00 +01:00
parent e73233c90a
commit 280d495335

View File

@@ -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 ? (