mirror of
https://github.com/acedanger/finance.git
synced 2025-12-06 07:00:13 -08:00
#1 Enhance transaction management UI with form validation, loading states, and improved error handling
This commit is contained in:
@@ -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">
|
||||
<button
|
||||
<button
|
||||
id="toggle-form-btn"
|
||||
class="toggle-form-btn"
|
||||
aria-expanded="false"
|
||||
aria-controls="add-transaction-form"
|
||||
>
|
||||
>
|
||||
Add Transaction +
|
||||
</button>
|
||||
<form id="add-transaction-form" class="collapsible-form collapsed">
|
||||
<h4>New Transaction</h4>
|
||||
<form id="add-transaction-form" class="collapsible-form collapsed" novalidate>
|
||||
<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">
|
||||
<label for="txn-date">Date</label>
|
||||
<input type="date" id="txn-date" name="date" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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 class="form-group">
|
||||
<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">
|
||||
<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>
|
||||
<!-- In a real app, prevent default and use JS to submit -->
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Script to handle the collapsible form toggle
|
||||
const toggleBtn = document.getElementById('toggle-form-btn');
|
||||
const form = document.getElementById('add-transaction-form');
|
||||
<style>
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
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) {
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true';
|
||||
toggleBtn.setAttribute('aria-expanded', String(!isExpanded));
|
||||
form.classList.toggle('collapsed');
|
||||
toggleBtn.textContent = isExpanded ? 'Add Transaction +' : 'Hide Form -';
|
||||
// Optional: Focus first input when opening
|
||||
if (!isExpanded) {
|
||||
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>
|
||||
@@ -11,9 +11,9 @@ const { transactions } = Astro.props;
|
||||
// Sort transactions by date descending for display
|
||||
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">
|
||||
<thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
@@ -30,8 +30,8 @@ const sortedTransactions = [...transactions].sort((a, b) => new Date(b.date).get
|
||||
{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>
|
||||
<button class="action-btn edit-btn" title="Edit transaction">Edit</button>
|
||||
<button class="action-btn delete-btn" title="Delete transaction">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user