mirror of
https://github.com/acedanger/finance.git
synced 2025-12-05 22:50:12 -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:
@@ -10,7 +10,9 @@
|
||||
},
|
||||
"runArgs": ["--dns", "8.8.8.8", "--dns", "8.8.4.4", "--network=host", "--dns-search=."],
|
||||
"containerEnv": {
|
||||
"HOSTALIASES": "/etc/host.aliases"
|
||||
"HOSTALIASES": "/etc/host.aliases",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${localEnv:GITHUB_PERSONAL_ACCESS_TOKEN}",
|
||||
"TZ": "America/New_York"
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
@@ -63,14 +65,10 @@
|
||||
},
|
||||
"terminal.integrated.defaultProfile.linux": "bash",
|
||||
"mcp.servers.github": {
|
||||
"command": "docker",
|
||||
"command": "bash",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"--env-file",
|
||||
"${containerWorkspaceFolder}/.devcontainer/.env",
|
||||
"ghcr.io/github/github-mcp-server"
|
||||
"-c",
|
||||
"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"
|
||||
],
|
||||
"env": {}
|
||||
}
|
||||
@@ -87,5 +85,8 @@
|
||||
"remoteEnv": {
|
||||
"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"
|
||||
}
|
||||
|
||||
18
README.md
18
README.md
@@ -75,4 +75,20 @@ import type { Transaction } from '@types';
|
||||
|
||||
// ❌ DON'T use relative imports like this
|
||||
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.
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { prisma } from './prisma';
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ interface Props {
|
||||
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -23,9 +24,16 @@ const { title } = Astro.props;
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<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>
|
||||
<body>
|
||||
<slot />
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -32,67 +32,76 @@ export const GET: APIRoute = async ({ params }) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const PUT: APIRoute = async ({ request, params }) => {
|
||||
const { id } = params;
|
||||
|
||||
const validateRequestParams = (id: string | undefined) => {
|
||||
if (!id) {
|
||||
return new Response(JSON.stringify({ error: 'Transaction ID is required' }), {
|
||||
status: 400,
|
||||
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 {
|
||||
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 existingTransaction = await transactionService.getById(id);
|
||||
|
||||
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);
|
||||
const updatedData = prepareUpdateData(updates);
|
||||
const updatedTransaction = await transactionService.update(params.id as string, updatedData);
|
||||
|
||||
if (!updatedTransaction) {
|
||||
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 = {
|
||||
...updatedTransaction,
|
||||
amount: Number(updatedTransaction.amount),
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
---
|
||||
import MainContent from "../components/MainContent.astro";
|
||||
import Sidebar from "../components/Sidebar.astro";
|
||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||
import type { Account, Transaction } from "../types";
|
||||
import type { Account, Transaction } from '@types';
|
||||
|
||||
// Get the base URL from the incoming request
|
||||
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
|
||||
const initialAccount: Account = accounts[0] || {
|
||||
id: "",
|
||||
name: "No accounts available",
|
||||
accountNumber: "000000",
|
||||
balance: 0,
|
||||
bankName: "",
|
||||
id: '',
|
||||
name: 'No accounts available',
|
||||
accountNumber: '000000',
|
||||
balance: 0,
|
||||
bankName: '',
|
||||
};
|
||||
|
||||
// Fetch initial transactions if we have an account, using absolute URL
|
||||
let initialTransactions: Transaction[] = [];
|
||||
if (initialAccount.id) {
|
||||
const transactionsResponse = await fetch(
|
||||
`${baseUrl}/api/accounts/${initialAccount.id}/transactions`,
|
||||
);
|
||||
initialTransactions = await transactionsResponse.json();
|
||||
const transactionsResponse = await fetch(
|
||||
`${baseUrl}/api/accounts/${initialAccount.id}/transactions`,
|
||||
);
|
||||
initialTransactions = await transactionsResponse.json();
|
||||
}
|
||||
---
|
||||
|
||||
@@ -44,7 +41,7 @@ if (initialAccount.id) {
|
||||
setTransactions,
|
||||
loadTransactionsForAccount,
|
||||
startEditingTransaction,
|
||||
} from "../stores/transactionStore";
|
||||
} from "@stores/transactionStore";
|
||||
|
||||
// Access server-rendered data which is available as globals
|
||||
const initialAccountData = JSON.parse(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// src/server.ts
|
||||
|
||||
import { IncomingMessage, Server, ServerResponse } from 'http';
|
||||
import { type BankAccount, PrismaClient } from '@prisma/client';
|
||||
import dotenv from 'dotenv';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
@@ -56,9 +53,16 @@ server.post(
|
||||
});
|
||||
reply.code(201);
|
||||
return newAccount;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
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);
|
||||
const body = createBankAccountSchema.parse(request.body);
|
||||
throw new Error(`Bank account with number ${body.accountNumber} already exists.`);
|
||||
@@ -97,14 +101,26 @@ server.post(
|
||||
data: updateData,
|
||||
});
|
||||
return updatedAccount;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
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
|
||||
reply.code(404);
|
||||
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);
|
||||
// Access accountNumber safely as it's optional in update
|
||||
const attemptedNumber = request.body.accountNumber || '(unchanged)';
|
||||
@@ -139,9 +155,14 @@ server.delete(
|
||||
where: { id: id }, // Use the validated numeric ID
|
||||
});
|
||||
return { message: `Bank account with ID ${id} deleted successfully.`, deletedAccount };
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
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
|
||||
reply.code(404);
|
||||
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' },
|
||||
});
|
||||
return accounts;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
server.log.error(error);
|
||||
reply.code(500);
|
||||
throw new Error('Failed to retrieve bank accounts.');
|
||||
@@ -195,7 +216,7 @@ server.get(
|
||||
throw new Error(`Bank account with ID ${id} not found.`);
|
||||
}
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
// Handle Zod validation errors (though should be caught by Fastify earlier)
|
||||
if (error instanceof z.ZodError) {
|
||||
reply.code(400);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Transaction } from '@types';
|
||||
import { atom } from 'nanostores';
|
||||
import type { Transaction } from '../types';
|
||||
|
||||
// Atom to hold the ID of the currently selected account
|
||||
export const currentAccountId = atom<string | null>(null);
|
||||
|
||||
@@ -1,67 +1,156 @@
|
||||
/* Paste the CSS from style-alt3.css here */
|
||||
/* Mobile-first styles */
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
background-color: #f0f2f5;
|
||||
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 {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Mobile-first sidebar */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.05);
|
||||
/* Ensure sidebar doesn't shrink */
|
||||
flex-shrink: 0;
|
||||
padding: 15px;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
/* Allow scrolling on mobile */
|
||||
overflow-y: auto;
|
||||
max-height: 60vh; /* Limit height on mobile */
|
||||
transition: max-height 0.3s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0 0 20px 0;
|
||||
color: #0056b3;
|
||||
.sidebar-collapsed {
|
||||
max-height: 180px; /* Just enough for header and account selector */
|
||||
}
|
||||
|
||||
.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 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 10px;
|
||||
font-size: 1em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.account-nav select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 25px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.add-transaction-section {
|
||||
margin-bottom: 25px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #e0e0e0;
|
||||
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 {
|
||||
background-color: #f8f9fa;
|
||||
color: #0056b3;
|
||||
border: none;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 12px 15px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
border-radius: 5px 5px 0 0;
|
||||
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 {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
@@ -69,44 +158,48 @@ body {
|
||||
.collapsible-form {
|
||||
padding: 15px;
|
||||
background-color: #fdfdfd;
|
||||
transition: max-height 0.3s ease-out, opacity 0.3s ease-out, padding 0.3s ease-out, border 0.3s
|
||||
ease-out;
|
||||
transition: max-height 0.3s ease-out, opacity 0.3s ease-out, padding 0.3s ease-out;
|
||||
overflow: hidden;
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
border-top: 1px solid #e0e0e0; /* Start with border */
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.collapsible-form.collapsed {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-top-color: transparent; /* Hide border when collapsed */
|
||||
}
|
||||
|
||||
.collapsible-form h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.9em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.form-group input[type="date"],
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="number"] {
|
||||
width: calc(100% - 18px); /* Account for padding */
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
font-size: 0.95em;
|
||||
box-sizing: border-box; /* Include padding in width calculation */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.collapsible-form button[type="submit"] {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
@@ -116,109 +209,241 @@ body {
|
||||
cursor: pointer;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.collapsible-form button[type="submit"]:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
.account-summary {
|
||||
padding-top: 20px;
|
||||
padding-top: 10px;
|
||||
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;
|
||||
color: #555;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#account-balance {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.account-summary p {
|
||||
margin: 5px 0;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
#account-balance {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex-grow: 1;
|
||||
padding: 20px 30px;
|
||||
padding: 15px;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.main-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.8em;
|
||||
font-size: 1.5em;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#current-account-name {
|
||||
font-style: italic;
|
||||
} /* Example styling */
|
||||
}
|
||||
|
||||
.transaction-list {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
overflow-x: auto; /* Enable horizontal scrolling for table on mobile */
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 500px; /* Ensure minimum width for better mobile experience */
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 15px;
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 0.9rem; /* Slightly smaller text on mobile */
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
font-size: 0.85em;
|
||||
text-transform: uppercase;
|
||||
color: #555;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.amount-col {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.amount-positive {
|
||||
color: #198754;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.amount-negative {
|
||||
color: #dc3545;
|
||||
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 {
|
||||
padding: 4px 8px;
|
||||
margin-right: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
margin-right: 3px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ccc;
|
||||
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 {
|
||||
background-color: #e9ecef;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background-color: #dde1e4;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border-color: #f5c6cb;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background-color: #f5c2c7;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #dc3545;
|
||||
background-color: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
padding: 8px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
@@ -228,7 +453,7 @@ tbody tr:hover {
|
||||
background-color: #d1e7dd;
|
||||
border: 1px solid #badbcc;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
padding: 8px;
|
||||
margin-bottom: 15px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
@@ -265,23 +490,42 @@ tbody tr:hover {
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Basic Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
max-height: none; /* Allow sidebar to grow */
|
||||
}
|
||||
.account-summary {
|
||||
margin-top: 20px;
|
||||
} /* Adjust spacing */
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
}
|
||||
/* Toast notifications - positioned better for mobile */
|
||||
.toast-viewport {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: calc(100% - 20px);
|
||||
max-width: 350px;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
z-index: 2147483647;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.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 */
|
||||
@@ -328,3 +572,69 @@ tbody tr:hover {
|
||||
right: 10px;
|
||||
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
189
src/styles/radix-ui.css
Normal 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%;
|
||||
}
|
||||
@@ -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 type { Transaction } from '@types';
|
||||
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
|
||||
const createMocks = vi.hoisted(() => ({
|
||||
@@ -34,7 +34,7 @@ vi.mock('@nanostores/react', () => ({
|
||||
useStore: createMocks.useStore,
|
||||
}));
|
||||
|
||||
vi.mock('../stores/transactionStore', () => ({
|
||||
vi.mock('@stores/transactionStore', () => ({
|
||||
currentAccountId: createMocks.currentAccountId,
|
||||
transactionToEdit: createMocks.transactionToEdit,
|
||||
refreshKey: createMocks.refreshKey,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Transaction } from '../types';
|
||||
import type { Transaction } from '@types';
|
||||
|
||||
export interface TransactionEventDetail {
|
||||
transaction: Transaction;
|
||||
|
||||
Reference in New Issue
Block a user