mirror of
https://github.com/acedanger/finance.git
synced 2025-12-06 07:00:13 -08:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user