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

@@ -10,7 +10,9 @@
}, },
"runArgs": ["--dns", "8.8.8.8", "--dns", "8.8.4.4", "--network=host", "--dns-search=."], "runArgs": ["--dns", "8.8.8.8", "--dns", "8.8.4.4", "--network=host", "--dns-search=."],
"containerEnv": { "containerEnv": {
"HOSTALIASES": "/etc/host.aliases" "HOSTALIASES": "/etc/host.aliases",
"GITHUB_PERSONAL_ACCESS_TOKEN": "${localEnv:GITHUB_PERSONAL_ACCESS_TOKEN}",
"TZ": "America/New_York"
}, },
"customizations": { "customizations": {
"vscode": { "vscode": {
@@ -63,14 +65,10 @@
}, },
"terminal.integrated.defaultProfile.linux": "bash", "terminal.integrated.defaultProfile.linux": "bash",
"mcp.servers.github": { "mcp.servers.github": {
"command": "docker", "command": "bash",
"args": [ "args": [
"run", "-c",
"-i", "source ${containerWorkspaceFolder}/.devcontainer/library-scripts/load-env.sh && docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN=\"$GITHUB_PERSONAL_ACCESS_TOKEN\" ghcr.io/github/github-mcp-server"
"--rm",
"--env-file",
"${containerWorkspaceFolder}/.devcontainer/.env",
"ghcr.io/github/github-mcp-server"
], ],
"env": {} "env": {}
} }
@@ -87,5 +85,8 @@
"remoteEnv": { "remoteEnv": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${localEnv:GITHUB_PERSONAL_ACCESS_TOKEN}" "GITHUB_PERSONAL_ACCESS_TOKEN": "${localEnv:GITHUB_PERSONAL_ACCESS_TOKEN}"
}, },
"postStartCommand": "gh auth status || gh auth login" "initializeCommand": {
"load-env": "echo 'Preparing environment variables for container startup'"
},
"postStartCommand": "source ${containerWorkspaceFolder}/.devcontainer/library-scripts/load-env.sh && gh auth status || gh auth login"
} }

View File

@@ -76,3 +76,19 @@ import type { Transaction } from '@types';
// ❌ DON'T use relative imports like this // ❌ DON'T use relative imports like this
import { transactionService } from '../../../../data/db.service'; import { transactionService } from '../../../../data/db.service';
``` ```
### Enforcing Path Aliases with Biome.js
This project uses [Biome.js](https://biomejs.dev/) for code formatting and linting. Biome enforces the use of path aliases instead of relative imports. To run Biome checks:
```bash
npm run check
```
To automatically fix issues:
```bash
npm run check -- --apply
```
The Biome configuration (in `biome.json`) includes rules for import sorting and path alias enforcement. To customize the rules, edit the `biome.json` file.

View File

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

View File

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

View File

@@ -1,13 +1,16 @@
import { useStore } from '@nanostores/react'; 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 { import {
cancelEditingTransaction, cancelEditingTransaction,
currentAccountId, currentAccountId,
loadTransactionsForAccount, loadTransactionsForAccount,
transactionSaved, transactionSaved,
transactionToEdit, 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() { export default function AddTransactionForm() {
const accountId = useStore(currentAccountId); const accountId = useStore(currentAccountId);
@@ -18,10 +21,10 @@ export default function AddTransactionForm() {
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [type, setType] = useState('WITHDRAWAL');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = 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 // Set initial date only on client-side after component mounts
useEffect(() => { useEffect(() => {
@@ -30,14 +33,15 @@ export default function AddTransactionForm() {
const today = new Date(); const today = new Date();
setDate(today.toISOString().split('T')[0]); setDate(today.toISOString().split('T')[0]);
} }
}, []); }, [date]);
// Reset form when accountId changes or when switching from edit to add mode // 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(() => { useEffect(() => {
if (!editingTransaction) { if (!editingTransaction) {
resetForm(); resetForm();
} }
}, [accountId, editingTransaction === null]); }, [accountId, editingTransaction]); // accountId is intentionally included for form reset on account change
// Populate form when editing a transaction // Populate form when editing a transaction
useEffect(() => { useEffect(() => {
@@ -60,11 +64,11 @@ export default function AddTransactionForm() {
setDate(dateStr); setDate(dateStr);
setDescription(editingTransaction.description); setDescription(editingTransaction.description);
setAmount(String(Math.abs(editingTransaction.amount))); // Set amount directly as positive or negative value
setAmount(String(editingTransaction.amount));
setCategory(editingTransaction.category || ''); setCategory(editingTransaction.category || '');
setType(editingTransaction.amount < 0 ? 'WITHDRAWAL' : 'DEPOSIT');
} }
}, [editingTransaction]); }, [editingTransaction]); // Add editingTransaction to dependencies
const resetForm = () => { const resetForm = () => {
// Get today's date in YYYY-MM-DD format for the date input // Get today's date in YYYY-MM-DD format for the date input
@@ -73,21 +77,86 @@ export default function AddTransactionForm() {
setDescription(''); setDescription('');
setAmount(''); setAmount('');
setCategory(''); setCategory('');
setType('WITHDRAWAL');
setError(null); setError(null);
setSuccessMessage(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) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!accountId) { if (!validateFormData()) {
setError('No account selected');
return;
}
if (!date || !description || !amount) {
setError('Date, description and amount are required');
return; return;
} }
@@ -95,76 +164,12 @@ export default function AddTransactionForm() {
setError(null); setError(null);
setSuccessMessage(null); setSuccessMessage(null);
// Calculate final amount based on type
const finalAmount = type === 'DEPOSIT' ? Math.abs(Number(amount)) : -Math.abs(Number(amount));
try { try {
let response; const { url, method, transaction } = prepareTransactionData();
const savedTransaction = await saveTransaction(url, method, transaction);
if (editingTransaction) { await handleSuccess(savedTransaction);
// 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);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'An unknown error occurred'); handleError(err);
console.error('Transaction error:', err);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@@ -181,89 +186,138 @@ export default function AddTransactionForm() {
<div className="transaction-form-container"> <div className="transaction-form-container">
<h3>{editingTransaction ? 'Edit Transaction' : 'Add Transaction'}</h3> <h3>{editingTransaction ? 'Edit Transaction' : 'Add Transaction'}</h3>
{successMessage && <div className="success-message">{successMessage}</div>} <Toast.Provider swipeDirection="right">
{error && (
{error && <div className="error-message">{error}</div>} <div className="error-message" role="alert">
{error}
<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
/>
</div> </div>
</div> )}
<div className="form-group"> <Form.Root onSubmit={handleSubmit} className="form-root">
<label htmlFor="txn-category">Category (optional):</label> <Form.Field className="form-field" name="date">
<input <div
type="text" style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}
id="txn-category" >
value={category} <Form.Label className="form-label">Date</Form.Label>
onChange={(e) => setCategory(e.target.value)} <Form.Message className="form-message" match="valueMissing">
disabled={isSubmitting} Please enter a date
placeholder="e.g., Food, Bills, etc." </Form.Message>
/> </div>
</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"> <Form.Field className="form-field" name="description">
<button <div
type="button" style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}
onClick={handleCancel} >
disabled={isSubmitting} <Form.Label className="form-label">Description</Form.Label>
className="cancel-btn" <Form.Message className="form-message" match="valueMissing">
> Please enter a description
Cancel </Form.Message>
</button> </div>
<button <Form.Control asChild>
type="submit" <input
// Allow submitting if we're editing a transaction, even if no account is currently selected type="text"
disabled={isSubmitting || (!editingTransaction && !accountId)} className="form-input"
className="submit-btn" value={description}
> onChange={(e) => setDescription(e.target.value)}
{isSubmitting ? 'Processing...' : editingTransaction ? 'Update' : 'Add'} disabled={isSubmitting}
</button> placeholder="e.g., Grocery store"
</div> required
</form> />
</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> </div>
); );
} }

View File

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

View File

@@ -1,11 +1,9 @@
--- ---
import type { Account } from "../types"; import type { Account } from '@types';
import AccountSummary from "./AccountSummary.tsx"; // Import the React component instead of the Astro one
import AddTransactionForm from "./AddTransactionForm.tsx";
interface Props { interface Props {
accounts: Account[]; accounts: Account[];
initialAccount: Account; initialAccount: Account;
} }
const { accounts, initialAccount } = Astro.props; const { accounts, initialAccount } = Astro.props;
@@ -14,18 +12,10 @@ const { accounts, initialAccount } = Astro.props;
<aside class="sidebar"> <aside class="sidebar">
<div class="sidebar-header"> <div class="sidebar-header">
<h2>My finances</h2> <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> </div>
<nav class="account-nav"> <nav class="account-nav">
<h3>Accounts</h3> <h3>Accounts</h3>
<select id="account-select" name="account"> <select id="account-select" name="account" class="form-input">
{ {
accounts.map((account) => ( accounts.map((account) => (
<option <option
@@ -39,43 +29,176 @@ const { accounts, initialAccount } = Astro.props;
</select> </select>
</nav> </nav>
{/* Use the React AccountSummary component, remove account prop */} <!-- Add Transaction Section with Toggle - Moved up to be right after account dropdown -->
<AccountSummary client:load /> <section id="add-transaction-section" class="add-transaction-section">
<button
{/* Section to contain the React form, initially hidden */} type="button"
<section id="add-transaction-section" class="collapsible collapsed"> class="toggle-form-btn"
{ id="toggle-add-txn-btn"
/* aria-controls="add-transaction-form"
Use the React component here. aria-expanded="false"
It now gets its state (currentAccountId, transactionToEdit) >
directly from the Nano Store. Add Transaction
*/ </button>
} <div id="add-transaction-form" class="collapsible-form collapsed">
<AddTransactionForm client:load /> <AddTransactionForm client:load />
</div>
</section> </section>
<!-- Account Summary Section - Always visible -->
<div class="account-summary-section" id="account-summary-section">
<AccountSummary client:load />
</div>
</aside> </aside>
{/* Keep the script for toggling visibility for now */} <!-- Toggle button for sidebar on mobile -->
<script> <button
const toggleButton = document.getElementById("toggle-add-txn-btn"); type="button"
const formSection = document.getElementById("add-transaction-section"); 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) { <script>
toggleButton.addEventListener("click", () => { // Add Transaction form toggle
const isExpanded = const toggleAddTxnBtn = document.getElementById("toggle-add-txn-btn");
toggleButton.getAttribute("aria-expanded") === "true"; const addTransactionForm = document.getElementById("add-transaction-form");
toggleButton.setAttribute("aria-expanded", String(!isExpanded));
formSection.classList.toggle("collapsed"); toggleAddTxnBtn?.addEventListener("click", () => {
formSection.classList.toggle("expanded"); const isExpanded =
// Optional: Focus first field when expanding toggleAddTxnBtn.getAttribute("aria-expanded") === "true";
if (!isExpanded) { toggleAddTxnBtn.setAttribute(
// Cast the result to HTMLElement before calling focus "aria-expanded",
( isExpanded ? "false" : "true",
formSection.querySelector( );
"input, select, textarea",
) as HTMLElement if (isExpanded) {
)?.focus(); 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> </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 type { Transaction } from '@types';
import { formatCurrency, formatDate } from '../utils';
interface Props { interface Props {
transactions: Transaction[]; transactions: Transaction[];
@@ -21,6 +20,7 @@ const sortedTransactions = [...transactions].sort(
// - Add transaction details expansion/collapse // - Add transaction details expansion/collapse
// - Consider adding bulk actions (delete, categorize) // - Consider adding bulk actions (delete, categorize)
--- ---
<section class="transaction-list" id="transaction-section"> <section class="transaction-list" id="transaction-section">
<table id="transaction-table"> <table id="transaction-table">
<thead> <thead>
@@ -32,24 +32,45 @@ const sortedTransactions = [...transactions].sort(
</tr> </tr>
</thead> </thead>
<tbody id="transaction-table-body"> <tbody id="transaction-table-body">
{sortedTransactions.map(txn => ( {
<tr data-txn-id={txn.id}> sortedTransactions.map((txn) => (
<td>{formatDate(txn.date)}</td> <tr data-txn-id={txn.id}>
<td>{txn.description}</td> <td>{formatDate(txn.date)}</td>
<td class={`amount-col ${txn.amount >= 0 ? 'amount-positive' : 'amount-negative'}`}> <td>{txn.description}</td>
{formatCurrency(txn.amount)} <td
</td> class={`amount-col ${Number(txn.amount) >= 0 ? "amount-positive" : "amount-negative"}`}
<td> >
<button class="action-btn edit-btn" title="Edit transaction">Edit</button> {formatCurrency(Number(txn.amount))}
<button class="action-btn delete-btn" title="Delete transaction">Delete</button> </td>
</td> <td>
</tr> <button
))} class="action-btn edit-btn"
{sortedTransactions.length === 0 && ( title="Edit transaction"
<tr> >
<td colspan="4" style="text-align: center; font-style: italic; color: #777;">No transactions found for this account.</td> Edit
</tr> </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> </tbody>
</table> </table>
</section> </section>

View File

@@ -1,15 +1,15 @@
import { useStore } from '@nanostores/react'; import { useStore } from '@nanostores/react';
import React, { useState, useEffect, useCallback } from 'react';
import { import {
currentAccountId as currentAccountIdStore, currentAccountId as currentAccountIdStore,
currentTransactions as currentTransactionsStore, currentTransactions as currentTransactionsStore,
loadTransactionsForAccount,
refreshKey, refreshKey,
startEditingTransaction, startEditingTransaction,
triggerRefresh, triggerRefresh,
loadTransactionsForAccount, } from '@stores/transactionStore';
} from '../stores/transactionStore'; import type { Transaction } from '@types';
import type { Transaction } from '../types'; import { formatCurrency, formatDate } from '@utils/formatters';
import { formatCurrency, formatDate } from '../utils'; import { useCallback, useEffect, useState } from 'react';
export default function TransactionTable() { export default function TransactionTable() {
const currentAccountId = useStore(currentAccountIdStore); const currentAccountId = useStore(currentAccountIdStore);
@@ -18,6 +18,7 @@ export default function TransactionTable() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isMobileView, setIsMobileView] = useState(false);
// Fetch transactions when account ID changes or refresh is triggered // Fetch transactions when account ID changes or refresh is triggered
const fetchTransactions = useCallback(async () => { const fetchTransactions = useCallback(async () => {
@@ -42,11 +43,27 @@ export default function TransactionTable() {
} }
}, [currentAccountId]); }, [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(() => { useEffect(() => {
fetchTransactions(); fetchTransactions();
}, [fetchTransactions, refreshCounter]); }, [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 // Safe sort function that handles invalid dates gracefully
const safeSort = (transactions: Transaction[]) => { const safeSort = (transactions: Transaction[]) => {
if (!Array.isArray(transactions)) { if (!Array.isArray(transactions)) {
@@ -156,32 +173,84 @@ export default function TransactionTable() {
sortedTransactions.map((txn) => ( sortedTransactions.map((txn) => (
<tr key={txn.id} data-txn-id={txn.id}> <tr key={txn.id} data-txn-id={txn.id}>
<td>{formatDate(txn.date)}</td> <td>{formatDate(txn.date)}</td>
<td>{txn.description || 'No description'}</td> <td className="description-col">{txn.description || 'No description'}</td>
<td className={`amount-col ${txn.amount >= 0 ? 'amount-positive' : 'amount-negative'}`}> <td
{formatCurrency(txn.amount)} className={`amount-col ${Number(txn.amount) >= 0 ? 'amount-positive' : 'amount-negative'}`}
>
{formatCurrency(Number(txn.amount))}
</td> </td>
<td>{txn.category || 'Uncategorized'}</td> <td className="category-col">{txn.category || 'Uncategorized'}</td>
<td> <td className="actions-col">
<button <button
type="button" type="button"
className="action-btn edit-btn" className="action-btn edit-btn"
title="Edit transaction" title="Edit transaction"
onClick={() => handleEdit(txn)} 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>
<button <button
type="button" type="button"
className="action-btn delete-btn" className="action-btn delete-btn"
title="Delete transaction" title="Delete transaction"
onClick={() => handleDelete(txn.id)} 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> </button>
</td> </td>
</tr> </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 ( return (
<div id="transaction-section" className={isLoading ? 'loading' : ''}> <div id="transaction-section" className={isLoading ? 'loading' : ''}>
{error && ( {error && (
@@ -189,24 +258,41 @@ export default function TransactionTable() {
Error loading transactions: {error} Error loading transactions: {error}
</div> </div>
)} )}
<table className="transaction-table">
<thead> {isMobileView ? (
<tr> // Mobile view: card-based layout
<th>Date</th> <div className="transaction-cards">
<th>Description</th> {isLoading ? (
<th className="amount-col">Amount</th> <div className="loading-message">Loading transactions...</div>
<th>Category</th> ) : sortedTransactions.length === 0 ? (
<th>Actions</th> <div className="empty-message">No transactions found for this account.</div>
</tr> ) : (
</thead> renderMobileCards()
<tbody id="transaction-table-body"> )}
{isLoading </div>
? renderLoading() ) : (
: sortedTransactions.length === 0 // Desktop view: table layout
? renderEmpty() <div className="transaction-table-container">
: renderRows()} <table className="transaction-table">
</tbody> <thead>
</table> <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> </div>
); );
} }

View File

@@ -1,5 +1,3 @@
import type { Prisma, PrismaClient } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import type { Account, Transaction } from '@types'; import type { Account, Transaction } from '@types';
import { prisma } from './prisma'; import { prisma } from './prisma';

View File

@@ -14,6 +14,7 @@ interface Props {
const { title } = Astro.props; const { title } = Astro.props;
--- ---
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -23,9 +24,16 @@ const { title } = Astro.props;
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<title>{title}</title> <title>{title}</title>
<link rel="stylesheet" href="/src/styles/global.css"> <link rel="stylesheet" href="/src/styles/radix-ui.css" />
<link
rel="stylesheet"
href="/node_modules/@fortawesome/fontawesome-free/css/all.min.css"
/>
<link rel="stylesheet" href="/src/styles/global.css" />
</head> </head>
<body> <body>
<slot /> <main>
<slot />
</main>
</body> </body>
</html> </html>

View File

@@ -32,67 +32,76 @@ export const GET: APIRoute = async ({ params }) => {
} }
}; };
export const PUT: APIRoute = async ({ request, params }) => { const validateRequestParams = (id: string | undefined) => {
const { id } = params;
if (!id) { if (!id) {
return new Response(JSON.stringify({ error: 'Transaction ID is required' }), { return new Response(JSON.stringify({ error: 'Transaction ID is required' }), {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}); });
} }
return null;
};
const validateTransaction = async (
id: string,
transactionService: typeof import('@data/db.service').transactionService,
) => {
const existingTransaction = await transactionService.getById(id);
if (!existingTransaction) {
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
return existingTransaction;
};
const prepareUpdateData = (updates: Partial<Transaction>) => {
const updatedData: Partial<{
accountId: string;
date: Date;
description: string;
amount: number;
category: string | undefined;
status: TransactionStatus | undefined;
type: TransactionType | undefined;
notes: string | undefined;
tags: string | undefined;
}> = {};
if (updates.accountId !== undefined) updatedData.accountId = updates.accountId;
if (updates.description !== undefined) updatedData.description = updates.description;
if (updates.amount !== undefined) updatedData.amount = Number(updates.amount);
if (updates.category !== undefined) updatedData.category = updates.category || undefined;
if (updates.notes !== undefined) updatedData.notes = updates.notes || undefined;
if (updates.tags !== undefined) updatedData.tags = updates.tags || undefined;
if (updates.date !== undefined) {
updatedData.date = typeof updates.date === 'string' ? new Date(updates.date) : updates.date;
}
if (updates.status !== undefined) {
updatedData.status = updates.status as TransactionStatus;
}
if (updates.type !== undefined) {
updatedData.type = updates.type as TransactionType;
}
return updatedData;
};
export const PUT: APIRoute = async ({ request, params }) => {
const validationError = validateRequestParams(params.id);
if (validationError) return validationError;
try { try {
const updates = (await request.json()) as Partial<Transaction>; const updates = (await request.json()) as Partial<Transaction>;
const existingTransaction = await validateTransaction(params.id as string, transactionService);
if (existingTransaction instanceof Response) return existingTransaction;
// Check if transaction exists const updatedData = prepareUpdateData(updates);
const existingTransaction = await transactionService.getById(id); const updatedTransaction = await transactionService.update(params.id as string, updatedData);
if (!existingTransaction) {
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
// Create a properly typed update object
const updatedData: Partial<{
accountId: string;
date: Date;
description: string;
amount: number;
category: string | undefined;
status: TransactionStatus | undefined;
type: TransactionType | undefined;
notes: string | undefined;
tags: string | undefined;
}> = {};
// Copy the properties we want to update
if (updates.accountId !== undefined) updatedData.accountId = updates.accountId;
if (updates.description !== undefined) updatedData.description = updates.description;
if (updates.amount !== undefined) updatedData.amount = Number(updates.amount);
if (updates.category !== undefined) updatedData.category = updates.category || undefined;
if (updates.notes !== undefined) updatedData.notes = updates.notes || undefined;
if (updates.tags !== undefined) updatedData.tags = updates.tags || undefined;
// Convert date to Date object if it's a string
if (updates.date !== undefined) {
updatedData.date = typeof updates.date === 'string' ? new Date(updates.date) : updates.date;
}
// Cast status and type to enum types if provided
if (updates.status !== undefined) {
updatedData.status = updates.status as TransactionStatus;
}
if (updates.type !== undefined) {
updatedData.type = updates.type as TransactionType;
}
// Update the transaction using the service
// The service will automatically handle account balance adjustments
const updatedTransaction = await transactionService.update(id, updatedData);
if (!updatedTransaction) { if (!updatedTransaction) {
return new Response(JSON.stringify({ error: 'Failed to update transaction' }), { return new Response(JSON.stringify({ error: 'Failed to update transaction' }), {
@@ -101,7 +110,6 @@ export const PUT: APIRoute = async ({ request, params }) => {
}); });
} }
// Convert Decimal to number for response
const response = { const response = {
...updatedTransaction, ...updatedTransaction,
amount: Number(updatedTransaction.amount), amount: Number(updatedTransaction.amount),

View File

@@ -1,8 +1,5 @@
--- ---
import MainContent from "../components/MainContent.astro"; import type { Account, Transaction } from '@types';
import Sidebar from "../components/Sidebar.astro";
import BaseLayout from "../layouts/BaseLayout.astro";
import type { Account, Transaction } from "../types";
// Get the base URL from the incoming request // Get the base URL from the incoming request
const baseUrl = new URL(Astro.request.url).origin; const baseUrl = new URL(Astro.request.url).origin;
@@ -13,20 +10,20 @@ const accounts: Account[] = await accountsResponse.json();
// Initialize with first account or empty account if none exist // Initialize with first account or empty account if none exist
const initialAccount: Account = accounts[0] || { const initialAccount: Account = accounts[0] || {
id: "", id: '',
name: "No accounts available", name: 'No accounts available',
accountNumber: "000000", accountNumber: '000000',
balance: 0, balance: 0,
bankName: "", bankName: '',
}; };
// Fetch initial transactions if we have an account, using absolute URL // Fetch initial transactions if we have an account, using absolute URL
let initialTransactions: Transaction[] = []; let initialTransactions: Transaction[] = [];
if (initialAccount.id) { if (initialAccount.id) {
const transactionsResponse = await fetch( const transactionsResponse = await fetch(
`${baseUrl}/api/accounts/${initialAccount.id}/transactions`, `${baseUrl}/api/accounts/${initialAccount.id}/transactions`,
); );
initialTransactions = await transactionsResponse.json(); initialTransactions = await transactionsResponse.json();
} }
--- ---
@@ -44,7 +41,7 @@ if (initialAccount.id) {
setTransactions, setTransactions,
loadTransactionsForAccount, loadTransactionsForAccount,
startEditingTransaction, startEditingTransaction,
} from "../stores/transactionStore"; } from "@stores/transactionStore";
// Access server-rendered data which is available as globals // Access server-rendered data which is available as globals
const initialAccountData = JSON.parse( const initialAccountData = JSON.parse(

View File

@@ -1,6 +1,3 @@
// src/server.ts
import { IncomingMessage, Server, ServerResponse } from 'http';
import { type BankAccount, PrismaClient } from '@prisma/client'; import { type BankAccount, PrismaClient } from '@prisma/client';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import Fastify, { type FastifyInstance } from 'fastify'; import Fastify, { type FastifyInstance } from 'fastify';
@@ -56,9 +53,16 @@ server.post(
}); });
reply.code(201); reply.code(201);
return newAccount; return newAccount;
} catch (error: any) { } catch (error: unknown) {
server.log.error(error); server.log.error(error);
if (error.code === 'P2002' && error.meta?.target?.includes('accountNumber')) { if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'P2002' &&
'meta' in error &&
error.meta?.target?.includes('accountNumber')
) {
reply.code(409); reply.code(409);
const body = createBankAccountSchema.parse(request.body); const body = createBankAccountSchema.parse(request.body);
throw new Error(`Bank account with number ${body.accountNumber} already exists.`); throw new Error(`Bank account with number ${body.accountNumber} already exists.`);
@@ -97,14 +101,26 @@ server.post(
data: updateData, data: updateData,
}); });
return updatedAccount; return updatedAccount;
} catch (error: any) { } catch (error: unknown) {
server.log.error(error); server.log.error(error);
if (error.code === 'P2025') { if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'P2025'
) {
// Record to update not found // Record to update not found
reply.code(404); reply.code(404);
throw new Error(`Bank account with ID ${request.params.id} not found.`); throw new Error(`Bank account with ID ${request.params.id} not found.`);
} }
if (error.code === 'P2002' && error.meta?.target?.includes('accountNumber')) { if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'P2002' &&
'meta' in error &&
error.meta?.target?.includes('accountNumber')
) {
reply.code(409); reply.code(409);
// Access accountNumber safely as it's optional in update // Access accountNumber safely as it's optional in update
const attemptedNumber = request.body.accountNumber || '(unchanged)'; const attemptedNumber = request.body.accountNumber || '(unchanged)';
@@ -139,9 +155,14 @@ server.delete(
where: { id: id }, // Use the validated numeric ID where: { id: id }, // Use the validated numeric ID
}); });
return { message: `Bank account with ID ${id} deleted successfully.`, deletedAccount }; return { message: `Bank account with ID ${id} deleted successfully.`, deletedAccount };
} catch (error: any) { } catch (error: unknown) {
server.log.error(error); server.log.error(error);
if (error.code === 'P2025') { if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'P2025'
) {
// Record to delete not found // Record to delete not found
reply.code(404); reply.code(404);
throw new Error(`Bank account with ID ${request.params.id} not found.`); throw new Error(`Bank account with ID ${request.params.id} not found.`);
@@ -165,7 +186,7 @@ server.get(`${API_PREFIX}/`, async (request, reply): Promise<BankAccount[]> => {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
return accounts; return accounts;
} catch (error: any) { } catch (error: unknown) {
server.log.error(error); server.log.error(error);
reply.code(500); reply.code(500);
throw new Error('Failed to retrieve bank accounts.'); throw new Error('Failed to retrieve bank accounts.');
@@ -195,7 +216,7 @@ server.get(
throw new Error(`Bank account with ID ${id} not found.`); throw new Error(`Bank account with ID ${id} not found.`);
} }
return account; return account;
} catch (error: any) { } catch (error: unknown) {
// Handle Zod validation errors (though should be caught by Fastify earlier) // Handle Zod validation errors (though should be caught by Fastify earlier)
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
reply.code(400); reply.code(400);

View File

@@ -1,5 +1,5 @@
import type { Transaction } from '@types';
import { atom } from 'nanostores'; import { atom } from 'nanostores';
import type { Transaction } from '../types';
// Atom to hold the ID of the currently selected account // Atom to hold the ID of the currently selected account
export const currentAccountId = atom<string | null>(null); export const currentAccountId = atom<string | null>(null);

View File

@@ -1,67 +1,156 @@
/* Paste the CSS from style-alt3.css here */ /* Mobile-first styles */
body { body {
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
line-height: 1.6; line-height: 1.6;
margin: 0; margin: 0;
background-color: #f0f2f5; background-color: #f0f2f5;
color: #333; color: #333;
height: 100%;
overflow: hidden;
} }
html {
height: 100%;
box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
/* Mobile-first: Dashboard starts as column layout */
.dashboard-layout { .dashboard-layout {
display: flex; display: flex;
min-height: 100vh; flex-direction: column;
height: 100vh;
overflow: hidden;
width: 100%;
} }
/* Mobile-first sidebar */
.sidebar { .sidebar {
width: 280px; width: 100%;
background-color: #ffffff; background-color: #ffffff;
border-right: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 20px; padding: 15px;
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
/* Ensure sidebar doesn't shrink */ /* Allow scrolling on mobile */
flex-shrink: 0; overflow-y: auto;
max-height: 60vh; /* Limit height on mobile */
transition: max-height 0.3s ease;
z-index: 10;
} }
.sidebar-header h2 { .sidebar-collapsed {
margin: 0 0 20px 0; max-height: 180px; /* Just enough for header and account selector */
color: #0056b3;
} }
.sidebar-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
background-color: #f3f4f6;
border: none;
border-top: 1px solid #e0e0e0;
border-bottom: 1px solid #e0e0e0;
color: #555;
padding: 8px;
font-size: 0.9em;
cursor: pointer;
position: relative;
z-index: 20;
text-align: center;
}
.sidebar-toggle span {
pointer-events: none; /* Ensure clicks register on the button, not inner elements */
}
.sidebar-toggle-icon {
display: inline-block;
margin-left: 5px;
transition: transform 0.3s ease;
pointer-events: none; /* Ensure icon doesn't interfere with clicks */
}
.sidebar-toggle[aria-expanded="false"] .sidebar-toggle-icon {
transform: rotate(180deg);
}
/* Sidebar header */
.sidebar-header h2 {
margin: 0 0 15px 0;
color: #0056b3;
font-size: 1.5em;
}
.account-nav h3 { .account-nav h3 {
margin-top: 0; margin-top: 0;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 1em; font-size: 1em;
color: #555; color: #555;
} }
.account-nav select { .account-nav select {
width: 100%; width: 100%;
padding: 10px; padding: 10px;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
margin-bottom: 25px; margin-bottom: 15px;
font-size: 1em; font-size: 1em;
} }
.add-transaction-section { .add-transaction-section {
margin-bottom: 25px; margin-bottom: 15px;
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
border-radius: 5px; border-radius: 5px;
transition: max-height 0.3s ease;
} }
/* Collapsible sections */
.collapsible-section {
max-height: 1000px; /* Large enough to contain content */
overflow: hidden;
transition: max-height 0.3s ease-out;
}
.collapsed {
max-height: 0;
}
/* Toggle button for add transaction form */
.toggle-form-btn { .toggle-form-btn {
background-color: #f8f9fa; background-color: #f8f9fa;
color: #0056b3; color: #0056b3;
border: none; border: none;
border-bottom: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;
padding: 12px 15px; padding: 10px;
width: 100%; width: 100%;
text-align: left; text-align: left;
font-size: 1em; font-size: 1em;
cursor: pointer; cursor: pointer;
border-radius: 5px 5px 0 0; border-radius: 5px 5px 0 0;
font-weight: 500; font-weight: 500;
display: block; /* Ensure it takes full width */ display: flex;
justify-content: space-between;
align-items: center;
} }
.toggle-form-btn::after {
content: "▼";
font-size: 0.8em;
transition: transform 0.3s ease;
}
.toggle-form-btn[aria-expanded="false"]::after {
transform: rotate(180deg);
}
.toggle-form-btn:hover { .toggle-form-btn:hover {
background-color: #e9ecef; background-color: #e9ecef;
} }
@@ -69,44 +158,48 @@ body {
.collapsible-form { .collapsible-form {
padding: 15px; padding: 15px;
background-color: #fdfdfd; background-color: #fdfdfd;
transition: max-height 0.3s ease-out, opacity 0.3s ease-out, padding 0.3s ease-out, border 0.3s transition: max-height 0.3s ease-out, opacity 0.3s ease-out, padding 0.3s ease-out;
ease-out;
overflow: hidden; overflow: hidden;
max-height: 500px; max-height: 500px;
opacity: 1; opacity: 1;
border-top: 1px solid #e0e0e0; /* Start with border */ border-top: 1px solid #e0e0e0;
} }
.collapsible-form.collapsed { .collapsible-form.collapsed {
max-height: 0; max-height: 0;
opacity: 0; opacity: 0;
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;
border-top-color: transparent; /* Hide border when collapsed */
} }
.collapsible-form h4 { .collapsible-form h4 {
margin-top: 0; margin-top: 0;
margin-bottom: 15px; margin-bottom: 15px;
font-size: 1.1em; font-size: 1.1em;
} }
.form-group { .form-group {
margin-bottom: 12px; margin-bottom: 12px;
} }
.form-group label { .form-group label {
display: block; display: block;
margin-bottom: 4px; margin-bottom: 4px;
font-size: 0.9em; font-size: 0.9em;
color: #555; color: #555;
} }
.form-group input[type="date"], .form-group input[type="date"],
.form-group input[type="text"], .form-group input[type="text"],
.form-group input[type="number"] { .form-group input[type="number"] {
width: calc(100% - 18px); /* Account for padding */ width: 100%;
padding: 8px; padding: 8px;
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 3px; border-radius: 3px;
font-size: 0.95em; font-size: 0.95em;
box-sizing: border-box; /* Include padding in width calculation */ box-sizing: border-box;
} }
.collapsible-form button[type="submit"] { .collapsible-form button[type="submit"] {
background-color: #007bff; background-color: #007bff;
color: white; color: white;
@@ -116,109 +209,241 @@ body {
cursor: pointer; cursor: pointer;
margin-top: 5px; margin-top: 5px;
} }
.collapsible-form button[type="submit"]:hover { .collapsible-form button[type="submit"]:hover {
background-color: #0056b3; background-color: #0056b3;
} }
.account-summary { .account-summary {
padding-top: 20px; padding-top: 10px;
border-top: 1px solid #eee; border-top: 1px solid #eee;
margin-bottom: 15px;
} }
.account-summary h4 {
margin: 0 0 10px 0; .account-summary-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.account-summary-header h4 {
margin: 0;
font-size: 1em; font-size: 1em;
color: #555; color: #555;
flex: 1;
} }
#account-balance {
font-weight: bold;
color: #333;
font-size: 1.1em;
}
.account-summary p { .account-summary p {
margin: 5px 0; margin: 5px 0;
font-size: 0.95em; font-size: 0.95em;
} }
#account-balance {
font-weight: bold;
color: #333;
}
.main-content { .main-content {
flex-grow: 1; flex-grow: 1;
padding: 20px 30px; padding: 15px;
overflow-y: auto; overflow-y: auto;
width: 100%;
} }
.main-header { .main-header {
margin-bottom: 20px; margin-bottom: 15px;
padding-bottom: 15px; padding-bottom: 10px;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
} }
.main-header h1 { .main-header h1 {
margin: 0; margin: 0;
font-size: 1.8em; font-size: 1.5em;
font-weight: 500; font-weight: 500;
color: #333; color: #333;
} }
#current-account-name { #current-account-name {
font-style: italic; font-style: italic;
} /* Example styling */ }
.transaction-list { .transaction-list {
background-color: #fff; background-color: #fff;
padding: 20px; padding: 10px;
border-radius: 5px; border-radius: 5px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
overflow-x: auto; /* Enable horizontal scrolling for table on mobile */
} }
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
min-width: 500px; /* Ensure minimum width for better mobile experience */
} }
th, th,
td { td {
padding: 12px 15px; padding: 8px 10px;
text-align: left; text-align: left;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
font-size: 0.9rem; /* Slightly smaller text on mobile */
} }
th { th {
background-color: #f8f9fa; background-color: #f8f9fa;
font-weight: 600; font-weight: 600;
font-size: 0.9em; font-size: 0.85em;
text-transform: uppercase; text-transform: uppercase;
color: #555; color: #555;
white-space: nowrap;
} }
tbody tr:hover { tbody tr:hover {
background-color: #f1f3f5; background-color: #f1f3f5;
} }
.amount-col { .amount-col {
text-align: right; text-align: right;
} }
.amount-positive { .amount-positive {
color: #198754; color: #198754;
font-weight: 500; font-weight: 500;
} }
.amount-negative { .amount-negative {
color: #dc3545; color: #dc3545;
font-weight: 500; font-weight: 500;
} }
/* Mobile Transaction Cards */
.transaction-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.transaction-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
padding: 12px;
position: relative;
}
.transaction-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.transaction-card-date {
font-size: 0.85rem;
color: #555;
font-weight: 500;
}
.transaction-card-amount {
font-weight: 600;
font-size: 1.1rem;
}
.transaction-card-description {
font-size: 1rem;
font-weight: 500;
margin-bottom: 4px;
word-break: break-word;
}
.transaction-card-category {
font-size: 0.85rem;
color: #666;
margin-bottom: 0; /* Reduce space below category */
display: inline-block; /* Allow actions to float beside it */
}
.transaction-card-label {
color: #777;
font-size: 0.8rem;
}
.transaction-card-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 0; /* Remove top margin */
float: right; /* Float actions to the right of category */
}
.empty-message,
.loading-message {
text-align: center;
padding: 20px;
color: #666;
background-color: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
/* Mobile-optimized action buttons */
.action-btn { .action-btn {
padding: 4px 8px; display: inline-flex;
margin-right: 5px; align-items: center;
justify-content: center;
padding: 6px;
margin-right: 3px;
cursor: pointer; cursor: pointer;
border-radius: 3px; border-radius: 4px;
border: 1px solid #ccc; border: 1px solid #ccc;
font-size: 0.85em; font-size: 0.85em;
min-height: 32px;
min-width: 32px;
} }
.button-text {
display: none; /* Hide text on mobile by default */
}
.action-btn:hover {
transform: translateY(-1px);
}
.action-btn:active {
transform: translateY(0);
}
.button-icon {
flex-shrink: 0;
}
.edit-btn { .edit-btn {
background-color: #e9ecef; background-color: #e9ecef;
color: #333; color: #333;
} }
.edit-btn:hover {
background-color: #dde1e4;
}
.delete-btn { .delete-btn {
background-color: #f8d7da; background-color: #f8d7da;
color: #721c24; color: #721c24;
border-color: #f5c6cb; border-color: #f5c6cb;
} }
.delete-btn:hover {
background-color: #f5c2c7;
}
.error-message { .error-message {
color: #dc3545; color: #dc3545;
background-color: #f8d7da; background-color: #f8d7da;
border: 1px solid #f5c6cb; border: 1px solid #f5c6cb;
border-radius: 4px; border-radius: 4px;
padding: 8px 12px; padding: 8px;
margin-bottom: 15px; margin-bottom: 15px;
font-size: 0.9em; font-size: 0.9em;
} }
@@ -228,7 +453,7 @@ tbody tr:hover {
background-color: #d1e7dd; background-color: #d1e7dd;
border: 1px solid #badbcc; border: 1px solid #badbcc;
border-radius: 4px; border-radius: 4px;
padding: 8px 12px; padding: 8px;
margin-bottom: 15px; margin-bottom: 15px;
font-size: 0.9em; font-size: 0.9em;
} }
@@ -265,23 +490,42 @@ tbody tr:hover {
font-size: 0.85em; font-size: 0.85em;
} }
/* Basic Responsive */ /* Toast notifications - positioned better for mobile */
@media (max-width: 768px) { .toast-viewport {
.dashboard-layout { position: fixed;
flex-direction: column; bottom: 10px;
} right: 10px;
.sidebar { display: flex;
width: 100%; flex-direction: column;
border-right: none; gap: 10px;
border-bottom: 1px solid #e0e0e0; width: calc(100% - 20px);
max-height: none; /* Allow sidebar to grow */ max-width: 350px;
} margin: 0;
.account-summary { list-style: none;
margin-top: 20px; z-index: 2147483647;
} /* Adjust spacing */ outline: none;
.main-content { }
padding: 20px;
} .toast-root {
background-color: white;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
padding: 15px;
display: flex;
align-items: center;
justify-content: space-between;
}
.toast-title {
margin: 0;
font-weight: 500;
}
.toast-close-button {
border: none;
background: transparent;
cursor: pointer;
color: #999;
} }
/* Loading States */ /* Loading States */
@@ -328,3 +572,69 @@ tbody tr:hover {
right: 10px; right: 10px;
border-width: 2px; border-width: 2px;
} }
/* Tablet breakpoint */
@media (min-width: 640px) {
.sidebar {
padding: 20px;
max-height: none;
}
.main-content {
padding: 20px;
}
th,
td {
padding: 10px 12px;
font-size: 1rem;
}
.action-btn {
padding: 6px 8px;
margin-right: 5px;
}
.button-text {
display: none; /* Still hide text on tablets */
}
}
/* Desktop breakpoint */
@media (min-width: 1024px) {
.dashboard-layout {
flex-direction: row;
}
.sidebar {
width: 300px;
border-right: 1px solid #e0e0e0;
border-bottom: none;
max-height: 100vh;
}
.sidebar-toggle {
display: none; /* Hide sidebar toggle on desktop */
}
.collapsible-section {
max-height: none !important; /* Always expanded on desktop */
}
.main-content {
padding: 25px 30px;
}
.action-btn {
padding: 6px 12px;
}
.button-text {
display: inline; /* Show text on desktop */
margin-left: 5px;
}
.sidebar-header h2 {
font-size: 1.8em;
}
}

189
src/styles/radix-ui.css Normal file
View File

@@ -0,0 +1,189 @@
/* Radix UI Form Styles */
.form-root {
width: 100%;
box-sizing: border-box;
}
.form-field {
display: flex;
flex-direction: column;
margin-bottom: 16px;
width: 100%;
}
.form-label {
font-size: 14px;
font-weight: 500;
margin-bottom: 6px;
color: #333;
}
.form-message {
font-size: 12px;
color: #e11d48;
}
.form-input {
padding: 8px 12px;
border-radius: 4px;
border: 1px solid #d1d5db;
font-size: 14px;
width: 100%;
box-sizing: border-box;
background-color: white;
transition: border-color 0.2s;
height: 38px; /* Match height of dropdown */
}
.form-input:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.2);
}
.form-input:disabled {
background-color: #f3f4f6;
cursor: not-allowed;
}
/* Amount input specific styling */
.amount-input-wrapper {
position: relative;
display: flex;
align-items: center;
width: 100%;
}
.currency-symbol {
position: absolute;
left: 12px;
color: #666;
font-size: 14px;
}
.amount-input {
padding-left: 24px;
}
/* Toast Notifications */
.toast-viewport {
position: fixed;
bottom: 0;
right: 0;
display: flex;
flex-direction: column;
padding: 16px;
gap: 8px;
width: 390px;
max-width: 100vw;
margin: 0;
list-style: none;
z-index: 2147483647;
outline: none;
}
.toast-root {
background-color: #fff;
border-radius: 6px;
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
border-left: 4px solid #22c55e;
margin-bottom: 8px;
}
.toast-title {
font-weight: 500;
color: #111;
font-size: 14px;
}
.toast-close-button {
background: transparent;
border: none;
color: #666;
cursor: pointer;
padding: 4px;
border-radius: 4px;
}
.toast-close-button:hover {
background-color: #f3f4f6;
}
/* Error message */
.error-message {
background-color: #fee2e2;
color: #b91c1c;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 14px;
border-left: 4px solid #b91c1c;
width: 100%;
box-sizing: border-box;
}
/* Form Actions */
.form-actions {
display: flex;
justify-content: space-between;
gap: 8px;
margin-top: 16px;
width: 100%;
}
.cancel-btn {
padding: 8px 16px;
background-color: transparent;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 14px;
color: #4b5563;
cursor: pointer;
transition: background-color 0.2s;
height: 38px;
flex: 1;
}
.cancel-btn:hover:not(:disabled) {
background-color: #f3f4f6;
}
.submit-btn {
padding: 8px 16px;
background-color: #2563eb;
border: none;
border-radius: 4px;
font-size: 14px;
color: white;
cursor: pointer;
transition: background-color 0.2s;
height: 38px;
flex: 1;
}
.submit-btn:hover:not(:disabled) {
background-color: #1d4ed8;
}
.submit-btn:disabled,
.cancel-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Sidebar component adjustments */
.sidebar select,
.sidebar button,
.sidebar input {
width: 100%;
box-sizing: border-box;
}
/* Make date picker fill entire width */
input[type="date"].form-input {
width: 100%;
}

View File

@@ -1,8 +1,8 @@
import AddTransactionForm from '@components/AddTransactionForm';
import TransactionTable from '@components/TransactionTable';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { Transaction } from '@types';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import AddTransactionForm from '../components/AddTransactionForm';
import TransactionTable from '../components/TransactionTable';
import type { Transaction } from '../types';
// Create hoisted mocks that are safe to use in vi.mock // Create hoisted mocks that are safe to use in vi.mock
const createMocks = vi.hoisted(() => ({ const createMocks = vi.hoisted(() => ({
@@ -34,7 +34,7 @@ vi.mock('@nanostores/react', () => ({
useStore: createMocks.useStore, useStore: createMocks.useStore,
})); }));
vi.mock('../stores/transactionStore', () => ({ vi.mock('@stores/transactionStore', () => ({
currentAccountId: createMocks.currentAccountId, currentAccountId: createMocks.currentAccountId,
transactionToEdit: createMocks.transactionToEdit, transactionToEdit: createMocks.transactionToEdit,
refreshKey: createMocks.refreshKey, refreshKey: createMocks.refreshKey,

View File

@@ -1,4 +1,4 @@
import type { Transaction } from '../types'; import type { Transaction } from '@types';
export interface TransactionEventDetail { export interface TransactionEventDetail {
transaction: Transaction; transaction: Transaction;