#1 Enhance transaction management UI with form validation, loading states, and improved error handling

This commit is contained in:
GitHub Copilot
2025-04-24 08:19:41 -04:00
parent 7c9bc51a9c
commit b76a24edba
5 changed files with 514 additions and 82 deletions

View File

@@ -1,51 +1,281 @@
--- ---
// This component needs client-side JS for the toggle // This component handles both creating and editing transactions
--- ---
<section class="add-transaction-section"> <section class="add-transaction-section">
<button <button
id="toggle-form-btn" id="toggle-form-btn"
class="toggle-form-btn" class="toggle-form-btn"
aria-expanded="false" aria-expanded="false"
aria-controls="add-transaction-form" aria-controls="add-transaction-form"
> >
Add Transaction + Add Transaction +
</button> </button>
<form id="add-transaction-form" class="collapsible-form collapsed"> <form id="add-transaction-form" class="collapsible-form collapsed" novalidate>
<h4>New Transaction</h4> <h4 id="form-title">New Transaction</h4>
<div id="form-error" class="error-message hidden"></div>
<input type="hidden" id="txn-id" name="id">
<div class="form-group"> <div class="form-group">
<label for="txn-date">Date</label> <label for="txn-date">Date</label>
<input type="date" id="txn-date" name="date" required> <input type="date" id="txn-date" name="date" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="txn-description">Description</label> <label for="txn-description">Description</label>
<input type="text" id="txn-description" name="description" required placeholder="e.g. Groceries"> <input type="text" id="txn-description" name="description" required minlength="2" maxlength="100" placeholder="e.g. Groceries">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="txn-amount">Amount</label> <label for="txn-amount">Amount</label>
<input type="number" id="txn-amount" name="amount" step="0.01" required placeholder="e.g. -25.50 or 1200.00"> <input type="number" id="txn-amount" name="amount" step="0.01" required placeholder="e.g. -25.50 or 1200.00">
<small class="help-text">Use negative numbers for expenses (e.g., -50.00)</small>
</div>
<div class="button-group">
<button type="submit" id="submit-btn" class="form-submit-btn">Save Transaction</button>
<button type="button" id="cancel-btn" class="cancel-btn hidden">Cancel</button>
</div> </div>
<!-- In a real app, prevent default and use JS to submit -->
<button type="submit">Save</button>
</form> </form>
</section> </section>
<script> <style>
// Script to handle the collapsible form toggle .error-message {
const toggleBtn = document.getElementById('toggle-form-btn'); background-color: #f8d7da;
const form = document.getElementById('add-transaction-form'); border: 1px solid #f5c6cb;
color: #721c24;
padding: 8px 12px;
margin-bottom: 15px;
border-radius: 4px;
font-size: 0.9em;
}
.hidden {
display: none;
}
.help-text {
color: #666;
font-size: 0.8em;
margin-top: 4px;
display: block;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 15px;
}
.cancel-btn {
background-color: #6c757d;
color: white;
padding: 8px 15px;
border: none;
border-radius: 3px;
cursor: pointer;
}
.cancel-btn:hover {
background-color: #5a6268;
}
.form-submit-btn.loading {
background-color: #ccc;
cursor: not-allowed;
}
</style>
<script>
// --- DOM Elements ---
const toggleBtn = document.getElementById('toggle-form-btn');
const form = document.getElementById('add-transaction-form') as HTMLFormElement;
const formTitle = document.getElementById('form-title');
const errorDiv = document.getElementById('form-error');
const accountSelect = document.getElementById('account-select') as HTMLSelectElement;
const cancelBtn = document.getElementById('cancel-btn');
const submitBtn = document.getElementById('submit-btn');
const txnIdInput = document.getElementById('txn-id') as HTMLInputElement;
const dateInput = document.getElementById('txn-date') as HTMLInputElement;
let isEditMode = false;
let isSubmitting = false;
// Set default date to today
if (dateInput) {
dateInput.value = new Date().toISOString().split('T')[0];
}
// --- Helper Functions ---
function showError(message: string) {
if (errorDiv) {
errorDiv.textContent = message;
errorDiv.classList.remove('hidden');
}
}
function hideError() {
if (errorDiv) {
errorDiv.classList.add('hidden');
}
}
function clearForm() {
if (form) {
form.reset();
// Reset date to today
if (dateInput) {
dateInput.value = new Date().toISOString().split('T')[0];
}
// Reset edit mode
setEditMode(false);
// Clear any errors
hideError();
}
}
function setEditMode(enabled: boolean) {
isEditMode = enabled;
if (formTitle) formTitle.textContent = enabled ? 'Edit Transaction' : 'New Transaction';
if (submitBtn) submitBtn.textContent = enabled ? 'Update Transaction' : 'Save Transaction';
if (cancelBtn) cancelBtn.classList.toggle('hidden', !enabled);
if (toggleBtn) toggleBtn.classList.toggle('hidden', enabled);
}
function setLoading(enabled: boolean) {
isSubmitting = enabled;
if (submitBtn) {
const button = submitBtn as HTMLButtonElement;
button.classList.toggle('loading', enabled);
button.disabled = enabled;
}
}
function validateForm() {
if (!form) return null;
const formData = new FormData(form);
const errors: string[] = [];
// Check required fields
const description = formData.get('description') as string;
const amount = formData.get('amount');
const date = formData.get('date');
if (!description || description.trim().length < 2) {
errors.push('Description must be at least 2 characters long');
}
if (!amount) {
errors.push('Amount is required');
}
if (!date) {
errors.push('Date is required');
}
return errors.length > 0 ? errors : null;
}
// --- Form Toggle ---
if (toggleBtn && form) { if (toggleBtn && form) {
toggleBtn.addEventListener('click', () => { toggleBtn.addEventListener('click', () => {
const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true'; const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true';
toggleBtn.setAttribute('aria-expanded', String(!isExpanded)); toggleBtn.setAttribute('aria-expanded', String(!isExpanded));
form.classList.toggle('collapsed'); form.classList.toggle('collapsed');
toggleBtn.textContent = isExpanded ? 'Add Transaction +' : 'Hide Form -'; toggleBtn.textContent = isExpanded ? 'Add Transaction +' : 'Hide Form -';
// Optional: Focus first input when opening
if (!isExpanded) { if (!isExpanded) {
form.querySelector('input')?.focus(); form.querySelector('input')?.focus();
} }
hideError();
}); });
} else {
console.error("Toggle button or form not found");
} }
// Cancel button handler
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
clearForm();
form?.classList.add('collapsed');
});
}
// --- Form Submission ---
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
if (isSubmitting) return;
// Validate form
const errors = validateForm();
if (errors) {
showError(errors.join('. '));
return;
}
setLoading(true);
try {
const formData = new FormData(form);
const currentAccountId = accountSelect?.value;
if (!currentAccountId) {
throw new Error('No account selected');
}
const amount = parseFloat(formData.get('amount') as string);
const transaction = {
accountId: currentAccountId,
date: formData.get('date'),
description: (formData.get('description') as string).trim(),
amount: amount
};
const transactionId = formData.get('id');
const method = transactionId ? 'PUT' : 'POST';
const url = transactionId
? `/api/transactions/${transactionId}`
: '/api/transactions';
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(transaction)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `Failed to ${isEditMode ? 'update' : 'create'} transaction`);
}
// Transaction saved successfully
const savedTransaction = await response.json();
// Update UI
const eventName = isEditMode ? 'transactionUpdated' : 'transactionCreated';
const event = new CustomEvent(eventName, {
detail: { transaction: savedTransaction }
});
document.dispatchEvent(event);
// Clear and collapse form
clearForm();
form.classList.add('collapsed');
} catch (error) {
showError(error instanceof Error ? error.message : 'An unexpected error occurred');
} finally {
setLoading(false);
}
});
}
// --- Edit Transaction Handler ---
document.addEventListener('editTransaction', ((event: CustomEvent) => {
const transaction = event.detail.transaction;
if (!form || !transaction) return;
// Populate form
txnIdInput.value = transaction.id;
if (dateInput) dateInput.value = transaction.date;
(form.querySelector('#txn-description') as HTMLInputElement).value = transaction.description;
(form.querySelector('#txn-amount') as HTMLInputElement).value = transaction.amount.toString();
// Show form in edit mode
setEditMode(true);
form.classList.remove('collapsed');
form.querySelector('input')?.focus();
}) as EventListener);
</script> </script>

View File

@@ -11,9 +11,9 @@ const { transactions } = Astro.props;
// Sort transactions by date descending for display // Sort transactions by date descending for display
const sortedTransactions = [...transactions].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); const sortedTransactions = [...transactions].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
--- ---
<section class="transaction-list"> <section class="transaction-list" id="transaction-section">
<table id="transaction-table"> <table id="transaction-table">
<thead> <thead>
<tr> <tr>
<th>Date</th> <th>Date</th>
<th>Description</th> <th>Description</th>
@@ -30,8 +30,8 @@ const sortedTransactions = [...transactions].sort((a, b) => new Date(b.date).get
{formatCurrency(txn.amount)} {formatCurrency(txn.amount)}
</td> </td>
<td> <td>
<button class="action-btn edit-btn" title="Edit transaction (not implemented)">Edit</button> <button class="action-btn edit-btn" title="Edit transaction">Edit</button>
<button class="action-btn delete-btn" title="Delete transaction (not implemented)">Delete</button> <button class="action-btn delete-btn" title="Delete transaction">Delete</button>
</td> </td>
</tr> </tr>
))} ))}

View File

@@ -6,10 +6,13 @@ export const PUT: APIRoute = async ({ request, params }) => {
const { id } = params; const { id } = params;
if (!id) { if (!id) {
return new Response(JSON.stringify({ error: "Transaction ID is required" }), { return new Response(
status: 400, JSON.stringify({ error: "Transaction ID is required" }),
headers: { "Content-Type": "application/json" }, {
}); status: 400,
headers: { "Content-Type": "application/json" },
}
);
} }
try { try {
@@ -65,10 +68,13 @@ export const DELETE: APIRoute = async ({ params }) => {
const { id } = params; const { id } = params;
if (!id) { if (!id) {
return new Response(JSON.stringify({ error: "Transaction ID is required" }), { return new Response(
status: 400, JSON.stringify({ error: "Transaction ID is required" }),
headers: { "Content-Type": "application/json" }, {
}); status: 400,
headers: { "Content-Type": "application/json" },
}
);
} }
const transactionIndex = transactions.findIndex((t) => t.id === id); const transactionIndex = transactions.findIndex((t) => t.id === id);

View File

@@ -16,93 +16,244 @@ const initialAccount: Account = accounts[0] || {
last4: '0000', last4: '0000',
balance: 0 balance: 0
}; };
const initialTransactions: Transaction[] = [];
// Fetch initial transactions if we have an account
let initialTransactions: Transaction[] = [];
if (initialAccount.id) {
const transactionsResponse = await fetch(`http://localhost:4321/api/accounts/${initialAccount.id}/transactions`);
initialTransactions = await transactionsResponse.json();
}
--- ---
<BaseLayout title="Bank Transactions Dashboard"> <BaseLayout title="Bank Transactions Dashboard">
<div class="dashboard-layout"> <div class="dashboard-layout">
<Sidebar accounts={accounts} initialAccount={initialAccount} /> <Sidebar accounts={accounts} initialAccount={initialAccount} />
<MainContent account={initialAccount} transactions={initialTransactions} /> <MainContent account={initialAccount} transactions={initialTransactions} />
</div> </div>
</BaseLayout> </BaseLayout>
<script define:vars={{ allAccounts: accounts, allTransactions: initialTransactions }}> <script>
// Client-side script to handle account switching and updating the UI
// --- DOM Elements --- // --- DOM Elements ---
const accountSelect = document.getElementById('account-select'); const accountSelect = document.getElementById('account-select');
const currentAccountNameSpan = document.getElementById('current-account-name'); const currentAccountNameSpan = document.getElementById('current-account-name');
const accountBalanceSpan = document.getElementById('account-balance'); const accountBalanceSpan = document.getElementById('account-balance');
const transactionTableBody = document.getElementById('transaction-table-body'); const transactionTableBody = document.getElementById('transaction-table-body');
// --- Helper Functions (mirror utils.ts for client-side use) --- // --- Helper Functions ---
function formatCurrency(amount) { function formatCurrency(amount) {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
} }
function formatDate(dateString) { function formatDate(dateString) {
const date = new Date(dateString + 'T00:00:00'); // Ensure local date const date = new Date(dateString + 'T00:00:00');
return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric' }).format(date); return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric' }).format(date);
} }
async function fetchAccountTransactions(accountId) {
try {
const response = await fetch(`/api/accounts/${accountId}/transactions`);
if (!response.ok) throw new Error('Failed to fetch transactions');
return await response.json();
} catch (error) {
console.error('Error fetching transactions:', error);
return [];
}
}
async function fetchAccountDetails(accountId) {
try {
const response = await fetch(`/api/accounts/${accountId}`);
if (!response.ok) throw new Error('Failed to fetch account details');
return await response.json();
} catch (error) {
console.error('Error fetching account:', error);
return null;
}
}
// --- Update UI Function --- // --- Update UI Function ---
function updateUIForAccount(accountId) { async function updateUIForAccount(accountId) {
console.log("Updating UI for account:", accountId); // Debug log console.log("Updating UI for account:", accountId);
const selectedAccount = allAccounts.find(acc => acc.id === accountId); const transactionSection = document.getElementById('transaction-section');
const accountTransactions = allTransactions if (transactionSection) {
.filter(txn => txn.accountId === accountId) transactionSection.classList.add('loading');
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // Sort desc }
if (!selectedAccount || !transactionTableBody || !currentAccountNameSpan || !accountBalanceSpan) { try {
console.error("Required UI elements not found!"); // Fetch latest account details and transactions
const [account, transactions] = await Promise.all([
fetchAccountDetails(accountId),
fetchAccountTransactions(accountId)
]);
if (!account || !transactionTableBody || !currentAccountNameSpan || !accountBalanceSpan) {
console.error("Required data or UI elements not found!");
return;
}
// Update header
currentAccountNameSpan.textContent = `${account.name} (***${account.last4})`;
// Update summary
accountBalanceSpan.textContent = formatCurrency(account.balance);
// Update table
updateTransactionTable(transactions);
} catch (error) {
console.error('Error updating UI:', error);
if (transactionTableBody) {
transactionTableBody.innerHTML = `
<tr>
<td colspan="4" style="text-align: center; color: #dc3545;">
Failed to load transactions. Please try again.
</td>
</tr>`;
}
} finally {
if (transactionSection) {
transactionSection.classList.remove('loading');
}
}
}
function updateTransactionTable(transactions) {
if (!transactionTableBody) return;
const sortedTransactions = [...transactions].sort((a, b) =>
new Date(b.date).getTime() - new Date(a.date).getTime()
);
transactionTableBody.innerHTML = '';
if (sortedTransactions.length === 0) {
transactionTableBody.innerHTML = `
<tr>
<td colspan="4" style="text-align: center; font-style: italic; color: #777;">
No transactions found for this account.
</td>
</tr>`;
return; return;
} }
// Update header sortedTransactions.forEach(txn => {
currentAccountNameSpan.textContent = `${selectedAccount.name} (***${selectedAccount.last4})`; const row = document.createElement('tr');
row.setAttribute('data-txn-id', txn.id);
row.innerHTML = `
<td>${formatDate(txn.date)}</td>
<td>${txn.description}</td>
<td class="amount-col ${txn.amount >= 0 ? 'amount-positive' : 'amount-negative'}">
${formatCurrency(txn.amount)}
</td>
<td>
<button class="action-btn edit-btn" title="Edit transaction">Edit</button>
<button class="action-btn delete-btn" title="Delete transaction">Delete</button>
</td>
`;
transactionTableBody.appendChild(row);
});
// Update summary // Add event listeners for edit and delete buttons
accountBalanceSpan.textContent = formatCurrency(selectedAccount.balance); setupTransactionButtons();
}
// Update table // --- Transaction Actions ---
transactionTableBody.innerHTML = ''; // Clear existing rows async function handleDeleteTransaction(txnId) {
if (!confirm('Are you sure you want to delete this transaction?')) {
return;
}
if (accountTransactions.length === 0) { const transactionSection = document.getElementById('transaction-section');
transactionTableBody.innerHTML = `<tr><td colspan="4" style="text-align: center; font-style: italic; color: #777;">No transactions found for this account.</td></tr>`; if (transactionSection) {
} else { transactionSection.classList.add('loading');
accountTransactions.forEach(txn => { }
const row = document.createElement('tr');
row.setAttribute('data-txn-id', txn.id); try {
row.innerHTML = ` const response = await fetch(`/api/transactions/${txnId}`, {
<td>${formatDate(txn.date)}</td> method: 'DELETE'
<td>${txn.description}</td>
<td class="amount-col ${txn.amount >= 0 ? 'amount-positive' : 'amount-negative'}">
${formatCurrency(txn.amount)}
</td>
<td>
<button class="action-btn edit-btn" title="Edit transaction (not implemented)">Edit</button>
<button class="action-btn delete-btn" title="Delete transaction (not implemented)">Delete</button>
</td>
`;
transactionTableBody.appendChild(row);
}); });
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete transaction');
}
// Refresh the current account view
const currentAccountId = accountSelect?.value;
if (currentAccountId) {
await updateUIForAccount(currentAccountId);
}
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to delete transaction');
} finally {
if (transactionSection) {
transactionSection.classList.remove('loading');
}
} }
} }
// --- Event Listener --- async function handleEditTransaction(txnId) {
if (accountSelect) { try {
accountSelect.addEventListener('change', (event) => { // Find transaction in current transactions list
const selectedAccountId = event.target.value; const currentAccountId = accountSelect?.value;
updateUIForAccount(selectedAccountId); const transactions = await fetchAccountTransactions(currentAccountId);
}); const transaction = transactions.find(t => t.id === txnId);
} else {
console.error("Account select element not found"); if (!transaction) {
throw new Error('Transaction not found');
}
// Trigger edit mode in form
document.dispatchEvent(new CustomEvent('editTransaction', {
detail: { transaction }
}));
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to load transaction for editing');
}
} }
// --- Initial Load (Optional but good practice) --- function setupTransactionButtons() {
// The page already renders the initial state server-side, const transactionRows = transactionTableBody?.querySelectorAll('tr[data-txn-id]');
// so no need to call updateUIForAccount() immediately unless transactionRows?.forEach(row => {
// there's a chance the JS loads before initial render completes fully. const txnId = row.getAttribute('data-txn-id');
// console.log("Account switcher script loaded."); if (!txnId) return;
// Delete button handler
const deleteBtn = row.querySelector('.delete-btn');
deleteBtn?.addEventListener('click', () => handleDeleteTransaction(txnId));
// Edit button handler
const editBtn = row.querySelector('.edit-btn');
editBtn?.addEventListener('click', () => handleEditTransaction(txnId));
});
}
// --- Event Listeners ---
if (accountSelect) {
accountSelect.addEventListener('change', async (event) => {
const selectedAccountId = event.target.value;
await updateUIForAccount(selectedAccountId);
});
}
// Listen for transaction events
document.addEventListener('transactionCreated', async (event) => {
const currentAccountId = accountSelect?.value;
if (currentAccountId) {
await updateUIForAccount(currentAccountId);
}
});
document.addEventListener('transactionUpdated', async (event) => {
const currentAccountId = accountSelect?.value;
if (currentAccountId) {
await updateUIForAccount(currentAccountId);
}
});
// Initial load with transactions if available
const initialAccountId = accountSelect?.value;
if (initialAccountId) {
updateUIForAccount(initialAccountId);
}
</script> </script>

View File

@@ -232,3 +232,48 @@ tbody tr:hover {
padding: 20px; padding: 20px;
} }
} }
/* Loading States */
.loading {
opacity: 0.6;
pointer-events: none;
position: relative;
}
.loading::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 1.5em;
height: 1.5em;
margin: -0.75em 0 0 -0.75em;
border: 3px solid rgba(0, 123, 255, 0.2);
border-top-color: #007bff;
border-radius: 50%;
animation: spinner 0.6s linear infinite;
}
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
.form-submit-btn {
position: relative;
min-width: 100px;
}
.form-submit-btn.loading {
padding-right: 35px;
}
.form-submit-btn.loading::after {
width: 1em;
height: 1em;
margin: -0.5em 0 0 0;
left: auto;
right: 10px;
border-width: 2px;
}