Refactor transaction components and styles

- Updated imports to use absolute paths for types and utilities.
- Enhanced TransactionTable component with mobile responsiveness and card-based layout.
- Improved loading and error handling in transaction fetching logic.
- Refactored transaction update API to streamline validation and data preparation.
- Added new styles for Radix UI components and improved global styles for better mobile experience.
- Implemented collapsible sections and improved button interactions in the UI.
- Updated tests to reflect changes in component structure and imports.
This commit is contained in:
GitHub Copilot
2025-05-07 17:10:21 -04:00
parent 7b3f9afa1a
commit d8678e68ed
19 changed files with 1285 additions and 443 deletions

View File

@@ -1,13 +1,19 @@
---
import type { Account } from '../types';
import { formatCurrency } from '../utils';
import type { Account } from '@types';
// biome-ignore lint/correctness/noUnusedImports: formatCurrency is used in the template
import { formatCurrency } from '@utils/formatters';
interface Props {
account: Account;
}
const { account } = Astro.props;
---
<div class="account-summary">
<h4>Account Summary</h4>
<p>Balance: <span id="account-balance">{formatCurrency(account.balance)}</span></p>
</div>
<h4>Account Summary</h4>
<p>
Balance: <span id="account-balance"
>{formatCurrency(Number(account.balance))}</span
>
</p>
</div>

View File

@@ -1,8 +1,8 @@
import { useStore } from '@nanostores/react';
import { currentAccountId as currentAccountIdStore, refreshKey } from '@stores/transactionStore';
import type { Account } from '@types';
import { formatCurrency } from '@utils/formatters';
import { useEffect, useState } from 'react';
import { currentAccountId as currentAccountIdStore, refreshKey } from '../stores/transactionStore';
import type { Account } from '../types';
import { formatCurrency } from '../utils';
export default function AccountSummary() {
const currentAccountId = useStore(currentAccountIdStore);
@@ -11,6 +11,7 @@ export default function AccountSummary() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshCounter is needed to trigger refresh on transaction changes
useEffect(() => {
if (!currentAccountId) {
setAccount(null);
@@ -51,17 +52,17 @@ export default function AccountSummary() {
} else if (error) {
balanceContent = <span className="error-message">Error: {error}</span>;
} else if (account) {
balanceContent = formatCurrency(account.balance);
balanceContent = formatCurrency(Number(account.balance));
} else {
balanceContent = 'N/A';
}
return (
<div className="account-summary">
<h4>Account Summary</h4>
<p>
Balance: <span id="account-balance">{balanceContent}</span>
</p>
<div className="account-summary-header">
<h4>Account Summary</h4>
<div id="account-balance">{balanceContent}</div>
</div>
</div>
);
}

View File

@@ -1,13 +1,16 @@
import { useStore } from '@nanostores/react';
import React, { useEffect, useState } from 'react';
import * as Form from '@radix-ui/react-form';
import * as Toast from '@radix-ui/react-toast';
import {
cancelEditingTransaction,
currentAccountId,
loadTransactionsForAccount,
transactionSaved,
transactionToEdit,
triggerRefresh,
} from '../stores/transactionStore';
} from '@stores/transactionStore';
import type { Transaction } from '@types';
import type React from 'react';
import { useEffect, useState } from 'react';
export default function AddTransactionForm() {
const accountId = useStore(currentAccountId);
@@ -18,10 +21,10 @@ export default function AddTransactionForm() {
const [description, setDescription] = useState('');
const [amount, setAmount] = useState('');
const [category, setCategory] = useState('');
const [type, setType] = useState('WITHDRAWAL');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [toastOpen, setToastOpen] = useState(false);
// Set initial date only on client-side after component mounts
useEffect(() => {
@@ -30,14 +33,15 @@ export default function AddTransactionForm() {
const today = new Date();
setDate(today.toISOString().split('T')[0]);
}
}, []);
}, [date]);
// Reset form when accountId changes or when switching from edit to add mode
// biome-ignore lint/correctness/useExhaustiveDependencies: accountId is needed to trigger form reset when account changes
useEffect(() => {
if (!editingTransaction) {
resetForm();
}
}, [accountId, editingTransaction === null]);
}, [accountId, editingTransaction]); // accountId is intentionally included for form reset on account change
// Populate form when editing a transaction
useEffect(() => {
@@ -60,11 +64,11 @@ export default function AddTransactionForm() {
setDate(dateStr);
setDescription(editingTransaction.description);
setAmount(String(Math.abs(editingTransaction.amount)));
// Set amount directly as positive or negative value
setAmount(String(editingTransaction.amount));
setCategory(editingTransaction.category || '');
setType(editingTransaction.amount < 0 ? 'WITHDRAWAL' : 'DEPOSIT');
}
}, [editingTransaction]);
}, [editingTransaction]); // Add editingTransaction to dependencies
const resetForm = () => {
// Get today's date in YYYY-MM-DD format for the date input
@@ -73,21 +77,86 @@ export default function AddTransactionForm() {
setDescription('');
setAmount('');
setCategory('');
setType('WITHDRAWAL');
setError(null);
setSuccessMessage(null);
};
const validateFormData = () => {
if (!editingTransaction && !accountId) {
setError('No account selected. Please select an account before adding a transaction.');
return false;
}
if (!date || !description || !amount) {
setError('Date, description, and amount are required fields.');
return false;
}
return true;
};
const prepareTransactionData = () => {
const isEditing = !!editingTransaction;
return {
transaction: {
accountId: editingTransaction ? editingTransaction.accountId : accountId,
date,
description,
amount: Number.parseFloat(amount),
category: category || undefined,
},
url: isEditing ? `/api/transactions/${editingTransaction.id}` : '/api/transactions',
method: isEditing ? 'PUT' : 'POST',
};
};
const saveTransaction = async (url: string, method: string, data: Omit<Transaction, 'id'>) => {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json();
const isEditing = !!editingTransaction;
throw new Error(errorData.error || `Failed to ${isEditing ? 'update' : 'add'} transaction`);
}
return response.json() as Promise<Transaction>;
};
const handleSuccess = async (savedTransaction: Transaction) => {
const isEditing = !!editingTransaction;
setSuccessMessage(`Transaction ${isEditing ? 'updated' : 'added'} successfully!`);
setToastOpen(true);
resetForm();
if (isEditing) {
cancelEditingTransaction();
}
transactionSaved(savedTransaction);
// Reload transactions for affected accounts
const accountToReload = isEditing ? editingTransaction.accountId : accountId;
if (accountToReload) {
await loadTransactionsForAccount(accountToReload);
}
if (isEditing && accountId && editingTransaction.accountId !== accountId) {
await loadTransactionsForAccount(accountId);
}
};
const handleError = (err: unknown) => {
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred.';
setError(errorMessage);
const isEditing = !!editingTransaction;
console.error(`Transaction ${isEditing ? 'update' : 'submission'} error:`, err);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!accountId) {
setError('No account selected');
return;
}
if (!date || !description || !amount) {
setError('Date, description and amount are required');
if (!validateFormData()) {
return;
}
@@ -95,76 +164,12 @@ export default function AddTransactionForm() {
setError(null);
setSuccessMessage(null);
// Calculate final amount based on type
const finalAmount = type === 'DEPOSIT' ? Math.abs(Number(amount)) : -Math.abs(Number(amount));
try {
let response;
if (editingTransaction) {
// Update existing transaction
response = await fetch(`/api/transactions/${editingTransaction.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
accountId,
date,
description,
amount: finalAmount,
category: category || undefined,
type,
}),
});
} else {
// Create new transaction
response = await fetch('/api/transactions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
accountId,
date,
description,
amount: finalAmount,
category: category || undefined,
type,
}),
});
}
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Transaction operation failed');
}
const savedTransaction = await response.json();
// Handle success
setSuccessMessage(
editingTransaction
? 'Transaction updated successfully!'
: 'Transaction added successfully!',
);
// Reset form
resetForm();
// Clear editing state
if (editingTransaction) {
cancelEditingTransaction();
}
// Notify about saved transaction
transactionSaved(savedTransaction);
// Reload transactions to ensure the list is up to date
await loadTransactionsForAccount(accountId);
const { url, method, transaction } = prepareTransactionData();
const savedTransaction = await saveTransaction(url, method, transaction);
await handleSuccess(savedTransaction);
} catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred');
console.error('Transaction error:', err);
handleError(err);
} finally {
setIsSubmitting(false);
}
@@ -181,89 +186,138 @@ export default function AddTransactionForm() {
<div className="transaction-form-container">
<h3>{editingTransaction ? 'Edit Transaction' : 'Add Transaction'}</h3>
{successMessage && <div className="success-message">{successMessage}</div>}
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="txn-date">Date:</label>
<input
type="date"
id="txn-date"
value={date}
onChange={(e) => setDate(e.target.value)}
disabled={isSubmitting}
required
/>
</div>
<div className="form-group">
<label htmlFor="txn-description">Description:</label>
<input
type="text"
id="txn-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
placeholder="e.g., Grocery store"
required
/>
</div>
<div className="form-group amount-group">
<label htmlFor="txn-amount">Amount:</label>
<div className="amount-input-group">
<select value={type} onChange={(e) => setType(e.target.value)} disabled={isSubmitting}>
<option value="WITHDRAWAL">-</option>
<option value="DEPOSIT">+</option>
</select>
<span className="currency-symbol">$</span>
<input
type="number"
id="txn-amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={isSubmitting}
step="0.01"
min="0"
placeholder="0.00"
required
/>
<Toast.Provider swipeDirection="right">
{error && (
<div className="error-message" role="alert">
{error}
</div>
</div>
)}
<div className="form-group">
<label htmlFor="txn-category">Category (optional):</label>
<input
type="text"
id="txn-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
disabled={isSubmitting}
placeholder="e.g., Food, Bills, etc."
/>
</div>
<Form.Root onSubmit={handleSubmit} className="form-root">
<Form.Field className="form-field" name="date">
<div
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}
>
<Form.Label className="form-label">Date</Form.Label>
<Form.Message className="form-message" match="valueMissing">
Please enter a date
</Form.Message>
</div>
<Form.Control asChild>
<input
type="date"
className="form-input"
value={date}
onChange={(e) => setDate(e.target.value)}
disabled={isSubmitting}
required
/>
</Form.Control>
</Form.Field>
<div className="form-actions">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="cancel-btn"
>
Cancel
</button>
<button
type="submit"
// Allow submitting if we're editing a transaction, even if no account is currently selected
disabled={isSubmitting || (!editingTransaction && !accountId)}
className="submit-btn"
>
{isSubmitting ? 'Processing...' : editingTransaction ? 'Update' : 'Add'}
</button>
</div>
</form>
<Form.Field className="form-field" name="description">
<div
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}
>
<Form.Label className="form-label">Description</Form.Label>
<Form.Message className="form-message" match="valueMissing">
Please enter a description
</Form.Message>
</div>
<Form.Control asChild>
<input
type="text"
className="form-input"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isSubmitting}
placeholder="e.g., Grocery store"
required
/>
</Form.Control>
</Form.Field>
<Form.Field className="form-field" name="amount">
<div
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}
>
<Form.Label className="form-label">Amount (negative for expenses)</Form.Label>
<Form.Message className="form-message" match="valueMissing">
Please enter an amount
</Form.Message>
</div>
<div className="amount-input-wrapper">
<span className="currency-symbol">$</span>
<Form.Control asChild>
<input
type="number"
className="form-input amount-input"
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={isSubmitting}
step="0.01"
placeholder="0.00 (negative for expenses)"
required
/>
</Form.Control>
</div>
</Form.Field>
<Form.Field className="form-field" name="category">
<div
style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}
>
<Form.Label className="form-label">Category (optional)</Form.Label>
</div>
<Form.Control asChild>
<input
type="text"
className="form-input"
value={category}
onChange={(e) => setCategory(e.target.value)}
disabled={isSubmitting}
placeholder="e.g., Food, Bills, etc."
/>
</Form.Control>
</Form.Field>
<div className="form-actions">
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
className="cancel-btn"
>
Cancel
</button>
<Form.Submit asChild>
<button
type="submit"
// Allow submitting if we're editing a transaction, even if no account is currently selected
disabled={isSubmitting || (!editingTransaction && !accountId)}
className="submit-btn"
>
{isSubmitting ? 'Processing...' : editingTransaction ? 'Update' : 'Add'}
</button>
</Form.Submit>
</div>
</Form.Root>
<Toast.Root
className="toast-root"
open={toastOpen}
onOpenChange={setToastOpen}
duration={3000}
>
<Toast.Title className="toast-title">{successMessage}</Toast.Title>
<Toast.Action className="toast-action" asChild altText="Close">
<button type="button" className="toast-close-button">
</button>
</Toast.Action>
</Toast.Root>
<Toast.Viewport className="toast-viewport" />
</Toast.Provider>
</div>
);
}

View File

@@ -1,6 +1,5 @@
---
import type { Account } from '../types';
import TransactionTable from './TransactionTable.tsx';
import type { Account } from '@types';
interface Props {
account: Account;
@@ -8,9 +7,13 @@ interface Props {
const { account } = Astro.props;
---
<main class="main-content">
<header class="main-header">
<h1>Transactions for <span id="current-account-name">{account.name} (***{account.accountNumber.slice(-3)})</span></h1>
</header>
<TransactionTable client:load />
</main>
<header class="main-header">
<h1>
Transactions for <span id="current-account-name"
>{account.name} (***{account.accountNumber.slice(-3)})</span>
</h1>
</header>
<TransactionTable client:load />
</main>

View File

@@ -1,11 +1,9 @@
---
import type { Account } from "../types";
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[];
initialAccount: Account;
accounts: Account[];
initialAccount: Account;
}
const { accounts, initialAccount } = Astro.props;
@@ -14,18 +12,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>
<select id="account-select" name="account">
<select id="account-select" name="account" class="form-input">
{
accounts.map((account) => (
<option
@@ -39,43 +29,176 @@ const { accounts, initialAccount } = Astro.props;
</select>
</nav>
{/* Use the React AccountSummary component, remove account prop */}
<AccountSummary 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 />
<!-- Add Transaction Section with Toggle - Moved up to be right after account dropdown -->
<section id="add-transaction-section" class="add-transaction-section">
<button
type="button"
class="toggle-form-btn"
id="toggle-add-txn-btn"
aria-controls="add-transaction-form"
aria-expanded="false"
>
Add Transaction
</button>
<div id="add-transaction-form" class="collapsible-form collapsed">
<AddTransactionForm client:load />
</div>
</section>
<!-- Account Summary Section - Always visible -->
<div class="account-summary-section" id="account-summary-section">
<AccountSummary client:load />
</div>
</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");
<!-- Toggle button for sidebar on mobile -->
<button
type="button"
class="sidebar-toggle"
id="sidebar-toggle"
aria-controls="sidebar-content"
aria-expanded="true"
>
<span>Toggle sidebar</span>
<span class="sidebar-toggle-icon">▲</span>
</button>
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>
// Add Transaction form toggle
const toggleAddTxnBtn = document.getElementById("toggle-add-txn-btn");
const addTransactionForm = document.getElementById("add-transaction-form");
toggleAddTxnBtn?.addEventListener("click", () => {
const isExpanded =
toggleAddTxnBtn.getAttribute("aria-expanded") === "true";
toggleAddTxnBtn.setAttribute(
"aria-expanded",
isExpanded ? "false" : "true",
);
if (isExpanded) {
addTransactionForm?.classList.add("collapsed");
} else {
addTransactionForm?.classList.remove("collapsed");
}
});
// Sidebar toggle for mobile
const sidebarToggleBtn = document.getElementById("sidebar-toggle");
const sidebar = document.querySelector(".sidebar");
sidebarToggleBtn?.addEventListener("click", () => {
const isExpanded =
sidebarToggleBtn.getAttribute("aria-expanded") === "true";
sidebarToggleBtn.setAttribute(
"aria-expanded",
isExpanded ? "false" : "true",
);
if (isExpanded) {
sidebar?.classList.add("sidebar-collapsed");
} else {
sidebar?.classList.remove("sidebar-collapsed");
}
});
// Check if we're on mobile and collapse sidebar by default
const checkMobile = () => {
const isMobile = window.innerWidth < 1024;
if (isMobile && sidebar && sidebarToggleBtn) {
// Start with sidebar collapsed on mobile
sidebar.classList.add("sidebar-collapsed");
sidebarToggleBtn.setAttribute("aria-expanded", "false");
} else if (sidebar && sidebarToggleBtn) {
sidebar.classList.remove("sidebar-collapsed");
sidebarToggleBtn.setAttribute("aria-expanded", "true");
}
};
// Check on load and window resize
window.addEventListener("load", checkMobile);
window.addEventListener("resize", checkMobile);
</script>
<style>
.sidebar {
padding: 20px;
background-color: #f9fafb;
border-right: 1px solid #e5e7eb;
height: 100%;
overflow-y: auto;
}
.sidebar-collapsed {
display: none;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.account-nav {
margin-bottom: 15px;
}
.account-nav h3 {
margin-bottom: 8px;
font-size: 16px;
}
/* Ensure account summary is always visible */
.account-summary-section {
margin-bottom: 15px;
padding-bottom: 5px;
}
.add-transaction-section {
margin-bottom: 15px;
}
.add-transaction-section .toggle-form-btn {
display: block;
width: 100%;
margin-bottom: 0;
}
.collapsible-form.collapsed {
display: none;
}
.sidebar-toggle {
display: none;
position: fixed;
bottom: 20px;
right: 20px;
background-color: #f9fafb;
border: 1px solid #e5e7eb;
padding: 10px;
cursor: pointer;
z-index: 100;
width: auto;
min-width: 140px;
text-align: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.sidebar-toggle span {
pointer-events: none;
}
.sidebar-toggle-icon {
margin-left: 8px;
pointer-events: none;
}
@media (max-width: 1024px) {
.sidebar-toggle {
display: block;
}
}
</style>

View File

@@ -1,6 +1,5 @@
---
import type { Transaction } from '../types';
import { formatCurrency, formatDate } from '../utils';
import type { Transaction } from '@types';
interface Props {
transactions: Transaction[];
@@ -21,6 +20,7 @@ const sortedTransactions = [...transactions].sort(
// - Add transaction details expansion/collapse
// - Consider adding bulk actions (delete, categorize)
---
<section class="transaction-list" id="transaction-section">
<table id="transaction-table">
<thead>
@@ -32,24 +32,45 @@ const sortedTransactions = [...transactions].sort(
</tr>
</thead>
<tbody id="transaction-table-body">
{sortedTransactions.map(txn => (
<tr data-txn-id={txn.id}>
<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>
</tr>
))}
{sortedTransactions.length === 0 && (
<tr>
<td colspan="4" style="text-align: center; font-style: italic; color: #777;">No transactions found for this account.</td>
</tr>
)}
{
sortedTransactions.map((txn) => (
<tr data-txn-id={txn.id}>
<td>{formatDate(txn.date)}</td>
<td>{txn.description}</td>
<td
class={`amount-col ${Number(txn.amount) >= 0 ? "amount-positive" : "amount-negative"}`}
>
{formatCurrency(Number(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>
</tr>
))
}
{
sortedTransactions.length === 0 && (
<tr>
<td
colspan="4"
style="text-align: center; font-style: italic; color: #777;"
>
No transactions found for this account.
</td>
</tr>
)
}
</tbody>
</table>
</section>
</section>

View File

@@ -1,15 +1,15 @@
import { useStore } from '@nanostores/react';
import React, { useState, useEffect, useCallback } from 'react';
import {
currentAccountId as currentAccountIdStore,
currentTransactions as currentTransactionsStore,
loadTransactionsForAccount,
refreshKey,
startEditingTransaction,
triggerRefresh,
loadTransactionsForAccount,
} from '../stores/transactionStore';
import type { Transaction } from '../types';
import { formatCurrency, formatDate } from '../utils';
} from '@stores/transactionStore';
import type { Transaction } from '@types';
import { formatCurrency, formatDate } from '@utils/formatters';
import { useCallback, useEffect, useState } from 'react';
export default function TransactionTable() {
const currentAccountId = useStore(currentAccountIdStore);
@@ -18,6 +18,7 @@ export default function TransactionTable() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isMobileView, setIsMobileView] = useState(false);
// Fetch transactions when account ID changes or refresh is triggered
const fetchTransactions = useCallback(async () => {
@@ -42,11 +43,27 @@ export default function TransactionTable() {
}
}, [currentAccountId]);
// Effect for loading transactions when account changes or refresh is triggered
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshCounter is needed to trigger transaction reload when data changes
useEffect(() => {
fetchTransactions();
}, [fetchTransactions, refreshCounter]);
// Check for mobile view on mount and resize
useEffect(() => {
const checkViewport = () => {
setIsMobileView(window.innerWidth < 640);
};
// Check on mount
checkViewport();
// Add resize listener
window.addEventListener('resize', checkViewport);
// Clean up
return () => window.removeEventListener('resize', checkViewport);
}, []);
// Safe sort function that handles invalid dates gracefully
const safeSort = (transactions: Transaction[]) => {
if (!Array.isArray(transactions)) {
@@ -156,32 +173,84 @@ export default function TransactionTable() {
sortedTransactions.map((txn) => (
<tr key={txn.id} data-txn-id={txn.id}>
<td>{formatDate(txn.date)}</td>
<td>{txn.description || 'No description'}</td>
<td className={`amount-col ${txn.amount >= 0 ? 'amount-positive' : 'amount-negative'}`}>
{formatCurrency(txn.amount)}
<td className="description-col">{txn.description || 'No description'}</td>
<td
className={`amount-col ${Number(txn.amount) >= 0 ? 'amount-positive' : 'amount-negative'}`}
>
{formatCurrency(Number(txn.amount))}
</td>
<td>{txn.category || 'Uncategorized'}</td>
<td>
<td className="category-col">{txn.category || 'Uncategorized'}</td>
<td className="actions-col">
<button
type="button"
className="action-btn edit-btn"
title="Edit transaction"
onClick={() => handleEdit(txn)}
aria-label="Edit transaction"
>
Edit
<i className="fa-solid fa-pen-to-square button-icon" aria-hidden="true" tabIndex={-1} />
<span className="button-text">Edit</span>
</button>
<button
type="button"
className="action-btn delete-btn"
title="Delete transaction"
onClick={() => handleDelete(txn.id)}
aria-label="Delete transaction"
>
Delete
<i className="fa-solid fa-trash-can button-icon" aria-hidden="true" tabIndex={-1} />
<span className="button-text">Delete</span>
</button>
</td>
</tr>
));
// Mobile view - card-based rendering for transactions
const renderMobileCards = () =>
sortedTransactions.map((txn) => (
<div key={txn.id} className="transaction-card" data-txn-id={txn.id}>
<div className="transaction-card-header">
<div className="transaction-card-date">{formatDate(txn.date)}</div>
<div
className={`transaction-card-amount ${Number(txn.amount) >= 0 ? 'amount-positive' : 'amount-negative'}`}
>
{formatCurrency(Number(txn.amount))}
</div>
</div>
<div className="transaction-card-description">{txn.description || 'No description'}</div>
<div className="transaction-card-footer">
<div className="transaction-card-category">
<span className="transaction-card-label">Category:</span>{' '}
{txn.category || 'Uncategorized'}
</div>
<div className="transaction-card-actions">
<button
type="button"
className="action-btn edit-btn"
title="Edit transaction"
onClick={() => handleEdit(txn)}
aria-label="Edit transaction"
>
<i
className="fa-solid fa-pen-to-square button-icon"
aria-hidden="true"
tabIndex={-1}
/>
</button>
<button
type="button"
className="action-btn delete-btn"
title="Delete transaction"
onClick={() => handleDelete(txn.id)}
aria-label="Delete transaction"
>
<i className="fa-solid fa-trash-can button-icon" aria-hidden="true" tabIndex={-1} />
</button>
</div>
</div>
</div>
));
return (
<div id="transaction-section" className={isLoading ? 'loading' : ''}>
{error && (
@@ -189,24 +258,41 @@ export default function TransactionTable() {
Error loading transactions: {error}
</div>
)}
<table className="transaction-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th className="amount-col">Amount</th>
<th>Category</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="transaction-table-body">
{isLoading
? renderLoading()
: sortedTransactions.length === 0
? renderEmpty()
: renderRows()}
</tbody>
</table>
{isMobileView ? (
// Mobile view: card-based layout
<div className="transaction-cards">
{isLoading ? (
<div className="loading-message">Loading transactions...</div>
) : sortedTransactions.length === 0 ? (
<div className="empty-message">No transactions found for this account.</div>
) : (
renderMobileCards()
)}
</div>
) : (
// Desktop view: table layout
<div className="transaction-table-container">
<table className="transaction-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th className="amount-col">Amount</th>
<th>Category</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="transaction-table-body">
{isLoading
? renderLoading()
: sortedTransactions.length === 0
? renderEmpty()
: renderRows()}
</tbody>
</table>
</div>
)}
</div>
);
}