feat(frontend): Added options for modifying and deleting transactions in the UI

This commit is contained in:
ribardej
2025-11-05 18:00:24 +01:00
parent 8543c72730
commit 36b1fe887b
2 changed files with 124 additions and 21 deletions

View File

@@ -19,6 +19,17 @@ export type Transaction = {
date?: string | null; // ISO date (YYYY-MM-DD) date?: string | null; // ISO date (YYYY-MM-DD)
}; };
export async function deleteTransaction(id: number): Promise<void> {
const res = await fetch(`${getBaseUrl()}/transactions/${id}/delete`, {
method: 'DELETE',
headers: getHeaders('none'),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Failed to delete transaction');
}
}
function getBaseUrl() { function getBaseUrl() {
const base = BACKEND_URL?.replace(/\/$/, '') || ''; const base = BACKEND_URL?.replace(/\/$/, '') || '';
return base || ''; return base || '';

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { type Category, type Transaction, type BalancePoint, 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';
import BalanceChart from './BalanceChart'; import BalanceChart from './BalanceChart';
@@ -161,9 +161,14 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
// Manual forms moved to ManualManagement page // Manual forms moved to ManualManagement page
// Inline edit state for transaction categories // Inline edit state for transaction editing
const [editingTxId, setEditingTxId] = useState<number | null>(null); const [editingTxId, setEditingTxId] = useState<number | null>(null);
const [editingCategoryIds, setEditingCategoryIds] = useState<number[]>([]); 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() { async function loadAll() {
setLoading(true); setLoading(true);
@@ -259,22 +264,50 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
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}`; }
function beginEditCategories(t: Transaction) { function beginEditTransaction(t: Transaction) {
setEditingTxId(t.id); setEditingTxId(t.id);
setEditingCategoryIds([...(t.category_ids || [])]); setEditingCategoryIds([...(t.category_ids || [])]);
setEditingAmount(String(t.amount));
setEditingDescription(t.description || '');
setEditingDate(t.date || '');
} }
function cancelEditCategories() { function cancelEditTransaction() {
setEditingTxId(null); setEditingTxId(null);
setEditingCategoryIds([]); setEditingCategoryIds([]);
setEditingAmount('');
setEditingDescription('');
setEditingDate('');
} }
async function saveEditCategories() { async function saveEditTransaction() {
if (editingTxId == null) return; if (editingTxId == null) return;
const amountNum = Number(editingAmount);
if (Number.isNaN(amountNum)) {
alert('Amount must be a number.');
return;
}
try { try {
const updated = await updateTransaction(editingTxId, { category_ids: editingCategoryIds }); 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))); setTransactions(prev => prev.map(p => (p.id === updated.id ? updated : p)));
cancelEditCategories(); // Optionally refresh balance series to reflect changes immediately
try { setBalanceSeries(await getBalanceSeries(startDate || undefined, endDate || undefined)); } catch {}
cancelEditTransaction();
} catch (err: any) { } catch (err: any) {
alert(err?.message || 'Failed to update transaction categories'); 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');
} }
} }
@@ -383,32 +416,91 @@ export default function Dashboard({ onLogout }: { onLogout: () => void }) {
<th style={{ textAlign: 'right' }}>Amount</th> <th style={{ textAlign: 'right' }}>Amount</th>
<th>Description</th> <th>Description</th>
<th>Categories</th> <th>Categories</th>
<th>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{visible.map(t => ( {visible.map(t => (
<tr key={t.id}> <tr key={t.id}>
<td>{t.date || ''}</td> {/* Date cell */}
<td className="amount">{formatAmount(t.amount)}</td>
<td>{t.description || ''}</td>
<td> <td>
{editingTxId === t.id ? ( {editingTxId === t.id ? (
<div className="space-y" style={{ display: 'flex', alignItems: 'center', gap: 8 }}> <input
<select multiple className="input" value={editingCategoryIds.map(String)} onChange={(e) => { className="input"
const opts = Array.from(e.currentTarget.selectedOptions).map(o => Number(o.value)); type="date"
setEditingCategoryIds(opts); 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 => ( {categories.map(c => (
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>{c.name}</option>
))} ))}
</select> </select>
<button className="btn small" onClick={saveEditCategories}>Save</button>
<button className="btn small" onClick={cancelEditCategories}>Cancel</button>
</div> </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>
<span>{t.category_ids.map(id => categoryNameById(id)).join(', ') || '—'}</span> )}
<button className="btn small" onClick={() => beginEditCategories(t)}>Change</button> </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> </div>
)} )}
</td> </td>