mirror of
https://github.com/acedanger/finance.git
synced 2025-12-05 22:50:12 -08:00
Merge pull request #6 from acedanger/feature/1-add-api-endpoints
Feature/1 add api endpoints
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
export default new Map();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export default new Map();
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.7.5","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[]},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"responsiveImages\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false},\"legacy\":{\"collections\":false}}"]
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"_variables": {
|
|
||||||
"lastUpdateCheck": 1745454555490
|
|
||||||
}
|
|
||||||
}
|
|
||||||
2
.astro/types.d.ts
vendored
2
.astro/types.d.ts
vendored
@@ -1,2 +0,0 @@
|
|||||||
/// <reference types="astro/client" />
|
|
||||||
/// <reference path="content.d.ts" />
|
|
||||||
29
.github/copilot-instructions.md
vendored
29
.github/copilot-instructions.md
vendored
@@ -9,7 +9,7 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele
|
|||||||
* **Framework:** Astro (latest version)
|
* **Framework:** Astro (latest version)
|
||||||
* **Language:** TypeScript, JavaScript (client-side scripts), HTML, CSS
|
* **Language:** TypeScript, JavaScript (client-side scripts), HTML, CSS
|
||||||
* **Styling:** Plain CSS (`src/styles/global.css`)
|
* **Styling:** Plain CSS (`src/styles/global.css`)
|
||||||
* **Data:** Currently using in-memory arrays. **The goal is to eventually integrate with a backend API.**
|
* **Data:** Using Astro's built-in API routes in `src/pages/api/` with a temporary in-memory store (`src/data/store.ts`). **The goal is to eventually replace the in-memory store with a persistent database.**
|
||||||
|
|
||||||
## Current State & Key Features
|
## Current State & Key Features
|
||||||
|
|
||||||
@@ -17,7 +17,18 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele
|
|||||||
* **Sidebar:** (`src/components/Sidebar.astro`) Contains account selection dropdown and a collapsible section for adding new transactions. Includes an account summary section.
|
* **Sidebar:** (`src/components/Sidebar.astro`) Contains account selection dropdown and a collapsible section for adding new transactions. Includes an account summary section.
|
||||||
* **Main Content:** (`src/components/MainContent.astro`) Displays the header with the current account name and the transaction list.
|
* **Main Content:** (`src/components/MainContent.astro`) Displays the header with the current account name and the transaction list.
|
||||||
* **Components:** Separate Astro components exist for major UI sections (Sidebar, MainContent, TransactionTable, AddTransactionForm, AccountSummary).
|
* **Components:** Separate Astro components exist for major UI sections (Sidebar, MainContent, TransactionTable, AddTransactionForm, AccountSummary).
|
||||||
* **Data Loading:** Currently using empty arrays for accounts and transactions, initialized in `src/pages/index.astro`.
|
* **API Integration:**
|
||||||
|
* API routes structure implemented in `src/pages/api/`
|
||||||
|
* Temporary data store in `src/data/store.ts`
|
||||||
|
* All API endpoints implemented and ready to use:
|
||||||
|
* GET /api/accounts - List all accounts
|
||||||
|
* GET /api/accounts/:id - Get single account details
|
||||||
|
* GET /api/accounts/:id/transactions - Get transactions for an account
|
||||||
|
* POST /api/transactions - Create new transaction
|
||||||
|
* PUT /api/transactions/:id - Update existing transaction
|
||||||
|
* DELETE /api/transactions/:id - Delete transaction
|
||||||
|
* Error handling and validation included
|
||||||
|
* Prepared for future database integration with modular store design
|
||||||
* **Account Switching:** Selecting an account from the dropdown in the sidebar correctly updates the Main Content area (header, transaction table) and the Account Summary section using client-side JavaScript (`<script>` tag in `index.astro`).
|
* **Account Switching:** Selecting an account from the dropdown in the sidebar correctly updates the Main Content area (header, transaction table) and the Account Summary section using client-side JavaScript (`<script>` tag in `index.astro`).
|
||||||
* **Collapsible Form:** The "Add Transaction" section in the sidebar (`src/components/AddTransactionForm.astro`) can be expanded and collapsed using client-side JavaScript (`<script>` tag in `AddTransactionForm.astro`).
|
* **Collapsible Form:** The "Add Transaction" section in the sidebar (`src/components/AddTransactionForm.astro`) can be expanded and collapsed using client-side JavaScript (`<script>` tag in `AddTransactionForm.astro`).
|
||||||
* **Basic Formatting:** Utility functions (`src/utils.ts`) exist for formatting currency and dates, used both server-side and client-side (mirrored in `index.astro` script).
|
* **Basic Formatting:** Utility functions (`src/utils.ts`) exist for formatting currency and dates, used both server-side and client-side (mirrored in `index.astro` script).
|
||||||
@@ -26,8 +37,13 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele
|
|||||||
## File Structure Overview
|
## File Structure Overview
|
||||||
|
|
||||||
* `src/components/`: Reusable UI components.
|
* `src/components/`: Reusable UI components.
|
||||||
|
* `src/data/`: Data store and persistence layer.
|
||||||
* `src/layouts/`: Base page layout(s).
|
* `src/layouts/`: Base page layout(s).
|
||||||
* `src/pages/`: Astro pages (routes). `index.astro` is the main page.
|
* `src/pages/`: Astro pages and API routes.
|
||||||
|
* `index.astro`: Main page
|
||||||
|
* `api/`: Backend API endpoints
|
||||||
|
* `accounts/`: Account-related endpoints
|
||||||
|
* `transactions/`: Transaction-related endpoints
|
||||||
* `src/styles/`: Global CSS styles.
|
* `src/styles/`: Global CSS styles.
|
||||||
* `src/types.ts`: TypeScript type definitions.
|
* `src/types.ts`: TypeScript type definitions.
|
||||||
* `src/utils.ts`: Utility functions (formatting, etc.).
|
* `src/utils.ts`: Utility functions (formatting, etc.).
|
||||||
@@ -35,10 +51,9 @@ This project is a web user interface (UI) for a CRUD (Create, Read, Update, Dele
|
|||||||
|
|
||||||
## Next Steps & TODOs
|
## Next Steps & TODOs
|
||||||
|
|
||||||
1. **Backend API Integration:**
|
1. **Complete API Implementation:**
|
||||||
* Define the expected API endpoints (e.g., `GET /api/accounts`, `GET /api/accounts/:id/transactions`, `POST /api/transactions`, `PUT /api/transactions/:id`, `DELETE /api/transactions/:id`).
|
* Add error handling and validation
|
||||||
* Replace static JSON data fetching in `index.astro` with `fetch` calls to the backend API during server-side rendering (or potentially client-side, depending on strategy).
|
* Prepare for future database integration
|
||||||
* Update client-side logic to interact with the API for CRUD operations.
|
|
||||||
2. **Implement Create Functionality:**
|
2. **Implement Create Functionality:**
|
||||||
* Add client-side JavaScript to the `AddTransactionForm.astro` component (or enhance the script in `index.astro`) to handle form submission.
|
* Add client-side JavaScript to the `AddTransactionForm.astro` component (or enhance the script in `index.astro`) to handle form submission.
|
||||||
* Prevent default form submission.
|
* Prevent default form submission.
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# build output
|
# build output
|
||||||
dist/
|
dist/
|
||||||
.output/
|
.output/
|
||||||
|
.astro/
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -23,3 +24,6 @@ pnpm-debug.log*
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({});
|
export default defineConfig({
|
||||||
|
output: "server",
|
||||||
|
});
|
||||||
|
|||||||
2470
package-lock.json
generated
2470
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -6,9 +6,17 @@
|
|||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro"
|
"astro": "astro",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"astro": "^5.7.5"
|
"astro": "^5.7.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/supertest": "^2.0.12",
|
||||||
|
"@vitest/coverage-v8": "^0.34.6",
|
||||||
|
"supertest": "^6.3.3",
|
||||||
|
"vitest": "^0.34.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
---
|
---
|
||||||
// This component needs client-side JS for the toggle
|
// TODO: Enhance form validation and submission
|
||||||
|
// - Add more robust client-side validation
|
||||||
|
// - Implement better error message display
|
||||||
|
// - Add loading states during submission
|
||||||
|
// - Consider adding form reset confirmation if there are unsaved changes
|
||||||
|
// This component handles both creating and editing transactions
|
||||||
---
|
---
|
||||||
<section class="add-transaction-section">
|
<section class="add-transaction-section">
|
||||||
<button
|
<button
|
||||||
@@ -10,42 +15,287 @@
|
|||||||
>
|
>
|
||||||
Add Transaction +
|
Add Transaction +
|
||||||
</button>
|
</button>
|
||||||
<form id="add-transaction-form" class="collapsible-form collapsed">
|
<form id="add-transaction-form" class="collapsible-form collapsed" novalidate>
|
||||||
<h4>New Transaction</h4>
|
<h4 id="form-title">New Transaction</h4>
|
||||||
|
<div id="form-error" class="error-message hidden"></div>
|
||||||
|
<input type="hidden" id="txn-id" name="id">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="txn-date">Date</label>
|
<label for="txn-date">Date</label>
|
||||||
<input type="date" id="txn-date" name="date" required>
|
<input type="date" id="txn-date" name="date" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="txn-description">Description</label>
|
<label for="txn-description">Description</label>
|
||||||
<input type="text" id="txn-description" name="description" required placeholder="e.g. Groceries">
|
<input type="text" id="txn-description" name="description" required minlength="2" maxlength="100" placeholder="e.g. Groceries">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="txn-amount">Amount</label>
|
<label for="txn-amount">Amount</label>
|
||||||
<input type="number" id="txn-amount" name="amount" step="0.01" required placeholder="e.g. -25.50 or 1200.00">
|
<input type="number" id="txn-amount" name="amount" step="0.01" required placeholder="e.g. -25.50 or 1200.00">
|
||||||
|
<small class="help-text">Use negative numbers for expenses (e.g., -50.00)</small>
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button type="submit" id="submit-btn" class="form-submit-btn">Save Transaction</button>
|
||||||
|
<button type="button" id="cancel-btn" class="cancel-btn hidden">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- In a real app, prevent default and use JS to submit -->
|
|
||||||
<button type="submit">Save</button>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<style>
|
||||||
// Script to handle the collapsible form toggle
|
.error-message {
|
||||||
const toggleBtn = document.getElementById('toggle-form-btn');
|
background-color: #f8d7da;
|
||||||
const form = document.getElementById('add-transaction-form');
|
border: 1px solid #f5c6cb;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.help-text {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
.cancel-btn {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cancel-btn:hover {
|
||||||
|
background-color: #5a6268;
|
||||||
|
}
|
||||||
|
.form-submit-btn.loading {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// --- DOM Elements ---
|
||||||
|
const toggleBtn = document.getElementById('toggle-form-btn');
|
||||||
|
const form = document.getElementById('add-transaction-form') as HTMLFormElement;
|
||||||
|
const formTitle = document.getElementById('form-title');
|
||||||
|
const errorDiv = document.getElementById('form-error');
|
||||||
|
const accountSelect = document.getElementById('account-select') as HTMLSelectElement;
|
||||||
|
const cancelBtn = document.getElementById('cancel-btn');
|
||||||
|
const submitBtn = document.getElementById('submit-btn');
|
||||||
|
const txnIdInput = document.getElementById('txn-id') as HTMLInputElement;
|
||||||
|
const dateInput = document.getElementById('txn-date') as HTMLInputElement;
|
||||||
|
|
||||||
|
let isEditMode = false;
|
||||||
|
let isSubmitting = false;
|
||||||
|
|
||||||
|
// Set default date to today
|
||||||
|
if (dateInput) {
|
||||||
|
dateInput.value = new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper Functions ---
|
||||||
|
function showError(message: string) {
|
||||||
|
if (errorDiv) {
|
||||||
|
errorDiv.textContent = message;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideError() {
|
||||||
|
if (errorDiv) {
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearForm() {
|
||||||
|
if (form) {
|
||||||
|
form.reset();
|
||||||
|
// Reset date to today
|
||||||
|
if (dateInput) {
|
||||||
|
dateInput.value = new Date().toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
// Reset edit mode
|
||||||
|
setEditMode(false);
|
||||||
|
// Clear any errors
|
||||||
|
hideError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEditMode(enabled: boolean) {
|
||||||
|
isEditMode = enabled;
|
||||||
|
if (formTitle) formTitle.textContent = enabled ? 'Edit Transaction' : 'New Transaction';
|
||||||
|
if (submitBtn) submitBtn.textContent = enabled ? 'Update Transaction' : 'Save Transaction';
|
||||||
|
if (cancelBtn) cancelBtn.classList.toggle('hidden', !enabled);
|
||||||
|
if (toggleBtn) toggleBtn.classList.toggle('hidden', enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(enabled: boolean) {
|
||||||
|
isSubmitting = enabled;
|
||||||
|
if (submitBtn) {
|
||||||
|
const button = submitBtn as HTMLButtonElement;
|
||||||
|
button.classList.toggle('loading', enabled);
|
||||||
|
button.disabled = enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
if (!form) return null;
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Check required fields
|
||||||
|
const description = formData.get('description') as string;
|
||||||
|
const amount = formData.get('amount') as string;
|
||||||
|
const date = formData.get('date');
|
||||||
|
|
||||||
|
if (!description || description.trim().length < 2) {
|
||||||
|
errors.push('Description must be at least 2 characters long');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!amount) {
|
||||||
|
errors.push('Amount is required');
|
||||||
|
} else {
|
||||||
|
const amountNum = parseFloat(amount);
|
||||||
|
if (isNaN(amountNum)) {
|
||||||
|
errors.push('Amount must be a valid number');
|
||||||
|
} else if (amountNum === 0) {
|
||||||
|
errors.push('Amount cannot be zero');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
errors.push('Date is required');
|
||||||
|
} else {
|
||||||
|
const dateObj = new Date(date as string);
|
||||||
|
if (isNaN(dateObj.getTime())) {
|
||||||
|
errors.push('Invalid date format');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.length > 0 ? errors : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Form Toggle ---
|
||||||
if (toggleBtn && form) {
|
if (toggleBtn && form) {
|
||||||
toggleBtn.addEventListener('click', () => {
|
toggleBtn.addEventListener('click', () => {
|
||||||
const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true';
|
const isExpanded = toggleBtn.getAttribute('aria-expanded') === 'true';
|
||||||
toggleBtn.setAttribute('aria-expanded', String(!isExpanded));
|
toggleBtn.setAttribute('aria-expanded', String(!isExpanded));
|
||||||
form.classList.toggle('collapsed');
|
form.classList.toggle('collapsed');
|
||||||
toggleBtn.textContent = isExpanded ? 'Add Transaction +' : 'Hide Form -';
|
toggleBtn.textContent = isExpanded ? 'Add Transaction +' : 'Hide Form -';
|
||||||
// Optional: Focus first input when opening
|
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
form.querySelector('input')?.focus();
|
form.querySelector('input')?.focus();
|
||||||
}
|
}
|
||||||
|
hideError();
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
console.error("Toggle button or form not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancel button handler
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
clearForm();
|
||||||
|
form?.classList.add('collapsed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Form Submission ---
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideError();
|
||||||
|
|
||||||
|
if (isSubmitting) return;
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const errors = validateForm();
|
||||||
|
if (errors) {
|
||||||
|
showError(errors.join('. '));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const currentAccountId = accountSelect?.value;
|
||||||
|
|
||||||
|
if (!currentAccountId) {
|
||||||
|
throw new Error('No account selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = parseFloat(formData.get('amount') as string);
|
||||||
|
const transaction = {
|
||||||
|
accountId: currentAccountId,
|
||||||
|
date: formData.get('date'),
|
||||||
|
description: (formData.get('description') as string).trim(),
|
||||||
|
amount: amount
|
||||||
|
};
|
||||||
|
|
||||||
|
const transactionId = txnIdInput.value;
|
||||||
|
const method = transactionId ? 'PUT' : 'POST';
|
||||||
|
const url = transactionId
|
||||||
|
? `/api/transactions/${transactionId}`
|
||||||
|
: '/api/transactions';
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(transaction)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || `Failed to ${isEditMode ? 'update' : 'create'} transaction`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transaction saved successfully
|
||||||
|
const savedTransaction = await response.json();
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
const eventName = isEditMode ? 'transactionUpdated' : 'transactionCreated';
|
||||||
|
document.dispatchEvent(new CustomEvent(eventName, {
|
||||||
|
detail: { transaction: savedTransaction }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear and collapse form
|
||||||
|
clearForm();
|
||||||
|
form.classList.add('collapsed');
|
||||||
|
if (toggleBtn) {
|
||||||
|
toggleBtn.setAttribute('aria-expanded', 'false');
|
||||||
|
toggleBtn.textContent = 'Add Transaction +';
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : 'An unexpected error occurred');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Edit Transaction Handler ---
|
||||||
|
document.addEventListener('editTransaction', ((event: CustomEvent) => {
|
||||||
|
const transaction = event.detail.transaction;
|
||||||
|
if (!form || !transaction) return;
|
||||||
|
|
||||||
|
// Populate form
|
||||||
|
txnIdInput.value = transaction.id;
|
||||||
|
if (dateInput) dateInput.value = transaction.date;
|
||||||
|
(form.querySelector('#txn-description') as HTMLInputElement).value = transaction.description;
|
||||||
|
(form.querySelector('#txn-amount') as HTMLInputElement).value = transaction.amount.toString();
|
||||||
|
|
||||||
|
// Show form in edit mode
|
||||||
|
setEditMode(true);
|
||||||
|
form.classList.remove('collapsed');
|
||||||
|
form.querySelector('input')?.focus();
|
||||||
|
}) as EventListener);
|
||||||
</script>
|
</script>
|
||||||
@@ -10,8 +10,16 @@ const { transactions } = Astro.props;
|
|||||||
|
|
||||||
// Sort transactions by date descending for display
|
// Sort transactions by date descending for display
|
||||||
const sortedTransactions = [...transactions].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
const sortedTransactions = [...transactions].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
|
||||||
|
// TODO: UI/UX Improvements
|
||||||
|
// - Add sorting functionality for all columns
|
||||||
|
// - Implement pagination for large transaction lists
|
||||||
|
// - Add transaction filtering capabilities
|
||||||
|
// - Implement row hover actions
|
||||||
|
// - Add transaction details expansion/collapse
|
||||||
|
// - Consider adding bulk actions (delete, categorize)
|
||||||
---
|
---
|
||||||
<section class="transaction-list">
|
<section class="transaction-list" id="transaction-section">
|
||||||
<table id="transaction-table">
|
<table id="transaction-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -30,8 +38,8 @@ const sortedTransactions = [...transactions].sort((a, b) => new Date(b.date).get
|
|||||||
{formatCurrency(txn.amount)}
|
{formatCurrency(txn.amount)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="action-btn edit-btn" title="Edit transaction (not implemented)">Edit</button>
|
<button class="action-btn edit-btn" title="Edit transaction">Edit</button>
|
||||||
<button class="action-btn delete-btn" title="Delete transaction (not implemented)">Delete</button>
|
<button class="action-btn delete-btn" title="Delete transaction">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
57
src/data/store.ts
Normal file
57
src/data/store.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// TODO: Database Integration & Persistence
|
||||||
|
// - Implement database schema design
|
||||||
|
// - Add database connection pooling
|
||||||
|
// - Implement data migration strategy
|
||||||
|
// - Add database backup and recovery plans
|
||||||
|
// - Implement data validation layer
|
||||||
|
// - Add transaction logging
|
||||||
|
// - Implement audit trail
|
||||||
|
// - Add data archival strategy
|
||||||
|
|
||||||
|
import type { Account, Transaction } from "../types";
|
||||||
|
|
||||||
|
// TODO: Replace in-memory store with persistent database
|
||||||
|
// - Implement database connection and configuration
|
||||||
|
// - Create database schema for accounts and transactions
|
||||||
|
// - Add migration system for schema changes
|
||||||
|
// - Update all CRUD operations to use database instead of arrays
|
||||||
|
|
||||||
|
// Temporary in-memory store for development
|
||||||
|
export const accounts: Account[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "Checking Account",
|
||||||
|
last4: "4321",
|
||||||
|
balance: 2500.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "Savings Account",
|
||||||
|
last4: "8765",
|
||||||
|
balance: 10000.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const transactions: Transaction[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
accountId: "1",
|
||||||
|
date: "2025-04-20",
|
||||||
|
description: "Grocery Store",
|
||||||
|
amount: -75.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
accountId: "1",
|
||||||
|
date: "2025-04-21",
|
||||||
|
description: "Salary Deposit",
|
||||||
|
amount: 3000.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
accountId: "2",
|
||||||
|
date: "2025-04-22",
|
||||||
|
description: "Transfer to Savings",
|
||||||
|
amount: 500.0,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -3,6 +3,15 @@ interface Props {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Accessibility Improvements
|
||||||
|
// - Add ARIA landmarks for main regions
|
||||||
|
// - Implement keyboard navigation
|
||||||
|
// - Add skip navigation links
|
||||||
|
// - Ensure proper heading hierarchy
|
||||||
|
// - Add focus management for modals/dialogs
|
||||||
|
// - Implement proper announcements for dynamic content
|
||||||
|
// - Add high contrast theme support
|
||||||
|
|
||||||
const { title } = Astro.props;
|
const { title } = Astro.props;
|
||||||
---
|
---
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
|
|||||||
22
src/pages/api/accounts/[id]/index.ts
Normal file
22
src/pages/api/accounts/[id]/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { accounts } from "../../../../data/store";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params }) => {
|
||||||
|
const account = accounts.find((a) => a.id === params.id);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return new Response(JSON.stringify({ error: "Account not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(account), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
15
src/pages/api/accounts/[id]/transactions/index.ts
Normal file
15
src/pages/api/accounts/[id]/transactions/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { transactions } from "../../../../../data/store";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params }) => {
|
||||||
|
const accountTransactions = transactions.filter(
|
||||||
|
(t) => t.accountId === params.id
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(accountTransactions), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
11
src/pages/api/accounts/index.ts
Normal file
11
src/pages/api/accounts/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { accounts } from "../../../data/store";
|
||||||
|
|
||||||
|
export const GET: APIRoute = async () => {
|
||||||
|
return new Response(JSON.stringify(accounts), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
126
src/pages/api/transactions/[id]/index.ts
Normal file
126
src/pages/api/transactions/[id]/index.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { transactions, accounts } from "../../../../data/store";
|
||||||
|
import type { Transaction } from "../../../../types";
|
||||||
|
|
||||||
|
export const PUT: APIRoute = async ({ request, params }) => {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Transaction ID is required" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates = (await request.json()) as Partial<Transaction>;
|
||||||
|
const transactionIndex = transactions.findIndex((t) => t.id === id);
|
||||||
|
|
||||||
|
if (transactionIndex === -1) {
|
||||||
|
return new Response(JSON.stringify({ error: "Transaction not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldTransaction = transactions[transactionIndex];
|
||||||
|
|
||||||
|
// Get the old account first
|
||||||
|
const oldAccount = accounts.find((a) => a.id === oldTransaction.accountId);
|
||||||
|
if (!oldAccount) {
|
||||||
|
return new Response(JSON.stringify({ error: "Account not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If account is changing, validate new account exists
|
||||||
|
let newAccount = oldAccount;
|
||||||
|
if (updates.accountId && updates.accountId !== oldTransaction.accountId) {
|
||||||
|
const foundAccount = accounts.find((a) => a.id === updates.accountId);
|
||||||
|
if (!foundAccount) {
|
||||||
|
return new Response(JSON.stringify({ error: "Account not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
newAccount = foundAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, remove the old transaction's effect on the old account
|
||||||
|
oldAccount.balance -= oldTransaction.amount;
|
||||||
|
|
||||||
|
// Create updated transaction
|
||||||
|
const updatedTransaction: Transaction = {
|
||||||
|
...oldTransaction,
|
||||||
|
...updates,
|
||||||
|
id: id, // Ensure ID doesn't change
|
||||||
|
};
|
||||||
|
|
||||||
|
// Then add the new transaction's effect to the appropriate account
|
||||||
|
if (newAccount === oldAccount) {
|
||||||
|
// If same account, just add the new amount
|
||||||
|
oldAccount.balance += updatedTransaction.amount;
|
||||||
|
} else {
|
||||||
|
// If different account, add to the new account
|
||||||
|
newAccount.balance += updatedTransaction.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update transaction in array
|
||||||
|
transactions[transactionIndex] = updatedTransaction;
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(updatedTransaction), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ error: "Invalid request body" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: APIRoute = async ({ params }) => {
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Transaction ID is required" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionIndex = transactions.findIndex((t) => t.id === id);
|
||||||
|
|
||||||
|
if (transactionIndex === -1) {
|
||||||
|
return new Response(JSON.stringify({ error: "Transaction not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = transactions[transactionIndex];
|
||||||
|
const account = accounts.find((a) => a.id === transaction.accountId);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return new Response(JSON.stringify({ error: "Account not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update account balance
|
||||||
|
account.balance -= transaction.amount;
|
||||||
|
|
||||||
|
// Remove transaction from array
|
||||||
|
transactions.splice(transactionIndex, 1);
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
};
|
||||||
79
src/pages/api/transactions/index.ts
Normal file
79
src/pages/api/transactions/index.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* TODO: Security Improvements
|
||||||
|
* - Add input validation and sanitization
|
||||||
|
* - Implement rate limiting for API endpoints
|
||||||
|
* - Add request authentication
|
||||||
|
* - Implement CSRF protection
|
||||||
|
* - Add request logging and monitoring
|
||||||
|
* - Implement secure session management
|
||||||
|
* - Add API versioning
|
||||||
|
* - Set up proper CORS configuration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { transactions, accounts } from "../../../data/store";
|
||||||
|
import type { Transaction } from "../../../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: API Improvements
|
||||||
|
* - Add request rate limiting
|
||||||
|
* - Implement proper API authentication
|
||||||
|
* - Add input sanitization
|
||||||
|
* - Implement request validation middleware
|
||||||
|
* - Add API versioning
|
||||||
|
* - Consider implementing GraphQL for more flexible queries
|
||||||
|
* - Add proper logging and monitoring
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
|
try {
|
||||||
|
const transaction = (await request.json()) as Omit<Transaction, "id">;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (
|
||||||
|
!transaction.accountId ||
|
||||||
|
!transaction.date ||
|
||||||
|
!transaction.description ||
|
||||||
|
transaction.amount === undefined
|
||||||
|
) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing required fields" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate account exists
|
||||||
|
const account = accounts.find((a) => a.id === transaction.accountId);
|
||||||
|
if (!account) {
|
||||||
|
return new Response(JSON.stringify({ error: "Account not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new transaction with generated ID
|
||||||
|
const newTransaction: Transaction = {
|
||||||
|
...transaction,
|
||||||
|
id: (transactions.length + 1).toString(), // Simple ID generation for demo
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update account balance
|
||||||
|
account.balance += transaction.amount;
|
||||||
|
|
||||||
|
// Add to transactions array
|
||||||
|
transactions.push(newTransaction);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(newTransaction), {
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(JSON.stringify({ error: "Invalid request body" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,21 +3,48 @@ import BaseLayout from '../layouts/BaseLayout.astro';
|
|||||||
import Sidebar from '../components/Sidebar.astro';
|
import Sidebar from '../components/Sidebar.astro';
|
||||||
import MainContent from '../components/MainContent.astro';
|
import MainContent from '../components/MainContent.astro';
|
||||||
import type { Account, Transaction } from '../types';
|
import type { Account, Transaction } from '../types';
|
||||||
import { formatCurrency, formatDate } from '../utils';
|
|
||||||
|
|
||||||
// Initialize with empty arrays until API integration
|
// Fetch accounts from API
|
||||||
const accounts: Account[] = [];
|
const accountsResponse = await fetch('http://localhost:4321/api/accounts');
|
||||||
const allTransactions: Transaction[] = [];
|
const accounts: Account[] = await accountsResponse.json();
|
||||||
|
|
||||||
// Create an empty initial account
|
// Initialize with first account or empty account if none exist
|
||||||
const initialAccount: Account = {
|
const initialAccount: Account = accounts[0] || {
|
||||||
id: '',
|
id: '',
|
||||||
name: 'No accounts available',
|
name: 'No accounts available',
|
||||||
last4: '0000',
|
last4: '0000',
|
||||||
balance: 0
|
balance: 0
|
||||||
};
|
};
|
||||||
const initialTransactions: Transaction[] = [];
|
|
||||||
|
// Fetch initial transactions if we have an account
|
||||||
|
let initialTransactions: Transaction[] = [];
|
||||||
|
if (initialAccount.id) {
|
||||||
|
const transactionsResponse = await fetch(`http://localhost:4321/api/accounts/${initialAccount.id}/transactions`);
|
||||||
|
initialTransactions = await transactionsResponse.json();
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
TODO: State Management Improvements
|
||||||
|
- Consider implementing Nano Stores for better state management
|
||||||
|
- Add more robust error handling and user feedback
|
||||||
|
- Implement loading states for all async operations
|
||||||
|
- Add offline support with data synchronization
|
||||||
|
- Consider implementing optimistic updates for better UX
|
||||||
|
-->
|
||||||
|
|
||||||
|
<!--
|
||||||
|
TODO: Performance & Monitoring
|
||||||
|
- Implement client-side error tracking
|
||||||
|
- Add performance metrics collection
|
||||||
|
- Set up monitoring for API response times
|
||||||
|
- Implement request caching strategy
|
||||||
|
- Add lazy loading for transaction history
|
||||||
|
- Optimize bundle size
|
||||||
|
- Add performance budgets
|
||||||
|
- Implement progressive loading
|
||||||
|
-->
|
||||||
|
|
||||||
<BaseLayout title="Bank Transactions Dashboard">
|
<BaseLayout title="Bank Transactions Dashboard">
|
||||||
<div class="dashboard-layout">
|
<div class="dashboard-layout">
|
||||||
<Sidebar accounts={accounts} initialAccount={initialAccount} />
|
<Sidebar accounts={accounts} initialAccount={initialAccount} />
|
||||||
@@ -25,52 +52,116 @@ const initialTransactions: Transaction[] = [];
|
|||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script define:vars={{ allAccounts: accounts, allTransactions }}>
|
<script>
|
||||||
// Client-side script to handle account switching and updating the UI
|
// Import types for client-side script
|
||||||
|
type Transaction = import('../types').Transaction;
|
||||||
|
type Account = import('../types').Account;
|
||||||
|
type TransactionEventDetail = import('../types/events').TransactionEventDetail;
|
||||||
|
|
||||||
// --- DOM Elements ---
|
// --- DOM Elements ---
|
||||||
const accountSelect = document.getElementById('account-select');
|
const accountSelect = document.getElementById('account-select') as HTMLSelectElement;
|
||||||
const currentAccountNameSpan = document.getElementById('current-account-name');
|
const currentAccountNameSpan = document.getElementById('current-account-name');
|
||||||
const accountBalanceSpan = document.getElementById('account-balance');
|
const accountBalanceSpan = document.getElementById('account-balance');
|
||||||
const transactionTableBody = document.getElementById('transaction-table-body');
|
const transactionTableBody = document.getElementById('transaction-table-body');
|
||||||
|
const transactionSection = document.getElementById('transaction-section');
|
||||||
|
|
||||||
// --- Helper Functions (mirror utils.ts for client-side use) ---
|
// --- Helper Functions ---
|
||||||
function formatCurrency(amount) {
|
function formatCurrency(amount: number): string {
|
||||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString: string): string {
|
||||||
const date = new Date(dateString + 'T00:00:00'); // Ensure local date
|
const date = new Date(dateString + 'T00:00:00');
|
||||||
return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric' }).format(date);
|
return new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric' }).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchAccountTransactions(accountId: string): Promise<Transaction[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/accounts/${accountId}/transactions`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch transactions');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching transactions:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAccountDetails(accountId: string): Promise<Account | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/accounts/${accountId}`);
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch account details');
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching account:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Update UI Function ---
|
// --- Update UI Function ---
|
||||||
function updateUIForAccount(accountId) {
|
async function updateUIForAccount(accountId: string): Promise<void> {
|
||||||
console.log("Updating UI for account:", accountId); // Debug log
|
console.log("Updating UI for account:", accountId);
|
||||||
|
|
||||||
const selectedAccount = allAccounts.find(acc => acc.id === accountId);
|
if (transactionSection) {
|
||||||
const accountTransactions = allTransactions
|
transactionSection.classList.add('loading');
|
||||||
.filter(txn => txn.accountId === accountId)
|
}
|
||||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // Sort desc
|
|
||||||
|
|
||||||
if (!selectedAccount || !transactionTableBody || !currentAccountNameSpan || !accountBalanceSpan) {
|
try {
|
||||||
console.error("Required UI elements not found!");
|
// Fetch latest account details and transactions
|
||||||
|
const [account, transactions] = await Promise.all([
|
||||||
|
fetchAccountDetails(accountId),
|
||||||
|
fetchAccountTransactions(accountId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!account || !transactionTableBody || !currentAccountNameSpan || !accountBalanceSpan) {
|
||||||
|
console.error("Required data or UI elements not found!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update header
|
// Update header
|
||||||
currentAccountNameSpan.textContent = `${selectedAccount.name} (***${selectedAccount.last4})`;
|
currentAccountNameSpan.textContent = `${account.name} (***${account.last4})`;
|
||||||
|
|
||||||
// Update summary
|
// Update summary
|
||||||
accountBalanceSpan.textContent = formatCurrency(selectedAccount.balance);
|
accountBalanceSpan.textContent = formatCurrency(account.balance);
|
||||||
|
|
||||||
// Update table
|
// Update table
|
||||||
transactionTableBody.innerHTML = ''; // Clear existing rows
|
updateTransactionTable(transactions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating UI:', error);
|
||||||
|
if (transactionTableBody) {
|
||||||
|
transactionTableBody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="text-align: center; color: #dc3545;">
|
||||||
|
Failed to load transactions. Please try again.
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (transactionSection) {
|
||||||
|
transactionSection.classList.remove('loading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (accountTransactions.length === 0) {
|
function updateTransactionTable(transactions: Transaction[]): void {
|
||||||
transactionTableBody.innerHTML = `<tr><td colspan="4" style="text-align: center; font-style: italic; color: #777;">No transactions found for this account.</td></tr>`;
|
if (!transactionTableBody) return;
|
||||||
} else {
|
|
||||||
accountTransactions.forEach(txn => {
|
const sortedTransactions = [...transactions].sort((a, b) =>
|
||||||
|
new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
transactionTableBody.innerHTML = '';
|
||||||
|
|
||||||
|
if (sortedTransactions.length === 0) {
|
||||||
|
transactionTableBody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" style="text-align: center; font-style: italic; color: #777;">
|
||||||
|
No transactions found for this account.
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sortedTransactions.forEach(txn => {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.setAttribute('data-txn-id', txn.id);
|
row.setAttribute('data-txn-id', txn.id);
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
@@ -80,29 +171,115 @@ const initialTransactions: Transaction[] = [];
|
|||||||
${formatCurrency(txn.amount)}
|
${formatCurrency(txn.amount)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button class="action-btn edit-btn" title="Edit transaction (not implemented)">Edit</button>
|
<button class="action-btn edit-btn" title="Edit transaction">Edit</button>
|
||||||
<button class="action-btn delete-btn" title="Delete transaction (not implemented)">Delete</button>
|
<button class="action-btn delete-btn" title="Delete transaction">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
transactionTableBody.appendChild(row);
|
transactionTableBody.appendChild(row);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
setupTransactionButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Event Listener ---
|
function setupTransactionButtons(): void {
|
||||||
if (accountSelect) {
|
const transactionRows = transactionTableBody?.querySelectorAll('tr[data-txn-id]');
|
||||||
accountSelect.addEventListener('change', (event) => {
|
transactionRows?.forEach(row => {
|
||||||
const selectedAccountId = event.target.value;
|
const txnId = row.getAttribute('data-txn-id');
|
||||||
updateUIForAccount(selectedAccountId);
|
if (!txnId) return;
|
||||||
|
|
||||||
|
// Delete button handler
|
||||||
|
const deleteBtn = row.querySelector('.delete-btn');
|
||||||
|
deleteBtn?.addEventListener('click', () => handleDeleteTransaction(txnId));
|
||||||
|
|
||||||
|
// Edit button handler
|
||||||
|
const editBtn = row.querySelector('.edit-btn');
|
||||||
|
editBtn?.addEventListener('click', () => handleEditTransaction(txnId));
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
console.error("Account select element not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Initial Load (Optional but good practice) ---
|
// --- Transaction Actions ---
|
||||||
// The page already renders the initial state server-side,
|
async function handleDeleteTransaction(txnId: string): Promise<void> {
|
||||||
// so no need to call updateUIForAccount() immediately unless
|
if (!confirm('Are you sure you want to delete this transaction?')) {
|
||||||
// there's a chance the JS loads before initial render completes fully.
|
return;
|
||||||
// console.log("Account switcher script loaded.");
|
}
|
||||||
|
|
||||||
|
if (transactionSection) {
|
||||||
|
transactionSection.classList.add('loading');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/transactions/${txnId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to delete transaction');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the current account view
|
||||||
|
const currentAccountId = accountSelect?.value;
|
||||||
|
if (currentAccountId) {
|
||||||
|
await updateUIForAccount(currentAccountId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(error instanceof Error ? error.message : 'Failed to delete transaction');
|
||||||
|
} finally {
|
||||||
|
if (transactionSection) {
|
||||||
|
transactionSection.classList.remove('loading');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditTransaction(txnId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Find transaction in current transactions list
|
||||||
|
const currentAccountId = accountSelect?.value;
|
||||||
|
if (!currentAccountId) return;
|
||||||
|
|
||||||
|
const transactions = await fetchAccountTransactions(currentAccountId);
|
||||||
|
const transaction = transactions.find(t => t.id === txnId);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
throw new Error('Transaction not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger edit mode in form
|
||||||
|
document.dispatchEvent(new CustomEvent<TransactionEventDetail>('editTransaction', {
|
||||||
|
detail: { transaction }
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert(error instanceof Error ? error.message : 'Failed to load transaction for editing');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event Listeners ---
|
||||||
|
if (accountSelect) {
|
||||||
|
accountSelect.addEventListener('change', (event: Event) => {
|
||||||
|
const target = event.target as HTMLSelectElement;
|
||||||
|
updateUIForAccount(target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for transaction events
|
||||||
|
document.addEventListener('transactionCreated', ((event: CustomEvent<TransactionEventDetail>) => {
|
||||||
|
const currentAccountId = accountSelect?.value;
|
||||||
|
if (currentAccountId) {
|
||||||
|
updateUIForAccount(currentAccountId);
|
||||||
|
}
|
||||||
|
}) as EventListener);
|
||||||
|
|
||||||
|
document.addEventListener('transactionUpdated', ((event: CustomEvent<TransactionEventDetail>) => {
|
||||||
|
const currentAccountId = accountSelect?.value;
|
||||||
|
if (currentAccountId) {
|
||||||
|
updateUIForAccount(currentAccountId);
|
||||||
|
}
|
||||||
|
}) as EventListener);
|
||||||
|
|
||||||
|
// Initial load with transactions if available
|
||||||
|
const initialAccountId = accountSelect?.value;
|
||||||
|
if (initialAccountId) {
|
||||||
|
updateUIForAccount(initialAccountId);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -232,3 +232,48 @@ tbody tr:hover {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Loading States */
|
||||||
|
.loading {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
margin: -0.75em 0 0 -0.75em;
|
||||||
|
border: 3px solid rgba(0, 123, 255, 0.2);
|
||||||
|
border-top-color: #007bff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spinner 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spinner {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submit-btn {
|
||||||
|
position: relative;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submit-btn.loading {
|
||||||
|
padding-right: 35px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-submit-btn.loading::after {
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
margin: -0.5em 0 0 0;
|
||||||
|
left: auto;
|
||||||
|
right: 10px;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|||||||
65
src/test/accounts.test.ts
Normal file
65
src/test/accounts.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { GET as listAccounts } from "../pages/api/accounts/index";
|
||||||
|
import { GET as getAccount } from "../pages/api/accounts/[id]/index";
|
||||||
|
import { GET as listTransactions } from "../pages/api/accounts/[id]/transactions/index";
|
||||||
|
import { createMockAPIContext } from "./setup";
|
||||||
|
|
||||||
|
describe("Accounts API", () => {
|
||||||
|
describe("GET /api/accounts", () => {
|
||||||
|
it("should return all accounts", async () => {
|
||||||
|
const response = await listAccounts(createMockAPIContext() as any);
|
||||||
|
const accounts = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(accounts).toHaveLength(2);
|
||||||
|
expect(accounts[0]).toHaveProperty("id", "1");
|
||||||
|
expect(accounts[1]).toHaveProperty("id", "2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/accounts/:id", () => {
|
||||||
|
it("should return a specific account", async () => {
|
||||||
|
const response = await getAccount(
|
||||||
|
createMockAPIContext({ params: { id: "1" } }) as any
|
||||||
|
);
|
||||||
|
const account = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(account).toHaveProperty("id", "1");
|
||||||
|
expect(account).toHaveProperty("name", "Test Checking");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 for non-existent account", async () => {
|
||||||
|
const response = await getAccount(
|
||||||
|
createMockAPIContext({ params: { id: "999" } }) as any
|
||||||
|
);
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(error).toHaveProperty("error", "Account not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/accounts/:id/transactions", () => {
|
||||||
|
it("should return transactions for an account", async () => {
|
||||||
|
const response = await listTransactions(
|
||||||
|
createMockAPIContext({ params: { id: "1" } }) as any
|
||||||
|
);
|
||||||
|
const transactions = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(transactions).toHaveLength(1);
|
||||||
|
expect(transactions[0]).toHaveProperty("accountId", "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty array for account with no transactions", async () => {
|
||||||
|
const response = await listTransactions(
|
||||||
|
createMockAPIContext({ params: { id: "999" } }) as any
|
||||||
|
);
|
||||||
|
const transactions = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(transactions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/test/setup.ts
Normal file
67
src/test/setup.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { beforeEach } from "vitest";
|
||||||
|
import { accounts, transactions } from "../data/store";
|
||||||
|
import type { APIContext } from "astro";
|
||||||
|
|
||||||
|
// Create a mock APIContext factory
|
||||||
|
export function createMockAPIContext<
|
||||||
|
T extends Record<string, string> = Record<string, string>
|
||||||
|
>({ params = {} as T } = {}): Partial<APIContext> {
|
||||||
|
return {
|
||||||
|
params,
|
||||||
|
props: {},
|
||||||
|
request: new Request("http://localhost:4321"),
|
||||||
|
site: new URL("http://localhost:4321"),
|
||||||
|
generator: "test",
|
||||||
|
url: new URL("http://localhost:4321"),
|
||||||
|
clientAddress: "127.0.0.1",
|
||||||
|
cookies: new Headers() as any, // Cast Headers to cookies as we don't need cookie functionality in tests
|
||||||
|
redirect: () => new Response(),
|
||||||
|
locals: {},
|
||||||
|
preferredLocale: undefined,
|
||||||
|
preferredLocaleList: [],
|
||||||
|
currentLocale: undefined,
|
||||||
|
routePattern: "/api/[...path]",
|
||||||
|
originPathname: "/api",
|
||||||
|
getActionResult: () => undefined,
|
||||||
|
isPrerendered: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset test data before each test
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset accounts to initial state
|
||||||
|
accounts.length = 0;
|
||||||
|
accounts.push(
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "Test Checking",
|
||||||
|
last4: "1234",
|
||||||
|
balance: 1000.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "Test Savings",
|
||||||
|
last4: "5678",
|
||||||
|
balance: 5000.0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset transactions to initial state
|
||||||
|
transactions.length = 0;
|
||||||
|
transactions.push(
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
accountId: "1",
|
||||||
|
date: "2025-04-24",
|
||||||
|
description: "Test Transaction 1",
|
||||||
|
amount: -50.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
accountId: "2",
|
||||||
|
date: "2025-04-24",
|
||||||
|
description: "Test Transaction 2",
|
||||||
|
amount: 100.0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
332
src/test/transactions.test.ts
Normal file
332
src/test/transactions.test.ts
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
// TODO: Testing Improvements
|
||||||
|
// - Add integration tests for API endpoints
|
||||||
|
// - Add end-to-end tests with Playwright/Cypress
|
||||||
|
// - Add performance testing
|
||||||
|
// - Add accessibility testing with axe-core
|
||||||
|
// - Add visual regression testing
|
||||||
|
// - Add load testing for API endpoints
|
||||||
|
// - Implement test data factories
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { POST as createTransaction } from "../pages/api/transactions/index";
|
||||||
|
import {
|
||||||
|
PUT as updateTransaction,
|
||||||
|
DELETE as deleteTransaction,
|
||||||
|
} from "../pages/api/transactions/[id]/index";
|
||||||
|
import { accounts, transactions } from "../data/store";
|
||||||
|
import type { Transaction } from "../types";
|
||||||
|
import { createMockAPIContext } from "./setup";
|
||||||
|
|
||||||
|
describe("Transactions API", () => {
|
||||||
|
describe("POST /api/transactions", () => {
|
||||||
|
it("should create a new transaction", async () => {
|
||||||
|
const initialBalance = accounts[0].balance;
|
||||||
|
const newTransaction = {
|
||||||
|
accountId: "1",
|
||||||
|
date: "2025-04-24",
|
||||||
|
description: "Test New Transaction",
|
||||||
|
amount: -25.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = createMockAPIContext() as any;
|
||||||
|
ctx.request = new Request("http://localhost:4321/api/transactions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(newTransaction),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await createTransaction(ctx);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(result).toHaveProperty("id");
|
||||||
|
expect(result.description).toBe(newTransaction.description);
|
||||||
|
expect(accounts[0].balance).toBe(initialBalance + newTransaction.amount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject transaction with missing fields", async () => {
|
||||||
|
const invalidTransaction = {
|
||||||
|
accountId: "1",
|
||||||
|
// Missing required fields
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = createMockAPIContext() as any;
|
||||||
|
ctx.request = new Request("http://localhost:4321/api/transactions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(invalidTransaction),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await createTransaction(ctx);
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(error).toHaveProperty("error", "Missing required fields");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject transaction with invalid account", async () => {
|
||||||
|
const invalidTransaction = {
|
||||||
|
accountId: "999",
|
||||||
|
date: "2025-04-24",
|
||||||
|
description: "Invalid Account Test",
|
||||||
|
amount: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = createMockAPIContext() as any;
|
||||||
|
ctx.request = new Request("http://localhost:4321/api/transactions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(invalidTransaction),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await createTransaction(ctx);
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(error).toHaveProperty("error", "Account not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject invalid request body", async () => {
|
||||||
|
const ctx = createMockAPIContext() as any;
|
||||||
|
ctx.request = new Request("http://localhost:4321/api/transactions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "invalid json",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await createTransaction(ctx);
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(error).toHaveProperty("error", "Invalid request body");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/transactions/:id", () => {
|
||||||
|
it("should update an existing transaction", async () => {
|
||||||
|
const initialBalance = accounts[0].balance;
|
||||||
|
const originalAmount = transactions[0].amount;
|
||||||
|
const updates = {
|
||||||
|
description: "Updated Description",
|
||||||
|
amount: -75.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
|
||||||
|
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await updateTransaction(ctx);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(result.description).toBe(updates.description);
|
||||||
|
expect(result.amount).toBe(updates.amount);
|
||||||
|
expect(accounts[0].balance).toBe(
|
||||||
|
initialBalance - originalAmount + updates.amount
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject update with invalid request body", async () => {
|
||||||
|
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
|
||||||
|
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "invalid json",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await updateTransaction(ctx);
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(error).toHaveProperty("error", "Invalid request body");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject update for non-existent transaction", async () => {
|
||||||
|
const ctx = createMockAPIContext({ params: { id: "999" } }) as any;
|
||||||
|
ctx.request = new Request("http://localhost:4321/api/transactions/999", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ description: "Test" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await updateTransaction(ctx);
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(error).toHaveProperty("error", "Transaction not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject update for non-existent account", async () => {
|
||||||
|
// First update the transaction to point to a non-existent account
|
||||||
|
transactions[0].accountId = "999";
|
||||||
|
|
||||||
|
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
|
||||||
|
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ amount: -100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await updateTransaction(ctx);
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(error).toHaveProperty("error", "Account not found");
|
||||||
|
|
||||||
|
// Reset account ID for other tests
|
||||||
|
transactions[0].accountId = "1";
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle account balance updates correctly when switching accounts", async () => {
|
||||||
|
// Create initial state
|
||||||
|
const oldAccount = accounts[0];
|
||||||
|
const newAccount = accounts[1];
|
||||||
|
const initialOldBalance = oldAccount.balance;
|
||||||
|
const initialNewBalance = newAccount.balance;
|
||||||
|
const oldTransaction = transactions.find((t) => t.id === "1");
|
||||||
|
if (!oldTransaction) throw new Error("Test transaction not found");
|
||||||
|
|
||||||
|
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
|
||||||
|
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
accountId: newAccount.id,
|
||||||
|
amount: -100,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await updateTransaction(ctx);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(result.accountId).toBe(newAccount.id);
|
||||||
|
|
||||||
|
// Old account should have the old amount removed
|
||||||
|
expect(oldAccount.balance).toBe(
|
||||||
|
initialOldBalance + Math.abs(oldTransaction.amount)
|
||||||
|
);
|
||||||
|
|
||||||
|
// New account should have the new amount added
|
||||||
|
expect(newAccount.balance).toBe(initialNewBalance - 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject update without transaction ID", async () => {
|
||||||
|
const ctx = createMockAPIContext() as any;
|
||||||
|
ctx.request = new Request(
|
||||||
|
"http://localhost:4321/api/transactions/undefined",
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ description: "Test" }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await updateTransaction(ctx);
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(error).toHaveProperty("error", "Transaction ID is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject update when old account is missing", async () => {
|
||||||
|
// Store current accounts and clear the array
|
||||||
|
const savedAccounts = [...accounts];
|
||||||
|
accounts.length = 0;
|
||||||
|
|
||||||
|
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
|
||||||
|
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ amount: -100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await updateTransaction(ctx);
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(error).toHaveProperty("error", "Account not found");
|
||||||
|
|
||||||
|
// Restore accounts
|
||||||
|
accounts.push(...savedAccounts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject update when new account doesn't exist", async () => {
|
||||||
|
const ctx = createMockAPIContext({ params: { id: "1" } }) as any;
|
||||||
|
ctx.request = new Request("http://localhost:4321/api/transactions/1", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
accountId: "999", // Non-existent account
|
||||||
|
amount: -100,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await updateTransaction(ctx);
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(error).toHaveProperty("error", "Account not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/transactions/:id", () => {
|
||||||
|
it("should delete a transaction", async () => {
|
||||||
|
const initialBalance = accounts[0].balance;
|
||||||
|
const transactionAmount = transactions[0].amount;
|
||||||
|
const initialCount = transactions.length;
|
||||||
|
|
||||||
|
const response = await deleteTransaction(
|
||||||
|
createMockAPIContext({ params: { id: "1" } }) as any
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
expect(transactions).toHaveLength(initialCount - 1);
|
||||||
|
expect(accounts[0].balance).toBe(initialBalance - transactionAmount);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject delete without transaction ID", async () => {
|
||||||
|
const response = await deleteTransaction(createMockAPIContext() as any);
|
||||||
|
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(error).toHaveProperty("error", "Transaction ID is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 for non-existent transaction", async () => {
|
||||||
|
const response = await deleteTransaction(
|
||||||
|
createMockAPIContext({ params: { id: "999" } }) as any
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(error).toHaveProperty("error", "Transaction not found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle deletion with non-existent account", async () => {
|
||||||
|
// Create a transaction then remove its account
|
||||||
|
const testTransaction: Transaction = {
|
||||||
|
id: "test-delete",
|
||||||
|
accountId: "test-account",
|
||||||
|
date: "2025-04-24",
|
||||||
|
description: "Test Delete",
|
||||||
|
amount: 100,
|
||||||
|
};
|
||||||
|
transactions.push(testTransaction);
|
||||||
|
|
||||||
|
const response = await deleteTransaction(
|
||||||
|
createMockAPIContext({ params: { id: "test-delete" } }) as any
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(error).toHaveProperty("error", "Account not found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
13
src/types/events.ts
Normal file
13
src/types/events.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Transaction } from "../types";
|
||||||
|
|
||||||
|
export interface TransactionEventDetail {
|
||||||
|
transaction: Transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface WindowEventMap {
|
||||||
|
transactionCreated: CustomEvent<TransactionEventDetail>;
|
||||||
|
transactionUpdated: CustomEvent<TransactionEventDetail>;
|
||||||
|
editTransaction: CustomEvent<TransactionEventDetail>;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
vitest.config.ts
Normal file
21
vitest.config.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/// <reference types="vitest" />
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
// Increase timeout for slower CI environments
|
||||||
|
testTimeout: 10000,
|
||||||
|
// Use the setup file we created
|
||||||
|
setupFiles: ["./src/test/setup.ts"],
|
||||||
|
// Ensure we're using the right environment
|
||||||
|
environment: "node",
|
||||||
|
// Only include test files
|
||||||
|
include: ["src/test/**/*.{test,spec}.{ts,js}"],
|
||||||
|
// Configure coverage collection
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html"],
|
||||||
|
exclude: ["node_modules/", "src/test/**/*", "**/*.d.ts"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user