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=."],
"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"
}

View File

@@ -76,3 +76,19 @@ 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.

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,5 @@
---
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[];
@@ -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.
*/
}
<!-- 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", () => {
<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 =
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();
toggleAddTxnBtn.getAttribute("aria-expanded") === "true";
toggleAddTxnBtn.setAttribute(
"aria-expanded",
isExpanded ? "false" : "true",
);
if (isExpanded) {
addTransactionForm?.classList.add("collapsed");
} else {
addTransactionForm?.classList.remove("collapsed");
}
});
// Sidebar toggle for mobile
const sidebarToggleBtn = document.getElementById("sidebar-toggle");
const sidebar = document.querySelector(".sidebar");
sidebarToggleBtn?.addEventListener("click", () => {
const isExpanded =
sidebarToggleBtn.getAttribute("aria-expanded") === "true";
sidebarToggleBtn.setAttribute(
"aria-expanded",
isExpanded ? "false" : "true",
);
if (isExpanded) {
sidebar?.classList.add("sidebar-collapsed");
} else {
sidebar?.classList.remove("sidebar-collapsed");
}
});
// Check if we're on mobile and collapse sidebar by default
const checkMobile = () => {
const isMobile = window.innerWidth < 1024;
if (isMobile && sidebar && sidebarToggleBtn) {
// Start with sidebar collapsed on mobile
sidebar.classList.add("sidebar-collapsed");
sidebarToggleBtn.setAttribute("aria-expanded", "false");
} else if (sidebar && sidebarToggleBtn) {
sidebar.classList.remove("sidebar-collapsed");
sidebarToggleBtn.setAttribute("aria-expanded", "true");
}
};
// Check on load and window resize
window.addEventListener("load", checkMobile);
window.addEventListener("resize", checkMobile);
</script>
<style>
.sidebar {
padding: 20px;
background-color: #f9fafb;
border-right: 1px solid #e5e7eb;
height: 100%;
overflow-y: auto;
}
.sidebar-collapsed {
display: none;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.account-nav {
margin-bottom: 15px;
}
.account-nav h3 {
margin-bottom: 8px;
font-size: 16px;
}
/* Ensure account summary is always visible */
.account-summary-section {
margin-bottom: 15px;
padding-bottom: 5px;
}
.add-transaction-section {
margin-bottom: 15px;
}
.add-transaction-section .toggle-form-btn {
display: block;
width: 100%;
margin-bottom: 0;
}
.collapsible-form.collapsed {
display: none;
}
.sidebar-toggle {
display: none;
position: fixed;
bottom: 20px;
right: 20px;
background-color: #f9fafb;
border: 1px solid #e5e7eb;
padding: 10px;
cursor: pointer;
z-index: 100;
width: auto;
min-width: 140px;
text-align: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.sidebar-toggle span {
pointer-events: none;
}
.sidebar-toggle-icon {
margin-left: 8px;
pointer-events: none;
}
@media (max-width: 1024px) {
.sidebar-toggle {
display: block;
}
}
</style>

View File

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

View File

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

View File

@@ -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>
<main>
<slot />
</main>
</body>
</html>

View File

@@ -32,30 +32,31 @@ 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;
};
try {
const updates = (await request.json()) as Partial<Transaction>;
// Check if transaction exists
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;
};
// Create a properly typed update object
const prepareUpdateData = (updates: Partial<Transaction>) => {
const updatedData: Partial<{
accountId: string;
date: Date;
@@ -68,7 +69,6 @@ export const PUT: APIRoute = async ({ request, params }) => {
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);
@@ -76,12 +76,10 @@ export const PUT: APIRoute = async ({ request, params }) => {
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;
}
@@ -90,9 +88,20 @@ export const PUT: APIRoute = async ({ request, params }) => {
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);
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;
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),

View File

@@ -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,11 +10,11 @@ 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",
id: '',
name: 'No accounts available',
accountNumber: '000000',
balance: 0,
bankName: "",
bankName: '',
};
// Fetch initial transactions if we have an account, using absolute URL
@@ -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(

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 {
/* 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;
}
.sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #e0e0e0;
max-height: none; /* Allow sidebar to grow */
.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;
}
.account-summary {
margin-top: 20px;
} /* Adjust spacing */
.main-content {
padding: 20px;
.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
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 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,

View File

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