feat: Refactor transaction management with nanostores and convert components to React

- Added @nanostores/react for state management.
- Created AccountSummary component to display account balance.
- Replaced AddTransactionForm.astro with AddTransactionForm.tsx for better state handling.
- Introduced TransactionTable.tsx for displaying transactions with edit/delete functionality.
- Updated Sidebar.astro and MainContent.astro to use React components.
- Implemented transactionStore.ts for managing current account ID and transaction editing state.
- Removed obsolete AddTransactionForm.astro and related scripts.
- Enhanced error handling and loading states in transaction forms.
This fixes issues #7, #8, #9, #10, #11
This commit is contained in:
GitHub Copilot
2025-04-24 15:49:19 -04:00
parent d0a9af3dfd
commit 892ea684f4
24 changed files with 1000 additions and 2539 deletions

View File

@@ -0,0 +1,71 @@
import React, { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import type { Account } from "../types";
import { formatCurrency } from "../utils";
import { currentAccountId as currentAccountIdStore } from "../stores/transactionStore";
interface AccountSummaryProps {
// No props needed, data comes from store and fetch
}
export default function AccountSummary({}: AccountSummaryProps) {
const currentAccountId = useStore(currentAccountIdStore);
const [account, setAccount] = useState<Account | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!currentAccountId) {
setAccount(null); // Clear account details if no account selected
setError(null);
setIsLoading(false);
return;
}
const fetchDetails = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/api/accounts/${currentAccountId}`);
if (!response.ok) {
throw new Error("Failed to fetch account details");
}
const data: Account = await response.json();
setAccount(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "An unknown error occurred"
);
setAccount(null);
} finally {
setIsLoading(false);
}
};
fetchDetails();
}, [currentAccountId]);
// Determine content based on state
let balanceContent: React.ReactNode;
if (isLoading) {
balanceContent = <span className="loading-inline">Loading...</span>;
} else if (error) {
balanceContent = <span className="error-message">Error</span>;
} else if (account) {
balanceContent = formatCurrency(account.balance);
} else {
balanceContent = "N/A"; // Or some placeholder
}
return (
<div className="account-summary">
<h4>Account Summary</h4>
{/* Keep the ID for potential external manipulation if needed, though ideally not */}
<p>
Balance: <span id="account-balance">{balanceContent}</span>
</p>
{/* Display error details if needed */}
{/* {error && <small className="error-message">{error}</small>} */}
</div>
);
}

View File

@@ -1,301 +0,0 @@
---
// TODO: Enhance form validation and submission
// - Add more robust client-side validation
// - Implement better error message display
// - Add loading states during submission
// - Consider adding form reset confirmation if there are unsaved changes
// This component handles both creating and editing transactions
---
<section class="add-transaction-section">
<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" 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 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>
</form>
</section>
<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') as string;
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');
} else {
const amountNum = parseFloat(amount);
if (isNaN(amountNum)) {
errors.push('Amount must be a valid number');
} else if (amountNum === 0) {
errors.push('Amount cannot be zero');
}
}
if (!date) {
errors.push('Date is required');
} else {
const dateObj = new Date(date as string);
if (isNaN(dateObj.getTime())) {
errors.push('Invalid date format');
}
}
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 -';
if (!isExpanded) {
form.querySelector('input')?.focus();
}
hideError();
});
}
// 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 = txnIdInput.value;
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';
document.dispatchEvent(new CustomEvent(eventName, {
detail: { transaction: savedTransaction }
}));
// Clear and collapse form
clearForm();
form.classList.add('collapsed');
if (toggleBtn) {
toggleBtn.setAttribute('aria-expanded', 'false');
toggleBtn.textContent = 'Add Transaction +';
}
} 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>

View File

@@ -0,0 +1,260 @@
import React, { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import type { Transaction } from "../types";
// Import store atoms and actions
import {
currentAccountId as currentAccountIdStore,
transactionToEdit as transactionToEditStore,
cancelEditingTransaction,
transactionSaved,
} from "../stores/transactionStore";
// Remove props that now come from the store
interface AddTransactionFormProps {}
export default function AddTransactionForm({}: AddTransactionFormProps) {
// --- Read state from store ---
const currentAccountId = useStore(currentAccountIdStore);
const transactionToEdit = useStore(transactionToEditStore);
// --- State Variables ---
const [date, setDate] = useState("");
const [description, setDescription] = useState("");
const [amount, setAmount] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEditMode = !!editingId;
// --- Effects ---
// Effect to set default date on mount
useEffect(() => {
// Only set default date if not editing
if (!transactionToEdit) {
setDate(new Date().toISOString().split("T")[0]);
}
}, [transactionToEdit]); // Rerun if edit mode changes
// Effect to populate form when editing
useEffect(() => {
if (transactionToEdit) {
setEditingId(transactionToEdit.id);
// Format date correctly for input type="date"
try {
const dateObj = new Date(transactionToEdit.date);
// Check if date is valid before formatting
if (!isNaN(dateObj.getTime())) {
// Adjust for timezone offset to prevent date shifting
const timezoneOffset = dateObj.getTimezoneOffset() * 60000; //offset in milliseconds
const adjustedDate = new Date(dateObj.getTime() - timezoneOffset);
setDate(adjustedDate.toISOString().split("T")[0]);
} else {
console.warn(
"Invalid date received for editing:",
transactionToEdit.date
);
setDate(""); // Set to empty if invalid
}
} catch (e) {
console.error("Error parsing date for editing:", e);
setDate(""); // Set to empty on error
}
setDescription(transactionToEdit.description);
setAmount(transactionToEdit.amount.toString());
setError(null); // Clear errors when starting edit
} else {
// Reset form if transactionToEdit becomes null (e.g., after saving or cancelling)
// but only if not already resetting via handleCancel or handleSubmit
if (!isLoading) {
resetForm();
}
}
}, [transactionToEdit, isLoading]); // Add isLoading dependency
// --- Helper Functions ---
const resetForm = () => {
setEditingId(null);
setDate(new Date().toISOString().split("T")[0]);
setDescription("");
setAmount("");
setError(null);
// Don't reset isLoading here, it's handled in submit/cancel
};
const validateForm = (): string[] => {
const errors: string[] = [];
if (!description || description.trim().length < 2) {
errors.push("Description must be at least 2 characters long");
}
if (!amount) {
errors.push("Amount is required");
} else {
const amountNum = parseFloat(amount);
if (isNaN(amountNum)) {
errors.push("Amount must be a valid number");
} else if (amountNum === 0) {
errors.push("Amount cannot be zero");
}
}
if (!date) {
errors.push("Date is required");
} else {
try {
const dateObj = new Date(date + "T00:00:00"); // Treat input as local date
if (isNaN(dateObj.getTime())) {
errors.push("Invalid date format");
}
} catch (e) {
errors.push("Invalid date format");
}
}
return errors;
};
// --- Event Handlers ---
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (isLoading || !currentAccountId) {
if (!currentAccountId) setError("No account selected.");
return;
}
const validationErrors = validateForm();
if (validationErrors.length > 0) {
setError(validationErrors.join(". "));
return;
}
setIsLoading(true);
try {
// Ensure date is sent in a consistent format (e.g., YYYY-MM-DD)
// The API should handle parsing this.
const transactionData = {
accountId: currentAccountId,
date: date, // Send as YYYY-MM-DD string
description: description.trim(),
amount: parseFloat(amount),
};
const method = editingId ? "PUT" : "POST";
const url = editingId
? `/api/transactions/${editingId}`
: "/api/transactions";
const response = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(transactionData),
});
if (!response.ok) {
let errorMsg = `Failed to ${
isEditMode ? "update" : "create"
} transaction`;
try {
const errorData = await response.json();
errorMsg = errorData.error || errorMsg;
} catch (jsonError) {
// Ignore if response is not JSON
errorMsg = `${response.status}: ${response.statusText}`;
}
throw new Error(errorMsg);
}
const savedTransaction: Transaction = await response.json();
transactionSaved(savedTransaction); // Call store action instead of prop callback
resetForm(); // Reset form on success
} catch (err) {
setError(
err instanceof Error ? err.message : "An unexpected error occurred"
);
} finally {
setIsLoading(false);
}
};
const handleCancel = () => {
resetForm();
cancelEditingTransaction(); // Call store action instead of prop callback
};
// --- JSX ---
return (
<form id="add-transaction-form-react" onSubmit={handleSubmit} noValidate>
<h4>{isEditMode ? "Edit Transaction" : "New Transaction"}</h4>
{error && <div className="error-message">{error}</div>}
<div className="form-group">
<label htmlFor="txn-date-react">Date</label>
<input
type="date"
id="txn-date-react"
name="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="txn-description-react">Description</label>
<input
type="text"
id="txn-description-react"
name="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
required
minLength={2}
maxLength={100}
placeholder="e.g. Groceries"
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="txn-amount-react">Amount</label>
<input
type="number"
id="txn-amount-react"
name="amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
step="0.01"
required
placeholder="e.g. -25.50 or 1200.00"
disabled={isLoading}
/>
<small className="help-text">
Use negative numbers for expenses (e.g., -50.00)
</small>
</div>
<div className="button-group">
<button
type="submit"
className={`form-submit-btn ${isLoading ? "loading" : ""}`}
disabled={isLoading}
>
{isLoading
? "Saving..."
: isEditMode
? "Update Transaction"
: "Save Transaction"}
</button>
{isEditMode && (
<button
type="button"
className="cancel-btn"
onClick={handleCancel}
disabled={isLoading}
>
Cancel
</button>
)}
</div>
</form>
);
}

View File

@@ -1,18 +1,16 @@
---
import TransactionTable from './TransactionTable.astro';
import type { Account, Transaction } from '../types';
import TransactionTable from './TransactionTable.tsx';
import type { Account } from '../types';
interface Props {
account: Account;
transactions: Transaction[];
}
const { account, transactions } = Astro.props;
const { account } = Astro.props;
---
<main class="main-content">
<header class="main-header">
<h1>Transactions for <span id="current-account-name">{account.name} (***{account.last4})</span></h1>
</header>
/* Make table updatable */
<TransactionTable transactions={transactions} client:load /> {}
<TransactionTable client:load />
</main>

View File

@@ -1,11 +1,10 @@
---
import AddTransactionForm from './AddTransactionForm.astro';
import AccountSummary from './AccountSummary.astro';
import AccountSummary from './AccountSummary.tsx'; // Import the React component instead of the Astro one
import AddTransactionForm from './AddTransactionForm.tsx';
import type { Account } from '../types';
interface Props {
accounts: Account[];
/* The account to show in the sidebar. */
initialAccount: Account;
}
@@ -14,6 +13,10 @@ const { accounts, initialAccount } = Astro.props;
<aside class="sidebar">
<div class="sidebar-header">
<h2>My finances</h2>
{/* Add button to toggle form visibility */}
<button id="toggle-add-txn-btn" aria-expanded="false" aria-controls="add-transaction-section">
+ New Txn
</button>
</div>
<nav class="account-nav">
<h3>Accounts</h3>
@@ -26,9 +29,36 @@ const { accounts, initialAccount } = Astro.props;
</select>
</nav>
<AccountSummary account={initialAccount} /> {}
{/* Use the React AccountSummary component, remove account prop */}
<AccountSummary client:load />
/* Make form toggle interactive */
<AddTransactionForm client:load /> {}
{/* Section to contain the React form, initially hidden */}
<section id="add-transaction-section" class="collapsible collapsed">
{/*
Use the React component here.
It now gets its state (currentAccountId, transactionToEdit)
directly from the Nano Store.
*/}
<AddTransactionForm client:load />
</section>
</aside>
</aside>
{/* Keep the script for toggling visibility for now */}
<script>
const toggleButton = document.getElementById('toggle-add-txn-btn');
const formSection = document.getElementById('add-transaction-section');
if (toggleButton && formSection) {
toggleButton.addEventListener('click', () => {
const isExpanded = toggleButton.getAttribute('aria-expanded') === 'true';
toggleButton.setAttribute('aria-expanded', String(!isExpanded));
formSection.classList.toggle('collapsed');
formSection.classList.toggle('expanded');
// Optional: Focus first field when expanding
if (!isExpanded) {
// Cast the result to HTMLElement before calling focus
(formSection.querySelector('input, select, textarea') as HTMLElement)?.focus();
}
});
}
</script>

View File

@@ -0,0 +1,184 @@
import React, { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import type { Transaction } from "../types";
import { formatCurrency, formatDate } from "../utils";
import {
startEditingTransaction,
currentAccountId as currentAccountIdStore,
triggerRefresh,
} from "../stores/transactionStore";
interface TransactionTableProps {}
export default function TransactionTable({}: TransactionTableProps) {
const currentAccountId = useStore(currentAccountIdStore);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!currentAccountId) {
setTransactions([]);
return;
}
const fetchTransactions = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/accounts/${currentAccountId}/transactions`
);
if (!response.ok) {
throw new Error("Failed to fetch transactions");
}
const data: Transaction[] = await response.json();
setTransactions(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "An unknown error occurred"
);
setTransactions([]);
} finally {
setIsLoading(false);
}
};
fetchTransactions();
}, [currentAccountId]);
const sortedTransactions = [...transactions].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
const handleDelete = async (txnId: string) => {
if (!confirm("Are you sure you want to delete this transaction?")) {
return;
}
console.log(`Attempting to delete transaction: ${txnId}`);
try {
const response = await fetch(`/api/transactions/${txnId}`, {
method: "DELETE",
});
if (!response.ok) {
let errorMsg = "Failed to delete transaction";
try {
const errorData = await response.json();
errorMsg = errorData.error || errorMsg;
} catch (jsonError) {
errorMsg = `${response.status}: ${response.statusText}`;
}
throw new Error(errorMsg);
}
console.log(`Transaction ${txnId} deleted successfully.`);
setTransactions((currentTransactions) =>
currentTransactions.filter((txn) => txn.id !== txnId)
);
triggerRefresh();
} catch (error) {
alert(
error instanceof Error ? error.message : "Failed to delete transaction"
);
console.error("Delete error:", error);
} finally {
}
};
const handleEdit = (transaction: Transaction) => {
console.log(`Attempting to edit transaction: ${transaction.id}`);
startEditingTransaction(transaction);
const addTransactionSection = document.getElementById(
"add-transaction-section"
);
const toggleAddTxnBtn = document.getElementById("toggle-add-txn-btn");
if (addTransactionSection?.classList.contains("collapsed")) {
addTransactionSection.classList.replace("collapsed", "expanded");
toggleAddTxnBtn?.setAttribute("aria-expanded", "true");
addTransactionSection.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
};
return (
<div id="transaction-section" className={isLoading ? "loading" : ""}>
{error && (
<div className="error-message" style={{ padding: "1rem" }}>
Error loading transactions: {error}
</div>
)}
<table className="transaction-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th className="amount-col">Amount</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="transaction-table-body">
{isLoading ? (
<tr>
<td colSpan={4} style={{ textAlign: "center", padding: "2rem" }}>
Loading transactions...
</td>
</tr>
) : sortedTransactions.length === 0 && !error ? (
<tr>
<td
colSpan={4}
style={{
textAlign: "center",
fontStyle: "italic",
color: "#777",
}}
>
No transactions found for this account.
</td>
</tr>
) : (
!error &&
sortedTransactions.map((txn) => (
<tr key={txn.id} data-txn-id={txn.id}>
<td>{formatDate(txn.date)}</td>
<td>{txn.description}</td>
<td
className={`amount-col ${
txn.amount >= 0 ? "amount-positive" : "amount-negative"
}`}
>
{formatCurrency(txn.amount)}
</td>
<td>
<button
className="action-btn edit-btn"
title="Edit transaction"
onClick={() => handleEdit(txn)}
>
Edit
</button>
<button
className="action-btn delete-btn"
title="Delete transaction"
onClick={() => handleDelete(txn.id)}
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
}

View File

@@ -4,6 +4,11 @@ import Sidebar from '../components/Sidebar.astro';
import MainContent from '../components/MainContent.astro';
import type { Account, Transaction } from '../types';
export interface Props {
account: Account;
transactions: Transaction[];
}
// Fetch accounts from API
const accountsResponse = await fetch('http://localhost:4321/api/accounts');
const accounts: Account[] = await accountsResponse.json();
@@ -48,7 +53,7 @@ TODO: Performance & Monitoring
<BaseLayout title="Bank Transactions Dashboard">
<div class="dashboard-layout">
<Sidebar accounts={accounts} initialAccount={initialAccount} />
<MainContent account={initialAccount} transactions={initialTransactions} />
<MainContent account={initialAccount} />
</div>
</BaseLayout>
@@ -56,36 +61,17 @@ TODO: Performance & Monitoring
// Import types for client-side script
type Transaction = import('../types').Transaction;
type Account = import('../types').Account;
type TransactionEventDetail = import('../types/events').TransactionEventDetail;
// Import store atoms and actions
import { currentAccountId, startEditingTransaction } from '../stores/transactionStore';
// --- DOM Elements ---
const accountSelect = document.getElementById('account-select') as HTMLSelectElement;
const currentAccountNameSpan = document.getElementById('current-account-name');
const accountBalanceSpan = document.getElementById('account-balance');
const transactionTableBody = document.getElementById('transaction-table-body');
const transactionSection = document.getElementById('transaction-section');
const addTransactionSection = document.getElementById('add-transaction-section');
const toggleAddTxnBtn = document.getElementById('toggle-add-txn-btn');
// --- Helper Functions ---
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
function formatDate(dateString: string): string {
const date = new Date(dateString + 'T00:00:00');
return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric' }).format(date);
}
async function fetchAccountTransactions(accountId: string): Promise<Transaction[]> {
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: string): Promise<Account | null> {
try {
const response = await fetch(`/api/accounts/${accountId}`);
@@ -97,157 +83,59 @@ TODO: Performance & Monitoring
}
}
// --- Update UI Function ---
// --- Update UI Function (Further Simplified) ---
async function updateUIForAccount(accountId: string): Promise<void> {
console.log("Updating UI for account:", accountId);
console.log("Updating Account Header for account:", accountId);
if (transactionSection) {
transactionSection.classList.add('loading');
}
// Update the store with the current account ID
currentAccountId.set(accountId);
// Only update the non-React part (header span)
currentAccountNameSpan?.classList.add('loading-inline');
try {
// Fetch latest account details and transactions
const [account, transactions] = await Promise.all([
fetchAccountDetails(accountId),
fetchAccountTransactions(accountId)
]);
const account = await fetchAccountDetails(accountId);
if (!account || !transactionTableBody || !currentAccountNameSpan || !accountBalanceSpan) {
console.error("Required data or UI elements not found!");
if (!account || !currentAccountNameSpan) {
console.error("Account data or header element not found!");
if (currentAccountNameSpan) currentAccountNameSpan.textContent = 'Error';
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>`;
}
console.error('Error updating account header:', error);
if (currentAccountNameSpan) currentAccountNameSpan.textContent = 'Error';
} finally {
if (transactionSection) {
transactionSection.classList.remove('loading');
}
currentAccountNameSpan?.classList.remove('loading-inline');
}
}
function updateTransactionTable(transactions: Transaction[]): void {
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;
}
sortedTransactions.forEach(txn => {
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);
});
setupTransactionButtons();
}
function setupTransactionButtons(): void {
const transactionRows = transactionTableBody?.querySelectorAll('tr[data-txn-id]');
transactionRows?.forEach(row => {
const txnId = row.getAttribute('data-txn-id');
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));
});
}
// --- Transaction Actions ---
async function handleDeleteTransaction(txnId: string): Promise<void> {
if (!confirm('Are you sure you want to delete this transaction?')) {
return;
}
if (transactionSection) {
transactionSection.classList.add('loading');
}
try {
const response = await fetch(`/api/transactions/${txnId}`, {
method: 'DELETE'
});
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');
}
}
}
async function handleEditTransaction(txnId: string): Promise<void> {
try {
// Find transaction in current transactions list
const currentAccountId = accountSelect?.value;
if (!currentAccountId) return;
const transactions = await fetchAccountTransactions(currentAccountId);
const accountId = currentAccountId.get();
if (!accountId) return;
const response = await fetch(`/api/accounts/${accountId}/transactions`);
if (!response.ok) throw new Error('Failed to fetch transactions for edit');
const transactions: Transaction[] = await response.json();
const transaction = transactions.find(t => t.id === txnId);
if (!transaction) {
throw new Error('Transaction not found');
throw new Error('Transaction not found for editing');
}
// Trigger edit mode in form
document.dispatchEvent(new CustomEvent<TransactionEventDetail>('editTransaction', {
detail: { transaction }
}));
startEditingTransaction(transaction);
// Manually expand the form section if it's collapsed
if (addTransactionSection?.classList.contains('collapsed')) {
addTransactionSection.classList.replace('collapsed', 'expanded');
toggleAddTxnBtn?.setAttribute('aria-expanded', 'true');
addTransactionSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to load transaction for editing');
@@ -262,24 +150,11 @@ TODO: Performance & Monitoring
});
}
// Listen for transaction events
document.addEventListener('transactionCreated', ((event: CustomEvent<TransactionEventDetail>) => {
const currentAccountId = accountSelect?.value;
if (currentAccountId) {
updateUIForAccount(currentAccountId);
}
}) as EventListener);
document.addEventListener('transactionUpdated', ((event: CustomEvent<TransactionEventDetail>) => {
const currentAccountId = accountSelect?.value;
if (currentAccountId) {
updateUIForAccount(currentAccountId);
}
}) as EventListener);
// Initial load with transactions if available
const initialAccountId = accountSelect?.value;
if (initialAccountId) {
updateUIForAccount(initialAccountId);
// --- Initial Load ---
const initialAccountIdValue = accountSelect?.value;
if (initialAccountIdValue) {
updateUIForAccount(initialAccountIdValue);
} else {
currentAccountId.set(null);
}
</script>

View File

@@ -0,0 +1,48 @@
import { atom } from "nanostores";
import type { Transaction } from "../types";
// Atom to hold the ID of the currently selected account
export const currentAccountId = atom<string | null>(null);
// Atom to hold the transaction object when editing, or null otherwise
export const transactionToEdit = atom<Transaction | null>(null);
// Atom to trigger refreshes in components that depend on external changes
export const refreshKey = atom<number>(0);
// Action to increment the refresh key, forcing dependent effects to re-run
export function triggerRefresh() {
refreshKey.set(refreshKey.get() + 1);
}
// Action to set the transaction to be edited
export function startEditingTransaction(transaction: Transaction) {
transactionToEdit.set(transaction);
// Optionally, trigger UI changes like expanding the form here
// document.getElementById('add-transaction-section')?.classList.replace('collapsed', 'expanded');
// document.getElementById('toggle-add-txn-btn')?.setAttribute('aria-expanded', 'true');
}
// Action to clear the edit state
export function cancelEditingTransaction() {
transactionToEdit.set(null);
// Optionally, collapse the form
// document.getElementById('add-transaction-section')?.classList.replace('expanded', 'collapsed');
// document.getElementById('toggle-add-txn-btn')?.setAttribute('aria-expanded', 'false');
}
// Action triggered after a transaction is saved (created or updated)
export function transactionSaved(transaction: Transaction) {
// Clear edit state if the saved transaction was the one being edited
if (transactionToEdit.get()?.id === transaction.id) {
transactionToEdit.set(null);
}
// Potentially trigger UI updates or refreshes here
// This might involve dispatching a custom event or calling a refresh function
document.dispatchEvent(
new CustomEvent("transactionSaved", { detail: { transaction } })
);
// Trigger a general refresh after saving too, to update balance
triggerRefresh();
}