mirror of
https://github.com/acedanger/finance.git
synced 2025-12-05 22:50:12 -08:00
Fix: Update button remaining disabled in transaction edit mode
This commit resolves an issue where the Update button in the transaction form would remain disabled when attempting to edit a transaction. The problem was in how the transactionStore was managing state updates during transaction editing. Key changes: - Enhanced startEditingTransaction function in transactionStore.ts to ensure proper reactivity - Added clean copy creation of transaction objects to avoid reference issues - Implemented a state update cycle with null value first to force reactivity - Added a small timeout to ensure state changes are properly detected by components The Transaction form now correctly enables the Update button when in edit mode, regardless of account selection state.
This commit is contained in:
@@ -15,9 +15,13 @@ export default defineConfig({
|
|||||||
vite: {
|
vite: {
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'react-dom/server': 'react-dom/server',
|
// Use the browser version of react-dom/server for client-side rendering
|
||||||
'react-dom/server.browser': 'react-dom/server',
|
'react-dom/server.browser': 'react-dom/cjs/react-dom-server.browser.production.min.js',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Prevent server-only modules from being bundled for client side
|
||||||
|
ssr: {
|
||||||
|
noExternal: ['react-dom/server'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
155
docs/db-integration-testing-plan.md
Normal file
155
docs/db-integration-testing-plan.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
# Database Integration Testing Plan
|
||||||
|
|
||||||
|
This document outlines the testing strategy for verifying the successful integration of our PostgreSQL database with the finance application.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before running tests, ensure that:
|
||||||
|
|
||||||
|
1. PostgreSQL is running (via Docker or locally)
|
||||||
|
2. Database migrations have been applied (`npm run db:migrate -- --name initial`)
|
||||||
|
3. Seed data has been loaded (`npm run db:seed`)
|
||||||
|
4. The application server is running (`npm run dev`)
|
||||||
|
|
||||||
|
## 1. Database Connection Testing
|
||||||
|
|
||||||
|
- **Test Case 1.1:** Verify Prisma can connect to the database
|
||||||
|
- Run `npx prisma studio` to open the Prisma Studio interface
|
||||||
|
- Verify you can view the database tables and data
|
||||||
|
|
||||||
|
- **Test Case 1.2:** Check database tables creation
|
||||||
|
- Verify `accounts` and `transactions` tables exist
|
||||||
|
- Verify all columns match the schema definition
|
||||||
|
- Check that proper indexes are created
|
||||||
|
|
||||||
|
## 2. Account Service Testing
|
||||||
|
|
||||||
|
- **Test Case 2.1:** Get all accounts
|
||||||
|
- Access `/api/accounts` endpoint
|
||||||
|
- Verify it returns the seeded accounts with correct data
|
||||||
|
- Check response format and status code (200)
|
||||||
|
|
||||||
|
- **Test Case 2.2:** Get single account
|
||||||
|
- Get account ID from the list of accounts
|
||||||
|
- Access `/api/accounts/{id}` endpoint
|
||||||
|
- Verify it returns the correct account data
|
||||||
|
- Check response format and status code (200)
|
||||||
|
|
||||||
|
- **Test Case 2.3:** Handle non-existent account
|
||||||
|
- Access `/api/accounts/nonexistent-id` endpoint
|
||||||
|
- Verify it returns a 404 status code with appropriate error message
|
||||||
|
|
||||||
|
## 3. Transaction Service Testing
|
||||||
|
|
||||||
|
- **Test Case 3.1:** Get account transactions
|
||||||
|
- Get account ID from the list of accounts
|
||||||
|
- Access `/api/accounts/{id}/transactions` endpoint
|
||||||
|
- Verify it returns the correct transactions for that account
|
||||||
|
- Check response format and status code (200)
|
||||||
|
|
||||||
|
- **Test Case 3.2:** Create new transaction
|
||||||
|
- Send POST request to `/api/transactions` with valid transaction data
|
||||||
|
- Verify the response contains the created transaction with an ID
|
||||||
|
- Check that account balance is updated correctly
|
||||||
|
- Check response status code (201)
|
||||||
|
|
||||||
|
- **Test Case 3.3:** Update transaction
|
||||||
|
- Get transaction ID from a list of transactions
|
||||||
|
- Send PUT request to `/api/transactions/{id}` with updated data
|
||||||
|
- Verify the transaction is updated in the database
|
||||||
|
- Check that account balance is adjusted correctly
|
||||||
|
- Check response status code (200)
|
||||||
|
|
||||||
|
- **Test Case 3.4:** Delete transaction
|
||||||
|
- Get transaction ID from a list of transactions
|
||||||
|
- Send DELETE request to `/api/transactions/{id}`
|
||||||
|
- Verify the transaction is removed from the database
|
||||||
|
- Check that account balance is adjusted correctly
|
||||||
|
- Check response status code (204)
|
||||||
|
|
||||||
|
## 4. Error Handling Testing
|
||||||
|
|
||||||
|
- **Test Case 4.1:** Submit invalid transaction data
|
||||||
|
- Send POST request with missing required fields
|
||||||
|
- Verify appropriate error messages are returned
|
||||||
|
- Check response status code (400)
|
||||||
|
|
||||||
|
- **Test Case 4.2:** Update non-existent transaction
|
||||||
|
- Send PUT request to `/api/transactions/nonexistent-id`
|
||||||
|
- Verify appropriate error message is returned
|
||||||
|
- Check response status code (404)
|
||||||
|
|
||||||
|
- **Test Case 4.3:** Delete non-existent transaction
|
||||||
|
- Send DELETE request to `/api/transactions/nonexistent-id`
|
||||||
|
- Verify appropriate error message is returned
|
||||||
|
- Check response status code (404)
|
||||||
|
|
||||||
|
## 5. UI Integration Testing
|
||||||
|
|
||||||
|
- **Test Case 5.1:** Account selection updates
|
||||||
|
- Select different accounts from the dropdown
|
||||||
|
- Verify account summary (balance) updates correctly
|
||||||
|
- Verify transaction table updates to show correct transactions
|
||||||
|
|
||||||
|
- **Test Case 5.2:** Add transaction form submission
|
||||||
|
- Fill out the transaction form with valid data
|
||||||
|
- Submit the form
|
||||||
|
- Verify the new transaction appears in the transaction table
|
||||||
|
- Verify account balance updates correctly
|
||||||
|
|
||||||
|
- **Test Case 5.3:** Edit transaction functionality
|
||||||
|
- Click edit button on an existing transaction
|
||||||
|
- Modify transaction data and submit
|
||||||
|
- Verify the transaction is updated in the transaction table
|
||||||
|
- Verify account balance updates correctly
|
||||||
|
|
||||||
|
- **Test Case 5.4:** Delete transaction functionality
|
||||||
|
- Click delete button on an existing transaction
|
||||||
|
- Confirm deletion
|
||||||
|
- Verify the transaction is removed from the transaction table
|
||||||
|
- Verify account balance updates correctly
|
||||||
|
|
||||||
|
## 6. Data Consistency Testing
|
||||||
|
|
||||||
|
- **Test Case 6.1:** Account balance consistency
|
||||||
|
- Calculate the sum of transaction amounts for an account
|
||||||
|
- Compare with the account balance in the database
|
||||||
|
- Verify they match exactly
|
||||||
|
|
||||||
|
- **Test Case 6.2:** Transaction sequence
|
||||||
|
- Create multiple transactions rapidly
|
||||||
|
- Verify all transactions are saved correctly
|
||||||
|
- Check that account balance reflects all transactions
|
||||||
|
|
||||||
|
## 7. Performance Testing
|
||||||
|
|
||||||
|
- **Test Case 7.1:** Load testing with multiple transactions
|
||||||
|
- Create a test script that adds 100+ transactions
|
||||||
|
- Verify the application handles the load without errors
|
||||||
|
- Check response times remain reasonable
|
||||||
|
|
||||||
|
## Test Automation
|
||||||
|
|
||||||
|
Consider creating automated tests for these scenarios using the following approaches:
|
||||||
|
|
||||||
|
1. **API Tests:** Use Vitest with Supertest to automate API endpoint testing
|
||||||
|
2. **Integration Tests:** Use Vitest with JSDOM to test UI components with mocked API calls
|
||||||
|
3. **E2E Tests:** Consider adding Playwright or Cypress for end-to-end testing
|
||||||
|
|
||||||
|
## Reporting Issues
|
||||||
|
|
||||||
|
Document any issues found during testing with:
|
||||||
|
|
||||||
|
1. Steps to reproduce
|
||||||
|
2. Expected behavior
|
||||||
|
3. Actual behavior
|
||||||
|
4. Screenshots or console logs if applicable
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
The database integration will be considered successfully tested when:
|
||||||
|
|
||||||
|
1. All CRUD operations work correctly through both API and UI
|
||||||
|
2. Account balances remain consistent with transactions
|
||||||
|
3. Error handling works correctly for all error cases
|
||||||
|
4. The application maintains performance under expected load
|
||||||
413
overview.md
413
overview.md
@@ -1,413 +0,0 @@
|
|||||||
Okay, let's set up a skeleton Fastify API project with Prisma to interact with a PostgreSQL database. This structure will provide the requested endpoints.
|
|
||||||
|
|
||||||
**Assumptions:**
|
|
||||||
|
|
||||||
1. You have Node.js and npm (or yarn) installed.
|
|
||||||
2. You have PostgreSQL installed and running locally.
|
|
||||||
3. You have a PostgreSQL database created (e.g., `finance_db`).
|
|
||||||
4. You have a PostgreSQL user with privileges on that database.
|
|
||||||
|
|
||||||
**Project Setup Steps:**
|
|
||||||
|
|
||||||
1. **Create Project Directory & Initialize:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir finance-api
|
|
||||||
cd finance-api
|
|
||||||
npm init -y
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install Dependencies:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Runtime dependencies
|
|
||||||
npm install fastify @prisma/client dotenv
|
|
||||||
|
|
||||||
# Development dependencies
|
|
||||||
npm install --save-dev prisma typescript @types/node ts-node nodemon
|
|
||||||
# Or if you prefer plain Javascript, skip typescript/ts-node and adjust scripts
|
|
||||||
```
|
|
||||||
* `fastify`: The web framework.
|
|
||||||
* `@prisma/client`: The Prisma database client.
|
|
||||||
* `dotenv`: To load environment variables from a `.env` file.
|
|
||||||
* `prisma`: The Prisma CLI (for migrations, generation).
|
|
||||||
* `typescript`, `@types/node`, `ts-node`: For TypeScript support (recommended).
|
|
||||||
* `nodemon`: To automatically restart the server during development.
|
|
||||||
|
|
||||||
3. **Initialize Prisma:**
|
|
||||||
```bash
|
|
||||||
npx prisma init --datasource-provider postgresql
|
|
||||||
```
|
|
||||||
* This creates a `prisma` directory with a `schema.prisma` file and a `.env` file.
|
|
||||||
|
|
||||||
4. **Configure `.env`:**
|
|
||||||
Edit the newly created `.env` file and set your PostgreSQL connection URL:
|
|
||||||
```dotenv
|
|
||||||
# .env
|
|
||||||
# Replace with your actual database user, password, host, port, and database name
|
|
||||||
DATABASE_URL="postgresql://YOUR_USER:YOUR_PASSWORD@localhost:5432/finance_db?schema=public"
|
|
||||||
|
|
||||||
# API Server Configuration (Optional, but good practice)
|
|
||||||
API_HOST=0.0.0.0
|
|
||||||
API_PORT=3050
|
|
||||||
API_BASE_URL=https://finance.ptrwd.com # Used for documentation/reference, not binding
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Define Prisma Schema (`prisma/schema.prisma`):**
|
|
||||||
Define the model for your bank accounts.
|
|
||||||
|
|
||||||
```prisma
|
|
||||||
// prisma/schema.prisma
|
|
||||||
|
|
||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
model BankAccount {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
name String // e.g., "Checking Account", "Savings XYZ"
|
|
||||||
bankName String // e.g., "Chase", "Wells Fargo"
|
|
||||||
accountNumber String @unique // Consider encryption in a real app
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@map("bank_accounts") // Optional: specify table name in snake_case
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Create Initial Database Migration:**
|
|
||||||
```bash
|
|
||||||
npx prisma migrate dev --name init_bank_account
|
|
||||||
```
|
|
||||||
* This command does two things:
|
|
||||||
* Creates an SQL migration file in `prisma/migrations`.
|
|
||||||
* Applies the migration to your database, creating the `bank_accounts` table.
|
|
||||||
|
|
||||||
7. **Generate Prisma Client:**
|
|
||||||
Ensure the client is generated based on your schema:
|
|
||||||
```bash
|
|
||||||
npx prisma generate
|
|
||||||
```
|
|
||||||
* This command reads your `schema.prisma` and generates the typed `@prisma/client`. You typically run this after any schema change.
|
|
||||||
|
|
||||||
8. **Create the Fastify Server (`src/server.ts` or `server.js`):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/server.ts (if using TypeScript)
|
|
||||||
// If using Javascript, remove type annotations and use require instead of import
|
|
||||||
|
|
||||||
import Fastify, { FastifyInstance, RouteShorthandOptions } from 'fastify';
|
|
||||||
import { Server, IncomingMessage, ServerResponse } from 'http';
|
|
||||||
import { PrismaClient, BankAccount } from '@prisma/client';
|
|
||||||
import dotenv from 'dotenv';
|
|
||||||
|
|
||||||
// Load environment variables
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
const server: FastifyInstance = Fastify({
|
|
||||||
logger: true // Enable logging
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Type Definitions for Payloads/Params (Good Practice) ---
|
|
||||||
interface BankAccountCreateParams {
|
|
||||||
name: string;
|
|
||||||
bankName: string;
|
|
||||||
accountNumber: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BankAccountUpdateParams {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
interface BankAccountUpdateBody {
|
|
||||||
name?: string;
|
|
||||||
bankName?: string;
|
|
||||||
accountNumber?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BankAccountDeleteParams {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- API Routes ---
|
|
||||||
const API_PREFIX = '/api/bank-account';
|
|
||||||
|
|
||||||
// 1. Create Bank Account
|
|
||||||
server.post<{ Body: BankAccountCreateParams }>(
|
|
||||||
`${API_PREFIX}/create`,
|
|
||||||
async (request, reply): Promise<BankAccount> => {
|
|
||||||
try {
|
|
||||||
const { name, bankName, accountNumber } = request.body;
|
|
||||||
const newAccount = await prisma.bankAccount.create({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
bankName,
|
|
||||||
accountNumber,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
reply.code(201); // Resource Created
|
|
||||||
return newAccount;
|
|
||||||
} catch (error: any) {
|
|
||||||
server.log.error(error);
|
|
||||||
// Basic duplicate check example (Prisma throws P2002)
|
|
||||||
if (error.code === 'P2002' && error.meta?.target?.includes('accountNumber')) {
|
|
||||||
reply.code(409); // Conflict
|
|
||||||
throw new Error(`Bank account with number ${request.body.accountNumber} already exists.`);
|
|
||||||
}
|
|
||||||
reply.code(500);
|
|
||||||
throw new Error('Failed to create bank account.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Update Bank Account
|
|
||||||
server.post<{ Params: BankAccountUpdateParams; Body: BankAccountUpdateBody }>(
|
|
||||||
`${API_PREFIX}/update/:id`,
|
|
||||||
async (request, reply): Promise<BankAccount> => {
|
|
||||||
try {
|
|
||||||
const { id } = request.params;
|
|
||||||
const updateData = request.body;
|
|
||||||
|
|
||||||
// Ensure ID is a valid number before querying
|
|
||||||
const accountId = parseInt(id, 10);
|
|
||||||
if (isNaN(accountId)) {
|
|
||||||
reply.code(400);
|
|
||||||
throw new Error('Invalid account ID format.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedAccount = await prisma.bankAccount.update({
|
|
||||||
where: { id: accountId },
|
|
||||||
data: updateData,
|
|
||||||
});
|
|
||||||
return updatedAccount;
|
|
||||||
} catch (error: any) {
|
|
||||||
server.log.error(error);
|
|
||||||
// Handle case where account doesn't exist (Prisma throws P2025)
|
|
||||||
if (error.code === 'P2025') {
|
|
||||||
reply.code(404); // Not Found
|
|
||||||
throw new Error(`Bank account with ID ${request.params.id} not found.`);
|
|
||||||
}
|
|
||||||
// Handle potential duplicate account number on update
|
|
||||||
if (error.code === 'P2002' && error.meta?.target?.includes('accountNumber')) {
|
|
||||||
reply.code(409); // Conflict
|
|
||||||
throw new Error(`Bank account with number ${request.body.accountNumber} already exists.`);
|
|
||||||
}
|
|
||||||
reply.code(500);
|
|
||||||
throw new Error('Failed to update bank account.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. Delete Bank Account
|
|
||||||
server.delete<{ Params: BankAccountDeleteParams }>(
|
|
||||||
`${API_PREFIX}/delete/:id`,
|
|
||||||
async (request, reply): Promise<{ message: string; deletedAccount: BankAccount }> => {
|
|
||||||
try {
|
|
||||||
const { id } = request.params;
|
|
||||||
// Ensure ID is a valid number
|
|
||||||
const accountId = parseInt(id, 10);
|
|
||||||
if (isNaN(accountId)) {
|
|
||||||
reply.code(400);
|
|
||||||
throw new Error('Invalid account ID format.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedAccount = await prisma.bankAccount.delete({
|
|
||||||
where: { id: accountId },
|
|
||||||
});
|
|
||||||
return { message: `Bank account with ID ${id} deleted successfully.`, deletedAccount };
|
|
||||||
} catch (error: any) {
|
|
||||||
server.log.error(error);
|
|
||||||
// Handle case where account doesn't exist (Prisma throws P2025)
|
|
||||||
if (error.code === 'P2025') {
|
|
||||||
reply.code(404); // Not Found
|
|
||||||
throw new Error(`Bank account with ID ${request.params.id} not found.`);
|
|
||||||
}
|
|
||||||
reply.code(500);
|
|
||||||
throw new Error('Failed to delete bank account.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. Get All Bank Accounts
|
|
||||||
server.get(
|
|
||||||
`${API_PREFIX}/`, // Using trailing slash for consistency, Fastify often handles both
|
|
||||||
async (request, reply): Promise<BankAccount[]> => {
|
|
||||||
try {
|
|
||||||
const accounts = await prisma.bankAccount.findMany({
|
|
||||||
orderBy: { // Optional: Order results
|
|
||||||
createdAt: 'desc'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return accounts;
|
|
||||||
} catch (error: any) {
|
|
||||||
server.log.error(error);
|
|
||||||
reply.code(500);
|
|
||||||
throw new Error('Failed to retrieve bank accounts.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Optional: Get Single Bank Account by ID (Often useful)
|
|
||||||
server.get<{ Params: { id: string } }>(
|
|
||||||
`${API_PREFIX}/:id`,
|
|
||||||
async (request, reply): Promise<BankAccount> => {
|
|
||||||
try {
|
|
||||||
const { id } = request.params;
|
|
||||||
const accountId = parseInt(id, 10);
|
|
||||||
if (isNaN(accountId)) {
|
|
||||||
reply.code(400);
|
|
||||||
throw new Error('Invalid account ID format.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await prisma.bankAccount.findUnique({
|
|
||||||
where: { id: accountId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
reply.code(404);
|
|
||||||
throw new Error(`Bank account with ID ${id} not found.`);
|
|
||||||
}
|
|
||||||
return account;
|
|
||||||
} catch (error: any) {
|
|
||||||
// Catch errors that might have been thrown before prisma query
|
|
||||||
if (!reply.sent) { // Check if a reply hasn't already been sent (e.g., by 400 error)
|
|
||||||
server.log.error(error);
|
|
||||||
reply.code(500);
|
|
||||||
throw new Error('Failed to retrieve bank account.');
|
|
||||||
}
|
|
||||||
// If reply already sent, just rethrow the original error
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
// --- Graceful Shutdown ---
|
|
||||||
const gracefulShutdown = async (signal: string) => {
|
|
||||||
console.log(`*^! Received signal ${signal}. Shutting down...`);
|
|
||||||
try {
|
|
||||||
await server.close();
|
|
||||||
console.log('Fastify server closed.');
|
|
||||||
await prisma.$disconnect();
|
|
||||||
console.log('Prisma client disconnected.');
|
|
||||||
process.exit(0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error during shutdown:', err);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
||||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
||||||
|
|
||||||
|
|
||||||
// --- Start Server ---
|
|
||||||
const start = async () => {
|
|
||||||
try {
|
|
||||||
const host = process.env.API_HOST || '0.0.0.0';
|
|
||||||
const port = parseInt(process.env.API_PORT || '3050', 10);
|
|
||||||
|
|
||||||
await server.listen({ port, host });
|
|
||||||
|
|
||||||
// Log after listen resolves (Fastify v3+)
|
|
||||||
// server.log.info(`Server listening at http://${host}:${port}`);
|
|
||||||
// Note: Fastify logger automatically logs listening address
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
server.log.error(err);
|
|
||||||
await prisma.$disconnect(); // Ensure prisma disconnects on startup error too
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
start();
|
|
||||||
|
|
||||||
// Export prisma instance if needed elsewhere (e.g., tests, scripts)
|
|
||||||
// export { prisma };
|
|
||||||
```
|
|
||||||
|
|
||||||
9. **Configure `tsconfig.json` (if using TypeScript):**
|
|
||||||
Create a `tsconfig.json` file in the root directory:
|
|
||||||
```json
|
|
||||||
// tsconfig.json
|
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2017", // Or newer
|
|
||||||
"module": "CommonJS",
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"sourceMap": true, // Helpful for debugging
|
|
||||||
"resolveJsonModule": true // Allows importing JSON files
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"], // Only compile files in src
|
|
||||||
"exclude": ["node_modules", "**/*.spec.ts"] // Exclude tests if you have them
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
10. **Add Scripts to `package.json`:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
// package.json (add or modify the scripts section)
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc", // Compile TypeScript to JavaScript (if using TS)
|
|
||||||
"start": "node dist/server.js", // Run compiled JS (if using TS)
|
|
||||||
"dev": "nodemon --watch src --ext ts --exec ts-node src/server.ts", // Dev mode with TS
|
|
||||||
// --- OR if using plain JavaScript ---
|
|
||||||
// "start": "node server.js",
|
|
||||||
// "dev": "nodemon server.js",
|
|
||||||
// --- Prisma commands for convenience ---
|
|
||||||
"prisma:migrate": "prisma migrate dev",
|
|
||||||
"prisma:generate": "prisma generate",
|
|
||||||
"prisma:studio": "prisma studio" // GUI to view/edit data
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
11. **Run the Development Server:**
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**How to Test (using `curl`):**
|
|
||||||
|
|
||||||
*(Replace `YOUR_ACCOUNT_ID` with an actual ID after creating an account)*
|
|
||||||
|
|
||||||
* **Create:**
|
|
||||||
```bash
|
|
||||||
curl -X POST -H "Content-Type: application/json" \
|
|
||||||
-d '{"name": "My Checking", "bankName": "Local Bank", "accountNumber": "123456789"}' \
|
|
||||||
http://localhost:3050/api/bank-account/create
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Get All:**
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3050/api/bank-account/
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Update (using the ID returned from create):**
|
|
||||||
```bash
|
|
||||||
curl -X POST -H "Content-Type: application/json" \
|
|
||||||
-d '{"name": "My Primary Checking"}' \
|
|
||||||
http://localhost:3050/api/bank-account/update/YOUR_ACCOUNT_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Delete (using the ID):**
|
|
||||||
```bash
|
|
||||||
curl -X DELETE http://localhost:3050/api/bank-account/delete/YOUR_ACCOUNT_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Get Specific (using the ID):**
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3050/api/bank-account/YOUR_ACCOUNT_ID
|
|
||||||
```
|
|
||||||
|
|
||||||
This skeleton provides the core structure. You can build upon this by adding more robust error handling, input validation (using Fastify's built-in schema validation), authentication/authorization, more complex queries, and organizing routes into separate files/plugins as the application grows.
|
|
||||||
1157
package-lock.json
generated
1157
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
24
package.json
24
package.json
@@ -7,17 +7,24 @@
|
|||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"test": "vitest",
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
"check": "biome check --apply ."
|
"check": "biome check --apply .",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:seed": "node prisma/seed.js",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "^12.5.1",
|
"@astrojs/cloudflare": "^12.5.1",
|
||||||
"@astrojs/node": "^9.2.1",
|
"@astrojs/node": "^9.2.1",
|
||||||
"@astrojs/react": "^4.2.5",
|
"@astrojs/react": "^4.2.5",
|
||||||
"@nanostores/react": "^1.0.0",
|
"@nanostores/react": "^1.0.0",
|
||||||
|
"@prisma/client": "^6.7.0",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"astro": "^5.7.5",
|
"astro": "^5.7.5",
|
||||||
@@ -27,10 +34,21 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
|
"@testing-library/dom": "^10.4.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
"@vitest/coverage-v8": "^3.1.2",
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"@vitest/coverage-v8": "^3.1.3",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"prisma": "^6.7.0",
|
||||||
"supertest": "^7.1.0",
|
"supertest": "^7.1.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
"vitest": "^3.1.2",
|
"vitest": "^3.1.2",
|
||||||
"wrangler": "^4.13.1"
|
"wrangler": "^4.13.1"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "node prisma/seed.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
prisma/migrations/20250505192354_initial/migration.sql
Normal file
61
prisma/migrations/20250505192354_initial/migration.sql
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AccountType" AS ENUM ('CHECKING', 'SAVINGS', 'CREDIT_CARD', 'INVESTMENT', 'OTHER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AccountStatus" AS ENUM ('ACTIVE', 'CLOSED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TransactionStatus" AS ENUM ('PENDING', 'CLEARED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TransactionType" AS ENUM ('DEPOSIT', 'WITHDRAWAL', 'TRANSFER', 'UNSPECIFIED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "accounts" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"bankName" TEXT NOT NULL,
|
||||||
|
"accountNumber" VARCHAR(6) NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" "AccountType" NOT NULL DEFAULT 'CHECKING',
|
||||||
|
"status" "AccountStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
"balance" DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "transactions" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"date" TIMESTAMP(3) NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"amount" DECIMAL(10,2) NOT NULL,
|
||||||
|
"category" TEXT,
|
||||||
|
"status" "TransactionStatus" NOT NULL DEFAULT 'CLEARED',
|
||||||
|
"type" "TransactionType" NOT NULL DEFAULT 'UNSPECIFIED',
|
||||||
|
"notes" TEXT,
|
||||||
|
"tags" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "transactions_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "accounts_status_idx" ON "accounts"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "transactions_accountId_idx" ON "transactions"("accountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "transactions_date_idx" ON "transactions"("date");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "transactions_category_idx" ON "transactions"("category");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "transactions" ADD CONSTRAINT "transactions_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "accounts"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -9,13 +9,66 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
model BankAccount {
|
model Account {
|
||||||
id Int @id @default(autoincrement())
|
id String @id @default(uuid())
|
||||||
name String // e.g., "Checking Account", "Savings XYZ"
|
bankName String
|
||||||
bankName String // e.g., "Chase", "Wells Fargo"
|
accountNumber String @db.VarChar(6) // Last 6 digits
|
||||||
accountNumber String @unique // Consider encryption in a real app
|
name String // Friendly name
|
||||||
createdAt DateTime @default(now())
|
type AccountType @default(CHECKING)
|
||||||
updatedAt DateTime @updatedAt
|
status AccountStatus @default(ACTIVE)
|
||||||
|
currency String @default("USD")
|
||||||
|
balance Decimal @default(0) @db.Decimal(10, 2)
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
transactions Transaction[]
|
||||||
|
|
||||||
@@map("bank_accounts") // Optional: specify table name in snake_case
|
@@index([status])
|
||||||
|
@@map("accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Transaction {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
accountId String
|
||||||
|
account Account @relation(fields: [accountId], references: [id])
|
||||||
|
date DateTime
|
||||||
|
description String
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
category String?
|
||||||
|
status TransactionStatus @default(CLEARED)
|
||||||
|
type TransactionType @default(UNSPECIFIED)
|
||||||
|
notes String?
|
||||||
|
tags String? // Comma-separated values for tags
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([accountId])
|
||||||
|
@@index([date])
|
||||||
|
@@index([category])
|
||||||
|
@@map("transactions")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountType {
|
||||||
|
CHECKING
|
||||||
|
SAVINGS
|
||||||
|
CREDIT_CARD
|
||||||
|
INVESTMENT
|
||||||
|
OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountStatus {
|
||||||
|
ACTIVE
|
||||||
|
CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransactionStatus {
|
||||||
|
PENDING
|
||||||
|
CLEARED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransactionType {
|
||||||
|
DEPOSIT
|
||||||
|
WITHDRAWAL
|
||||||
|
TRANSFER
|
||||||
|
UNSPECIFIED
|
||||||
}
|
}
|
||||||
87
prisma/seed.js
Normal file
87
prisma/seed.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Starting database seeding...');
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
await prisma.transaction.deleteMany({});
|
||||||
|
await prisma.account.deleteMany({});
|
||||||
|
|
||||||
|
console.log('Cleared existing data');
|
||||||
|
|
||||||
|
// Create accounts
|
||||||
|
const checkingAccount = await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
bankName: 'First National Bank',
|
||||||
|
accountNumber: '432198',
|
||||||
|
name: 'Checking Account',
|
||||||
|
type: 'CHECKING',
|
||||||
|
balance: 2500.0,
|
||||||
|
notes: 'Primary checking account',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const savingsAccount = await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
bankName: 'First National Bank',
|
||||||
|
accountNumber: '876543',
|
||||||
|
name: 'Savings Account',
|
||||||
|
type: 'SAVINGS',
|
||||||
|
balance: 10000.0,
|
||||||
|
notes: 'Emergency fund',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Created accounts:', {
|
||||||
|
checkingAccount: checkingAccount.id,
|
||||||
|
savingsAccount: savingsAccount.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create transactions
|
||||||
|
const transactions = await Promise.all([
|
||||||
|
prisma.transaction.create({
|
||||||
|
data: {
|
||||||
|
accountId: checkingAccount.id,
|
||||||
|
date: new Date('2025-04-20'),
|
||||||
|
description: 'Grocery Store',
|
||||||
|
amount: -75.5,
|
||||||
|
category: 'Groceries',
|
||||||
|
type: 'WITHDRAWAL',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.transaction.create({
|
||||||
|
data: {
|
||||||
|
accountId: checkingAccount.id,
|
||||||
|
date: new Date('2025-04-21'),
|
||||||
|
description: 'Salary Deposit',
|
||||||
|
amount: 3000.0,
|
||||||
|
category: 'Income',
|
||||||
|
type: 'DEPOSIT',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.transaction.create({
|
||||||
|
data: {
|
||||||
|
accountId: savingsAccount.id,
|
||||||
|
date: new Date('2025-04-22'),
|
||||||
|
description: 'Transfer to Savings',
|
||||||
|
amount: 500.0,
|
||||||
|
category: 'Transfer',
|
||||||
|
type: 'TRANSFER',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`Created ${transactions.length} transactions`);
|
||||||
|
console.log('Seeding completed successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Error during seeding:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
87
prisma/seed.ts
Normal file
87
prisma/seed.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Starting database seeding...');
|
||||||
|
|
||||||
|
// Clear existing data
|
||||||
|
await prisma.transaction.deleteMany({});
|
||||||
|
await prisma.account.deleteMany({});
|
||||||
|
|
||||||
|
console.log('Cleared existing data');
|
||||||
|
|
||||||
|
// Create accounts
|
||||||
|
const checkingAccount = await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
bankName: 'First National Bank',
|
||||||
|
accountNumber: '432198',
|
||||||
|
name: 'Checking Account',
|
||||||
|
type: 'CHECKING',
|
||||||
|
balance: 2500.0,
|
||||||
|
notes: 'Primary checking account',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const savingsAccount = await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
bankName: 'First National Bank',
|
||||||
|
accountNumber: '876543',
|
||||||
|
name: 'Savings Account',
|
||||||
|
type: 'SAVINGS',
|
||||||
|
balance: 10000.0,
|
||||||
|
notes: 'Emergency fund',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Created accounts:', {
|
||||||
|
checkingAccount: checkingAccount.id,
|
||||||
|
savingsAccount: savingsAccount.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create transactions
|
||||||
|
const transactions = await Promise.all([
|
||||||
|
prisma.transaction.create({
|
||||||
|
data: {
|
||||||
|
accountId: checkingAccount.id,
|
||||||
|
date: new Date('2025-04-20'),
|
||||||
|
description: 'Grocery Store',
|
||||||
|
amount: -75.5,
|
||||||
|
category: 'Groceries',
|
||||||
|
type: 'WITHDRAWAL',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.transaction.create({
|
||||||
|
data: {
|
||||||
|
accountId: checkingAccount.id,
|
||||||
|
date: new Date('2025-04-21'),
|
||||||
|
description: 'Salary Deposit',
|
||||||
|
amount: 3000.0,
|
||||||
|
category: 'Income',
|
||||||
|
type: 'DEPOSIT',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.transaction.create({
|
||||||
|
data: {
|
||||||
|
accountId: savingsAccount.id,
|
||||||
|
date: new Date('2025-04-22'),
|
||||||
|
description: 'Transfer to Savings',
|
||||||
|
amount: 500.0,
|
||||||
|
category: 'Transfer',
|
||||||
|
type: 'TRANSFER',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log(`Created ${transactions.length} transactions`);
|
||||||
|
console.log('Seeding completed successfully!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Error during seeding:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -1,238 +1,269 @@
|
|||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import type React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
// Import store atoms and actions
|
|
||||||
import {
|
import {
|
||||||
cancelEditingTransaction,
|
cancelEditingTransaction,
|
||||||
currentAccountId as currentAccountIdStore,
|
currentAccountId,
|
||||||
|
loadTransactionsForAccount,
|
||||||
transactionSaved,
|
transactionSaved,
|
||||||
transactionToEdit as transactionToEditStore,
|
transactionToEdit,
|
||||||
|
triggerRefresh,
|
||||||
} from '../stores/transactionStore';
|
} from '../stores/transactionStore';
|
||||||
import type { Transaction } from '../types';
|
|
||||||
|
|
||||||
export default function AddTransactionForm() {
|
export default function AddTransactionForm() {
|
||||||
// --- Read state from store ---
|
const accountId = useStore(currentAccountId);
|
||||||
const currentAccountId = useStore(currentAccountIdStore);
|
const editingTransaction = useStore(transactionToEdit);
|
||||||
const transactionToEdit = useStore(transactionToEditStore);
|
|
||||||
|
|
||||||
// --- State Variables ---
|
// Form state - initialize with empty values to avoid hydration mismatch
|
||||||
const [date, setDate] = useState('');
|
const [date, setDate] = useState('');
|
||||||
const [description, setDescription] = useState('');
|
const [description, setDescription] = useState('');
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [category, setCategory] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [type, setType] = useState('WITHDRAWAL');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
const isEditMode = !!editingId;
|
// Set initial date only on client-side after component mounts
|
||||||
|
|
||||||
// --- Effects ---
|
|
||||||
// Effect to set default date on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only set default date if not editing
|
// Only run this effect on the client side to prevent hydration mismatch
|
||||||
if (!transactionToEdit) {
|
if (!date) {
|
||||||
setDate(new Date().toISOString().split('T')[0]);
|
const today = new Date();
|
||||||
|
setDate(today.toISOString().split('T')[0]);
|
||||||
}
|
}
|
||||||
}, [transactionToEdit]); // Rerun if edit mode changes
|
}, []);
|
||||||
|
|
||||||
// Effect to populate form when editing
|
// Reset form when accountId changes or when switching from edit to add mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (transactionToEdit) {
|
if (!editingTransaction) {
|
||||||
setEditingId(transactionToEdit.id);
|
resetForm();
|
||||||
// Format date correctly for input type="date"
|
}
|
||||||
|
}, [accountId, editingTransaction === null]);
|
||||||
|
|
||||||
|
// Populate form when editing a transaction
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingTransaction) {
|
||||||
|
let dateStr: string;
|
||||||
try {
|
try {
|
||||||
const dateObj = new Date(transactionToEdit.date);
|
if (editingTransaction.date instanceof Date) {
|
||||||
// Check if date is valid before formatting
|
dateStr = editingTransaction.date.toISOString().split('T')[0];
|
||||||
if (!Number.isNaN(dateObj.getTime())) {
|
|
||||||
// Directly format the date object (usually interpreted as UTC midnight)
|
|
||||||
// into the YYYY-MM-DD format required by the input.
|
|
||||||
// No timezone adjustment needed here.
|
|
||||||
setDate(dateObj.toISOString().split('T')[0]);
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('Invalid date received for editing:', transactionToEdit.date);
|
// Handle string dates safely
|
||||||
setDate(''); // Set to empty if invalid
|
const parsedDate = new Date(String(editingTransaction.date));
|
||||||
|
dateStr = Number.isNaN(parsedDate.getTime())
|
||||||
|
? new Date().toISOString().split('T')[0] // Fallback to today if invalid
|
||||||
|
: parsedDate.toISOString().split('T')[0];
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
console.error('Error parsing date for editing:', e);
|
console.error('Error parsing date for edit:', error);
|
||||||
setDate(''); // Set to empty on error
|
dateStr = new Date().toISOString().split('T')[0]; // Fallback to today
|
||||||
}
|
}
|
||||||
setDescription(transactionToEdit.description);
|
|
||||||
setAmount(transactionToEdit.amount.toString());
|
|
||||||
setError(null); // Clear errors when starting edit
|
|
||||||
} else {
|
|
||||||
// Reset form if transactionToEdit becomes null (e.g., after saving or cancelling)
|
|
||||||
// but only if not already resetting via handleCancel or handleSubmit
|
|
||||||
if (!isLoading) {
|
|
||||||
resetForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [transactionToEdit, isLoading]); // Add isLoading dependency
|
|
||||||
|
|
||||||
// --- Helper Functions ---
|
setDate(dateStr);
|
||||||
|
setDescription(editingTransaction.description);
|
||||||
|
setAmount(String(Math.abs(editingTransaction.amount)));
|
||||||
|
setCategory(editingTransaction.category || '');
|
||||||
|
setType(editingTransaction.amount < 0 ? 'WITHDRAWAL' : 'DEPOSIT');
|
||||||
|
}
|
||||||
|
}, [editingTransaction]);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setEditingId(null);
|
// Get today's date in YYYY-MM-DD format for the date input
|
||||||
setDate(new Date().toISOString().split('T')[0]);
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
setDate(today);
|
||||||
setDescription('');
|
setDescription('');
|
||||||
setAmount('');
|
setAmount('');
|
||||||
|
setCategory('');
|
||||||
|
setType('WITHDRAWAL');
|
||||||
setError(null);
|
setError(null);
|
||||||
// Don't reset isLoading here, it's handled in submit/cancel
|
setSuccessMessage(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateForm = (): string[] => {
|
|
||||||
const errors: string[] = [];
|
|
||||||
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 = Number.parseFloat(amount);
|
|
||||||
if (Number.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 {
|
|
||||||
try {
|
|
||||||
const dateObj = new Date(`${date}T00:00:00`); // Treat input as local date
|
|
||||||
if (Number.isNaN(dateObj.getTime())) {
|
|
||||||
errors.push('Invalid date format');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
errors.push('Invalid date format');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Event Handlers ---
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!accountId) {
|
||||||
|
setError('No account selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!date || !description || !amount) {
|
||||||
|
setError('Date, description and amount are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSuccessMessage(null);
|
||||||
|
|
||||||
if (isLoading || !currentAccountId) {
|
// Calculate final amount based on type
|
||||||
if (!currentAccountId) setError('No account selected.');
|
const finalAmount = type === 'DEPOSIT' ? Math.abs(Number(amount)) : -Math.abs(Number(amount));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const validationErrors = validateForm();
|
|
||||||
if (validationErrors.length > 0) {
|
|
||||||
setError(validationErrors.join('. '));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure date is sent in a consistent format (e.g., YYYY-MM-DD)
|
let response;
|
||||||
// The API should handle parsing this.
|
|
||||||
const transactionData = {
|
|
||||||
accountId: currentAccountId,
|
|
||||||
date: date, // Send as YYYY-MM-DD string
|
|
||||||
description: description.trim(),
|
|
||||||
amount: Number.parseFloat(amount),
|
|
||||||
};
|
|
||||||
|
|
||||||
const method = editingId ? 'PUT' : 'POST';
|
if (editingTransaction) {
|
||||||
const url = editingId ? `/api/transactions/${editingId}` : '/api/transactions';
|
// Update existing transaction
|
||||||
|
response = await fetch(`/api/transactions/${editingTransaction.id}`, {
|
||||||
const response = await fetch(url, {
|
method: 'PUT',
|
||||||
method,
|
headers: {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
'Content-Type': 'application/json',
|
||||||
body: JSON.stringify(transactionData),
|
},
|
||||||
});
|
body: JSON.stringify({
|
||||||
|
accountId,
|
||||||
if (!response.ok) {
|
date,
|
||||||
let errorMsg = `Failed to ${isEditMode ? 'update' : 'create'} transaction`;
|
description,
|
||||||
try {
|
amount: finalAmount,
|
||||||
const errorData = await response.json();
|
category: category || undefined,
|
||||||
errorMsg = errorData.error || errorMsg;
|
type,
|
||||||
} catch (jsonError) {
|
}),
|
||||||
// Ignore if response is not JSON
|
});
|
||||||
errorMsg = `${response.status}: ${response.statusText}`;
|
} else {
|
||||||
}
|
// Create new transaction
|
||||||
throw new Error(errorMsg);
|
response = await fetch('/api/transactions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
accountId,
|
||||||
|
date,
|
||||||
|
description,
|
||||||
|
amount: finalAmount,
|
||||||
|
category: category || undefined,
|
||||||
|
type,
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedTransaction: Transaction = await response.json();
|
if (!response.ok) {
|
||||||
transactionSaved(savedTransaction); // Call store action instead of prop callback
|
const errorData = await response.json();
|
||||||
resetForm(); // Reset form on success
|
throw new Error(errorData.error || 'Transaction operation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedTransaction = await response.json();
|
||||||
|
|
||||||
|
// Handle success
|
||||||
|
setSuccessMessage(
|
||||||
|
editingTransaction
|
||||||
|
? 'Transaction updated successfully!'
|
||||||
|
: 'Transaction added successfully!',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
resetForm();
|
||||||
|
|
||||||
|
// Clear editing state
|
||||||
|
if (editingTransaction) {
|
||||||
|
cancelEditingTransaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify about saved transaction
|
||||||
|
transactionSaved(savedTransaction);
|
||||||
|
|
||||||
|
// Reload transactions to ensure the list is up to date
|
||||||
|
await loadTransactionsForAccount(accountId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'An unexpected error occurred');
|
setError(err instanceof Error ? err.message : 'An unknown error occurred');
|
||||||
|
console.error('Transaction error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
|
if (editingTransaction) {
|
||||||
|
cancelEditingTransaction();
|
||||||
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
cancelEditingTransaction(); // Call store action instead of prop callback
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- JSX ---
|
|
||||||
return (
|
return (
|
||||||
<form id="add-transaction-form-react" onSubmit={handleSubmit} noValidate>
|
<div className="transaction-form-container">
|
||||||
<h4>{isEditMode ? 'Edit Transaction' : 'New Transaction'}</h4>
|
<h3>{editingTransaction ? 'Edit Transaction' : 'Add Transaction'}</h3>
|
||||||
|
|
||||||
|
{successMessage && <div className="success-message">{successMessage}</div>}
|
||||||
|
|
||||||
{error && <div className="error-message">{error}</div>}
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
<div className="form-group">
|
<form onSubmit={handleSubmit}>
|
||||||
<label htmlFor="txn-date-react">Date</label>
|
<div className="form-group">
|
||||||
<input
|
<label htmlFor="txn-date">Date:</label>
|
||||||
type="date"
|
<input
|
||||||
id="txn-date-react"
|
type="date"
|
||||||
name="date"
|
id="txn-date"
|
||||||
value={date}
|
value={date}
|
||||||
onChange={(e) => setDate(e.target.value)}
|
onChange={(e) => setDate(e.target.value)}
|
||||||
required
|
disabled={isSubmitting}
|
||||||
disabled={isLoading}
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="txn-description-react">Description</label>
|
<div className="form-group">
|
||||||
<input
|
<label htmlFor="txn-description">Description:</label>
|
||||||
type="text"
|
<input
|
||||||
id="txn-description-react"
|
type="text"
|
||||||
name="description"
|
id="txn-description"
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
required
|
disabled={isSubmitting}
|
||||||
minLength={2}
|
placeholder="e.g., Grocery store"
|
||||||
maxLength={100}
|
required
|
||||||
placeholder="e.g. Groceries"
|
/>
|
||||||
disabled={isLoading}
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
<div className="form-group amount-group">
|
||||||
<div className="form-group">
|
<label htmlFor="txn-amount">Amount:</label>
|
||||||
<label htmlFor="txn-amount-react">Amount</label>
|
<div className="amount-input-group">
|
||||||
<input
|
<select value={type} onChange={(e) => setType(e.target.value)} disabled={isSubmitting}>
|
||||||
type="number"
|
<option value="WITHDRAWAL">-</option>
|
||||||
id="txn-amount-react"
|
<option value="DEPOSIT">+</option>
|
||||||
name="amount"
|
</select>
|
||||||
value={amount}
|
<span className="currency-symbol">$</span>
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
<input
|
||||||
step="0.01"
|
type="number"
|
||||||
required
|
id="txn-amount"
|
||||||
placeholder="e.g. -25.50 or 1200.00"
|
value={amount}
|
||||||
disabled={isLoading}
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
/>
|
disabled={isSubmitting}
|
||||||
<small className="help-text">Use negative numbers for expenses (e.g., -50.00)</small>
|
step="0.01"
|
||||||
</div>
|
min="0"
|
||||||
<div className="button-group">
|
placeholder="0.00"
|
||||||
<button
|
required
|
||||||
type="submit"
|
/>
|
||||||
className={`form-submit-btn ${isLoading ? 'loading' : ''}`}
|
</div>
|
||||||
disabled={isLoading}
|
</div>
|
||||||
>
|
|
||||||
{isLoading ? 'Saving...' : isEditMode ? 'Update Transaction' : 'Save Transaction'}
|
<div className="form-group">
|
||||||
</button>
|
<label htmlFor="txn-category">Category (optional):</label>
|
||||||
{isEditMode && (
|
<input
|
||||||
<button type="button" className="cancel-btn" onClick={handleCancel} disabled={isLoading}>
|
type="text"
|
||||||
|
id="txn-category"
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
placeholder="e.g., Food, Bills, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="cancel-btn"
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
</div>
|
type="submit"
|
||||||
</form>
|
// Allow submitting if we're editing a transaction, even if no account is currently selected
|
||||||
|
disabled={isSubmitting || (!editingTransaction && !accountId)}
|
||||||
|
className="submit-btn"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Processing...' : editingTransaction ? 'Update' : 'Add'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const { account } = Astro.props;
|
|||||||
---
|
---
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
<header class="main-header">
|
<header class="main-header">
|
||||||
<h1>Transactions for <span id="current-account-name">{account.name} (***{account.last4})</span></h1>
|
<h1>Transactions for <span id="current-account-name">{account.name} (***{account.accountNumber.slice(-3)})</span></h1>
|
||||||
</header>
|
</header>
|
||||||
<TransactionTable client:load />
|
<TransactionTable client:load />
|
||||||
</main>
|
</main>
|
||||||
@@ -1,31 +1,41 @@
|
|||||||
---
|
---
|
||||||
import type { Account } from '../types';
|
import type { Account } from "../types";
|
||||||
import AccountSummary from './AccountSummary.tsx'; // Import the React component instead of the Astro one
|
import AccountSummary from "./AccountSummary.tsx"; // Import the React component instead of the Astro one
|
||||||
import AddTransactionForm from './AddTransactionForm.tsx';
|
import AddTransactionForm from "./AddTransactionForm.tsx";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
initialAccount: Account;
|
initialAccount: Account;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accounts, initialAccount } = Astro.props;
|
const { accounts, initialAccount } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h2>My finances</h2>
|
<h2>My finances</h2>
|
||||||
{/* Add button to toggle form visibility */}
|
{/* Add button to toggle form visibility */}
|
||||||
<button id="toggle-add-txn-btn" aria-expanded="false" aria-controls="add-transaction-section">
|
<button
|
||||||
|
id="toggle-add-txn-btn"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="add-transaction-section"
|
||||||
|
>
|
||||||
+ New Txn
|
+ New Txn
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="account-nav">
|
<nav class="account-nav">
|
||||||
<h3>Accounts</h3>
|
<h3>Accounts</h3>
|
||||||
<select id="account-select" name="account">
|
<select id="account-select" name="account">
|
||||||
{accounts.map(account => (
|
{
|
||||||
<option value={account.id} selected={account.id === initialAccount.id}>
|
accounts.map((account) => (
|
||||||
{account.name} (***{account.last4})
|
<option
|
||||||
</option>
|
value={account.id}
|
||||||
))}
|
selected={account.id === initialAccount.id}
|
||||||
|
>
|
||||||
|
{account.name} (***{account.accountNumber.slice(-3)})
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -34,30 +44,37 @@ const { accounts, initialAccount } = Astro.props;
|
|||||||
|
|
||||||
{/* Section to contain the React form, initially hidden */}
|
{/* Section to contain the React form, initially hidden */}
|
||||||
<section id="add-transaction-section" class="collapsible collapsed">
|
<section id="add-transaction-section" class="collapsible collapsed">
|
||||||
{/*
|
{
|
||||||
|
/*
|
||||||
Use the React component here.
|
Use the React component here.
|
||||||
It now gets its state (currentAccountId, transactionToEdit)
|
It now gets its state (currentAccountId, transactionToEdit)
|
||||||
directly from the Nano Store.
|
directly from the Nano Store.
|
||||||
*/}
|
*/
|
||||||
<AddTransactionForm client:load />
|
}
|
||||||
|
<AddTransactionForm client:load />
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Keep the script for toggling visibility for now */}
|
{/* Keep the script for toggling visibility for now */}
|
||||||
<script>
|
<script>
|
||||||
const toggleButton = document.getElementById('toggle-add-txn-btn');
|
const toggleButton = document.getElementById("toggle-add-txn-btn");
|
||||||
const formSection = document.getElementById('add-transaction-section');
|
const formSection = document.getElementById("add-transaction-section");
|
||||||
|
|
||||||
if (toggleButton && formSection) {
|
if (toggleButton && formSection) {
|
||||||
toggleButton.addEventListener('click', () => {
|
toggleButton.addEventListener("click", () => {
|
||||||
const isExpanded = toggleButton.getAttribute('aria-expanded') === 'true';
|
const isExpanded =
|
||||||
toggleButton.setAttribute('aria-expanded', String(!isExpanded));
|
toggleButton.getAttribute("aria-expanded") === "true";
|
||||||
formSection.classList.toggle('collapsed');
|
toggleButton.setAttribute("aria-expanded", String(!isExpanded));
|
||||||
formSection.classList.toggle('expanded');
|
formSection.classList.toggle("collapsed");
|
||||||
|
formSection.classList.toggle("expanded");
|
||||||
// Optional: Focus first field when expanding
|
// Optional: Focus first field when expanding
|
||||||
if (!isExpanded) {
|
if (!isExpanded) {
|
||||||
// Cast the result to HTMLElement before calling focus
|
// Cast the result to HTMLElement before calling focus
|
||||||
(formSection.querySelector('input, select, textarea') as HTMLElement)?.focus();
|
(
|
||||||
|
formSection.querySelector(
|
||||||
|
"input, select, textarea",
|
||||||
|
) as HTMLElement
|
||||||
|
)?.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,82 @@
|
|||||||
import { useStore } from '@nanostores/react';
|
import { useStore } from '@nanostores/react';
|
||||||
import { useStore } from '@nanostores/react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import {
|
import {
|
||||||
currentAccountId as currentAccountIdStore,
|
currentAccountId as currentAccountIdStore,
|
||||||
|
currentTransactions as currentTransactionsStore,
|
||||||
refreshKey,
|
refreshKey,
|
||||||
startEditingTransaction,
|
startEditingTransaction,
|
||||||
triggerRefresh,
|
triggerRefresh,
|
||||||
|
loadTransactionsForAccount,
|
||||||
} from '../stores/transactionStore';
|
} from '../stores/transactionStore';
|
||||||
import type { Transaction } from '../types';
|
import type { Transaction } from '../types';
|
||||||
import { formatCurrency, formatDate } from '../utils';
|
import { formatCurrency, formatDate } from '../utils';
|
||||||
|
|
||||||
type TransactionTableProps = {};
|
export default function TransactionTable() {
|
||||||
|
|
||||||
export default function TransactionTable({}: TransactionTableProps) {
|
|
||||||
const currentAccountId = useStore(currentAccountIdStore);
|
const currentAccountId = useStore(currentAccountIdStore);
|
||||||
const refreshCounter = useStore(refreshKey);
|
const refreshCounter = useStore(refreshKey);
|
||||||
|
const transactions = useStore(currentTransactionsStore);
|
||||||
|
|
||||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// Fetch transactions when account ID changes or refresh is triggered
|
||||||
|
const fetchTransactions = useCallback(async () => {
|
||||||
if (!currentAccountId) {
|
if (!currentAccountId) {
|
||||||
setTransactions([]);
|
console.log('TransactionTable: No account selected, skipping transaction load');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchTransactions = async () => {
|
setIsLoading(true);
|
||||||
setIsLoading(true);
|
setError(null);
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/accounts/${currentAccountId}/transactions`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch transactions');
|
|
||||||
}
|
|
||||||
const data: Transaction[] = await response.json();
|
|
||||||
setTransactions(data);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'An unknown error occurred');
|
|
||||||
setTransactions([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTransactions();
|
try {
|
||||||
|
console.log(`TransactionTable: Loading transactions for account ${currentAccountId}`);
|
||||||
|
await loadTransactionsForAccount(currentAccountId);
|
||||||
|
console.log('TransactionTable: Transactions loaded successfully');
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||||
|
console.error('TransactionTable: Error loading transactions:', errorMessage);
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}, [currentAccountId]);
|
}, [currentAccountId]);
|
||||||
|
|
||||||
const sortedTransactions = [...transactions].sort(
|
// Effect for loading transactions when account changes or refresh is triggered
|
||||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
useEffect(() => {
|
||||||
);
|
fetchTransactions();
|
||||||
|
}, [fetchTransactions, refreshCounter]);
|
||||||
|
|
||||||
|
// Safe sort function that handles invalid dates gracefully
|
||||||
|
const safeSort = (transactions: Transaction[]) => {
|
||||||
|
if (!Array.isArray(transactions)) {
|
||||||
|
console.warn('Expected transactions array but received:', transactions);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...transactions].sort((a, b) => {
|
||||||
|
try {
|
||||||
|
// Safely parse dates with validation
|
||||||
|
const dateA = a.date ? new Date(a.date).getTime() : 0;
|
||||||
|
const dateB = b.date ? new Date(b.date).getTime() : 0;
|
||||||
|
|
||||||
|
// If either date is invalid, use a fallback approach
|
||||||
|
if (Number.isNaN(dateA) || Number.isNaN(dateB)) {
|
||||||
|
console.warn('Found invalid date during sort:', { a: a.date, b: b.date });
|
||||||
|
// Sort by ID as fallback or keep original order
|
||||||
|
return (b.id || '').localeCompare(a.id || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateB - dateA; // Newest first
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during transaction sort:', error);
|
||||||
|
return 0; // Keep original order on error
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format transactions to display in table - with better error handling
|
||||||
|
const sortedTransactions = Array.isArray(transactions) ? safeSort(transactions) : [];
|
||||||
|
|
||||||
const handleDelete = async (txnId: string) => {
|
const handleDelete = async (txnId: string) => {
|
||||||
if (!confirm('Are you sure you want to delete this transaction?')) {
|
if (!confirm('Are you sure you want to delete this transaction?')) {
|
||||||
@@ -75,16 +102,10 @@ export default function TransactionTable({}: TransactionTableProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Transaction ${txnId} deleted successfully.`);
|
console.log(`Transaction ${txnId} deleted successfully.`);
|
||||||
|
triggerRefresh(); // This will reload transactions
|
||||||
setTransactions((currentTransactions) =>
|
|
||||||
currentTransactions.filter((txn) => txn.id !== txnId),
|
|
||||||
);
|
|
||||||
|
|
||||||
triggerRefresh();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error instanceof Error ? error.message : 'Failed to delete transaction');
|
alert(error instanceof Error ? error.message : 'Failed to delete transaction');
|
||||||
console.error('Delete error:', error);
|
console.error('Delete error:', error);
|
||||||
} finally {
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,7 +128,7 @@ export default function TransactionTable({}: TransactionTableProps) {
|
|||||||
// Helper function to render loading state
|
// Helper function to render loading state
|
||||||
const renderLoading = () => (
|
const renderLoading = () => (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={4} style={{ textAlign: 'center', padding: '2rem' }}>
|
<td colSpan={5} style={{ textAlign: 'center', padding: '2rem' }}>
|
||||||
Loading transactions...
|
Loading transactions...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -117,11 +138,12 @@ export default function TransactionTable({}: TransactionTableProps) {
|
|||||||
const renderEmpty = () => (
|
const renderEmpty = () => (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={4}
|
colSpan={5}
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
color: '#777',
|
color: '#777',
|
||||||
|
padding: '2rem 1rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
No transactions found for this account.
|
No transactions found for this account.
|
||||||
@@ -129,15 +151,16 @@ export default function TransactionTable({}: TransactionTableProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Helper function to render transaction rows
|
// Helper function to render transaction rows with better error handling
|
||||||
const renderRows = () =>
|
const renderRows = () =>
|
||||||
sortedTransactions.map((txn) => (
|
sortedTransactions.map((txn) => (
|
||||||
<tr key={txn.id} data-txn-id={txn.id}>
|
<tr key={txn.id} data-txn-id={txn.id}>
|
||||||
<td>{formatDate(txn.date)}</td>
|
<td>{formatDate(txn.date)}</td>
|
||||||
<td>{txn.description}</td>
|
<td>{txn.description || 'No description'}</td>
|
||||||
<td className={`amount-col ${txn.amount >= 0 ? 'amount-positive' : 'amount-negative'}`}>
|
<td className={`amount-col ${txn.amount >= 0 ? 'amount-positive' : 'amount-negative'}`}>
|
||||||
{formatCurrency(txn.amount)}
|
{formatCurrency(txn.amount)}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{txn.category || 'Uncategorized'}</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -162,7 +185,7 @@ export default function TransactionTable({}: TransactionTableProps) {
|
|||||||
return (
|
return (
|
||||||
<div id="transaction-section" className={isLoading ? 'loading' : ''}>
|
<div id="transaction-section" className={isLoading ? 'loading' : ''}>
|
||||||
{error && (
|
{error && (
|
||||||
<div className="error-message" style={{ padding: '1rem' }}>
|
<div className="error-message" style={{ padding: '1rem', marginBottom: '1rem' }}>
|
||||||
Error loading transactions: {error}
|
Error loading transactions: {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -172,17 +195,16 @@ export default function TransactionTable({}: TransactionTableProps) {
|
|||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th className="amount-col">Amount</th>
|
<th className="amount-col">Amount</th>
|
||||||
|
<th>Category</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="transaction-table-body">
|
<tbody id="transaction-table-body">
|
||||||
{isLoading
|
{isLoading
|
||||||
? renderLoading()
|
? renderLoading()
|
||||||
: error
|
: sortedTransactions.length === 0
|
||||||
? null // Error message is shown above the table
|
? renderEmpty()
|
||||||
: sortedTransactions.length === 0
|
: renderRows()}
|
||||||
? renderEmpty()
|
|
||||||
: renderRows()}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
275
src/data/db.service.ts
Normal file
275
src/data/db.service.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import type { PrismaClient } from '@prisma/client';
|
||||||
|
import { Decimal } from '@prisma/client/runtime/library';
|
||||||
|
import type { Account, Transaction } from '../types';
|
||||||
|
import { prisma } from './prisma';
|
||||||
|
|
||||||
|
// Define the enums ourselves since Prisma isn't exporting them
|
||||||
|
export enum AccountType {
|
||||||
|
CHECKING = 'CHECKING',
|
||||||
|
SAVINGS = 'SAVINGS',
|
||||||
|
CREDIT_CARD = 'CREDIT_CARD',
|
||||||
|
INVESTMENT = 'INVESTMENT',
|
||||||
|
OTHER = 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AccountStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
CLOSED = 'CLOSED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TransactionStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
CLEARED = 'CLEARED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TransactionType {
|
||||||
|
DEPOSIT = 'DEPOSIT',
|
||||||
|
WITHDRAWAL = 'WITHDRAWAL',
|
||||||
|
TRANSFER = 'TRANSFER',
|
||||||
|
UNSPECIFIED = 'UNSPECIFIED',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account services
|
||||||
|
export const accountService = {
|
||||||
|
/**
|
||||||
|
* Get all accounts
|
||||||
|
*/
|
||||||
|
async getAll(): Promise<Account[]> {
|
||||||
|
return prisma.account.findMany({
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account by ID
|
||||||
|
*/
|
||||||
|
async getById(id: string): Promise<Account | null> {
|
||||||
|
return prisma.account.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new account
|
||||||
|
*/
|
||||||
|
async create(data: {
|
||||||
|
bankName: string;
|
||||||
|
accountNumber: string;
|
||||||
|
name: string;
|
||||||
|
type?: AccountType;
|
||||||
|
status?: AccountStatus;
|
||||||
|
currency?: string;
|
||||||
|
balance?: number;
|
||||||
|
notes?: string;
|
||||||
|
}): Promise<Account> {
|
||||||
|
return prisma.account.create({
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an account
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
bankName?: string;
|
||||||
|
accountNumber?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: AccountType;
|
||||||
|
status?: AccountStatus;
|
||||||
|
currency?: string;
|
||||||
|
balance?: number;
|
||||||
|
notes?: string;
|
||||||
|
},
|
||||||
|
): Promise<Account | null> {
|
||||||
|
return prisma.account.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an account
|
||||||
|
*/
|
||||||
|
async delete(id: string): Promise<Account | null> {
|
||||||
|
return prisma.account.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update account balance
|
||||||
|
*/
|
||||||
|
async updateBalance(id: string, amount: number): Promise<Account | null> {
|
||||||
|
const account = await prisma.account.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
|
return prisma.account.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
balance: {
|
||||||
|
increment: amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transaction services
|
||||||
|
export const transactionService = {
|
||||||
|
/**
|
||||||
|
* Get all transactions
|
||||||
|
*/
|
||||||
|
async getAll(): Promise<Transaction[]> {
|
||||||
|
return prisma.transaction.findMany({
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get transactions by account ID
|
||||||
|
*/
|
||||||
|
async getByAccountId(accountId: string): Promise<Transaction[]> {
|
||||||
|
return prisma.transaction.findMany({
|
||||||
|
where: { accountId },
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get transaction by ID
|
||||||
|
*/
|
||||||
|
async getById(id: string): Promise<Transaction | null> {
|
||||||
|
return prisma.transaction.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new transaction and update account balance
|
||||||
|
*/
|
||||||
|
async create(data: {
|
||||||
|
accountId: string;
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
category?: string;
|
||||||
|
status?: TransactionStatus;
|
||||||
|
type?: TransactionType;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string;
|
||||||
|
}): Promise<Transaction> {
|
||||||
|
// Use a transaction to ensure data consistency
|
||||||
|
return prisma.$transaction(async (prismaClient: PrismaClient) => {
|
||||||
|
// Create the transaction
|
||||||
|
const transaction = await prismaClient.transaction.create({
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the account balance
|
||||||
|
await prismaClient.account.update({
|
||||||
|
where: { id: data.accountId },
|
||||||
|
data: {
|
||||||
|
balance: {
|
||||||
|
increment: data.amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return transaction;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a transaction and adjust account balance
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
data: {
|
||||||
|
accountId?: string;
|
||||||
|
date?: Date;
|
||||||
|
description?: string;
|
||||||
|
amount?: number;
|
||||||
|
category?: string;
|
||||||
|
status?: TransactionStatus;
|
||||||
|
type?: TransactionType;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string;
|
||||||
|
},
|
||||||
|
): Promise<Transaction | null> {
|
||||||
|
// If amount is changing, we need to adjust the account balance
|
||||||
|
if (typeof data.amount !== 'undefined') {
|
||||||
|
return prisma.$transaction(async (prismaClient: PrismaClient) => {
|
||||||
|
// Get the current transaction to calculate difference
|
||||||
|
const currentTxn = await prismaClient.transaction.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentTxn) return null;
|
||||||
|
|
||||||
|
// Calculate amount difference - amount is guaranteed to be defined at this point
|
||||||
|
const amount = data.amount; // Store in a constant to help TypeScript understand
|
||||||
|
const amountDifference = amount - Number(currentTxn.amount);
|
||||||
|
|
||||||
|
// Update transaction
|
||||||
|
const updatedTxn = await prismaClient.transaction.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update account balance
|
||||||
|
await prismaClient.account.update({
|
||||||
|
where: { id: data.accountId || currentTxn.accountId },
|
||||||
|
data: {
|
||||||
|
balance: {
|
||||||
|
increment: amountDifference,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedTxn;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If amount isn't changing, just update the transaction
|
||||||
|
return prisma.transaction.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a transaction and adjust account balance
|
||||||
|
*/
|
||||||
|
async delete(id: string): Promise<Transaction | null> {
|
||||||
|
return prisma.$transaction(async (prismaClient: PrismaClient) => {
|
||||||
|
// Get transaction before deleting
|
||||||
|
const transaction = await prismaClient.transaction.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!transaction) return null;
|
||||||
|
|
||||||
|
// Delete the transaction
|
||||||
|
const deletedTxn = await prismaClient.transaction.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adjust the account balance (reverse the transaction amount)
|
||||||
|
await prismaClient.account.update({
|
||||||
|
where: { id: transaction.accountId },
|
||||||
|
data: {
|
||||||
|
balance: {
|
||||||
|
decrement: Number(transaction.amount),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return deletedTxn;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
13
src/data/prisma.ts
Normal file
13
src/data/prisma.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
// Prevent multiple instances of Prisma Client in development
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var prismaClient: PrismaClient | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma = global.prismaClient || new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
global.prismaClient = prisma;
|
||||||
|
}
|
||||||
@@ -1,22 +1,32 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { accounts } from '../../../../data/store';
|
import { accountService } from '../../../../data/db.service';
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params }) => {
|
export const GET: APIRoute = async ({ params }) => {
|
||||||
const account = accounts.find((a) => a.id === params.id);
|
try {
|
||||||
|
const account = await accountService.getById(params.id as string);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return new Response(JSON.stringify({ error: 'Account not found' }), {
|
return new Response(JSON.stringify({ error: 'Account not found' }), {
|
||||||
status: 404,
|
status: 404,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(account), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching account details:', error);
|
||||||
|
return new Response(JSON.stringify({ error: 'Failed to fetch account details' }), {
|
||||||
|
status: 500,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify(account), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { transactions } from '../../../../../data/store';
|
import { transactionService } from '../../../../../data/db.service';
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params }) => {
|
export const GET: APIRoute = async ({ params }) => {
|
||||||
const accountTransactions = transactions.filter((t) => t.accountId === params.id);
|
try {
|
||||||
|
const accountTransactions = await transactionService.getByAccountId(params.id as string);
|
||||||
|
|
||||||
return new Response(JSON.stringify(accountTransactions), {
|
return new Response(JSON.stringify(accountTransactions), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching account transactions:', error);
|
||||||
|
return new Response(JSON.stringify({ error: 'Failed to fetch account transactions' }), {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { accounts } from '../../../data/store';
|
import { accountService } from '../../../data/db.service';
|
||||||
|
|
||||||
export const GET: APIRoute = async () => {
|
export const GET: APIRoute = async () => {
|
||||||
return new Response(JSON.stringify(accounts), {
|
try {
|
||||||
status: 200,
|
const accounts = await accountService.getAll();
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
return new Response(JSON.stringify(accounts), {
|
||||||
},
|
status: 200,
|
||||||
});
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching accounts:', error);
|
||||||
|
return new Response(JSON.stringify({ error: 'Failed to fetch accounts' }), {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,31 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { accounts, transactions } from '../../../../data/store';
|
import { transactionService } from '../../../../data/db.service';
|
||||||
import type { Transaction } from '../../../../types';
|
import type { Transaction } from '../../../../types';
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const transaction = await transactionService.getById(params.id as string);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(transaction), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching transaction:', error);
|
||||||
|
return new Response(JSON.stringify({ error: 'Failed to fetch transaction' }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const PUT: APIRoute = async ({ request, params }) => {
|
export const PUT: APIRoute = async ({ request, params }) => {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
|
||||||
@@ -14,68 +38,42 @@ export const PUT: APIRoute = async ({ request, params }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const updates = (await request.json()) as Partial<Transaction>;
|
const updates = (await request.json()) as Partial<Transaction>;
|
||||||
const transactionIndex = transactions.findIndex((t) => t.id === id);
|
|
||||||
|
|
||||||
if (transactionIndex === -1) {
|
// Check if transaction exists
|
||||||
|
const existingTransaction = await transactionService.getById(id);
|
||||||
|
|
||||||
|
if (!existingTransaction) {
|
||||||
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
|
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldTransaction = transactions[transactionIndex];
|
// Convert date to Date object if it's a string
|
||||||
|
const updatedData: any = { ...updates };
|
||||||
|
if (typeof updates.date === 'string') {
|
||||||
|
updatedData.date = new Date(updates.date);
|
||||||
|
}
|
||||||
|
|
||||||
// Get the old account first
|
// Update the transaction using the service
|
||||||
const oldAccount = accounts.find((a) => a.id === oldTransaction.accountId);
|
// The service will automatically handle account balance adjustments
|
||||||
if (!oldAccount) {
|
const updatedTransaction = await transactionService.update(id, updatedData);
|
||||||
return new Response(JSON.stringify({ error: 'Account not found' }), {
|
|
||||||
status: 404,
|
if (!updatedTransaction) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Failed to update transaction' }), {
|
||||||
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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), {
|
return new Response(JSON.stringify(updatedTransaction), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
console.error('Error updating transaction:', error);
|
||||||
status: 400,
|
return new Response(JSON.stringify({ error: 'Failed to update transaction' }), {
|
||||||
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -91,30 +89,24 @@ export const DELETE: APIRoute = async ({ params }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const transactionIndex = transactions.findIndex((t) => t.id === id);
|
try {
|
||||||
|
// Delete the transaction using the service
|
||||||
|
// The service will automatically handle account balance adjustments
|
||||||
|
const deletedTransaction = await transactionService.delete(id);
|
||||||
|
|
||||||
if (transactionIndex === -1) {
|
if (!deletedTransaction) {
|
||||||
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
|
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
|
||||||
status: 404,
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting transaction:', error);
|
||||||
|
return new Response(JSON.stringify({ error: 'Failed to delete transaction' }), {
|
||||||
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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 });
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { accounts, transactions } from '../../../data/store';
|
import { accountService, transactionService } from '../../../data/db.service';
|
||||||
import type { Transaction } from '../../../types';
|
import type { Transaction } from '../../../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +43,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate account exists
|
// Validate account exists
|
||||||
const account = accounts.find((a) => a.id === transaction.accountId);
|
const account = await accountService.getById(transaction.accountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return new Response(JSON.stringify({ error: 'Account not found' }), {
|
return new Response(JSON.stringify({ error: 'Account not found' }), {
|
||||||
status: 404,
|
status: 404,
|
||||||
@@ -51,25 +51,32 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new transaction with generated ID
|
// Convert string date to Date object if needed
|
||||||
const newTransaction: Transaction = {
|
const transactionDate =
|
||||||
...transaction,
|
typeof transaction.date === 'string' ? new Date(transaction.date) : transaction.date;
|
||||||
id: (transactions.length + 1).toString(), // Simple ID generation for demo
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update account balance
|
// Create new transaction with database service
|
||||||
account.balance += transaction.amount;
|
// The database service will also update the account balance
|
||||||
|
const newTransaction = await transactionService.create({
|
||||||
// Add to transactions array
|
accountId: transaction.accountId,
|
||||||
transactions.push(newTransaction);
|
date: transactionDate,
|
||||||
|
description: transaction.description,
|
||||||
|
amount: transaction.amount,
|
||||||
|
category: transaction.category,
|
||||||
|
status: transaction.status as any,
|
||||||
|
type: transaction.type as any,
|
||||||
|
notes: transaction.notes,
|
||||||
|
tags: transaction.tags,
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(JSON.stringify(newTransaction), {
|
return new Response(JSON.stringify(newTransaction), {
|
||||||
status: 201,
|
status: 201,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
console.error('Error creating transaction:', error);
|
||||||
status: 400,
|
return new Response(JSON.stringify({ error: 'Failed to create transaction' }), {
|
||||||
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
---
|
---
|
||||||
import MainContent from '../components/MainContent.astro';
|
import MainContent from "../components/MainContent.astro";
|
||||||
import Sidebar from '../components/Sidebar.astro';
|
import Sidebar from "../components/Sidebar.astro";
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||||
import type { Account, Transaction } from '../types';
|
import type { Account, Transaction } from "../types";
|
||||||
|
|
||||||
export interface Props {
|
|
||||||
account: Account;
|
|
||||||
transactions: Transaction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the base URL from the incoming request
|
// Get the base URL from the incoming request
|
||||||
const baseUrl = new URL(Astro.request.url).origin;
|
const baseUrl = new URL(Astro.request.url).origin;
|
||||||
@@ -18,43 +13,23 @@ const accounts: Account[] = await accountsResponse.json();
|
|||||||
|
|
||||||
// Initialize with first account or empty account if none exist
|
// Initialize with first account or empty account if none exist
|
||||||
const initialAccount: Account = accounts[0] || {
|
const initialAccount: Account = accounts[0] || {
|
||||||
id: '',
|
id: "",
|
||||||
name: 'No accounts available',
|
name: "No accounts available",
|
||||||
last4: '0000',
|
accountNumber: "000000",
|
||||||
balance: 0,
|
balance: 0,
|
||||||
|
bankName: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch initial transactions if we have an account, using absolute URL
|
// Fetch initial transactions if we have an account, using absolute URL
|
||||||
let initialTransactions: Transaction[] = [];
|
let initialTransactions: Transaction[] = [];
|
||||||
if (initialAccount.id) {
|
if (initialAccount.id) {
|
||||||
const transactionsResponse = await fetch(
|
const transactionsResponse = await fetch(
|
||||||
`${baseUrl}/api/accounts/${initialAccount.id}/transactions`,
|
`${baseUrl}/api/accounts/${initialAccount.id}/transactions`,
|
||||||
);
|
);
|
||||||
initialTransactions = await transactionsResponse.json();
|
initialTransactions = await transactionsResponse.json();
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<!--
|
|
||||||
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} />
|
||||||
@@ -63,103 +38,228 @@ TODO: Performance & Monitoring
|
|||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Import types for client-side script
|
// Import store actions - done directly to avoid TypeScript import issues
|
||||||
type Transaction = import('../types').Transaction;
|
import {
|
||||||
type Account = import('../types').Account;
|
currentAccountId,
|
||||||
|
setTransactions,
|
||||||
|
loadTransactionsForAccount,
|
||||||
|
startEditingTransaction,
|
||||||
|
} from "../stores/transactionStore";
|
||||||
|
|
||||||
// Import store atoms and actions
|
// Access server-rendered data which is available as globals
|
||||||
import { currentAccountId, startEditingTransaction } from '../stores/transactionStore';
|
const initialAccountData = JSON.parse(
|
||||||
|
document
|
||||||
|
.getElementById("initial-account-data")
|
||||||
|
?.getAttribute("data-account") || "{}",
|
||||||
|
);
|
||||||
|
const initialTransactionsData = JSON.parse(
|
||||||
|
document
|
||||||
|
.getElementById("initial-transactions-data")
|
||||||
|
?.getAttribute("data-transactions") || "[]",
|
||||||
|
);
|
||||||
|
|
||||||
// --- DOM Elements ---
|
// --- DOM Elements ---
|
||||||
const accountSelect = document.getElementById('account-select') as HTMLSelectElement;
|
const accountSelect = document.getElementById("account-select");
|
||||||
const currentAccountNameSpan = document.getElementById('current-account-name');
|
const currentAccountNameSpan = document.getElementById(
|
||||||
const addTransactionSection = document.getElementById('add-transaction-section');
|
"current-account-name",
|
||||||
const toggleAddTxnBtn = document.getElementById('toggle-add-txn-btn');
|
);
|
||||||
|
const addTransactionSection = document.getElementById(
|
||||||
|
"add-transaction-section",
|
||||||
|
);
|
||||||
|
const toggleAddTxnBtn = document.getElementById("toggle-add-txn-btn");
|
||||||
|
|
||||||
|
console.log("Initial setup - Account:", initialAccountData);
|
||||||
|
console.log("Initial setup - Transactions:", initialTransactionsData);
|
||||||
|
|
||||||
// --- Helper Functions ---
|
// --- Helper Functions ---
|
||||||
async function fetchAccountDetails(accountId: string): Promise<Account | null> {
|
async function fetchAccountDetails(accountId) {
|
||||||
|
console.log("Fetching details for account:", accountId);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/accounts/${accountId}`);
|
const response = await fetch(`/api/accounts/${accountId}`);
|
||||||
if (!response.ok) throw new Error('Failed to fetch account details');
|
if (!response.ok)
|
||||||
|
throw new Error("Failed to fetch account details");
|
||||||
return await response.json();
|
return await response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching account:', error);
|
console.error("Error fetching account:", error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Update UI Function (Further Simplified) ---
|
// --- Update UI Function ---
|
||||||
async function updateUIForAccount(accountId: string): Promise<void> {
|
async function updateUIForAccount(accountId) {
|
||||||
console.log("Updating Account Header for account:", accountId);
|
console.log("Updating UI for account:", accountId);
|
||||||
|
|
||||||
// Update the store with the current account ID
|
// Update the store with the current account ID
|
||||||
currentAccountId.set(accountId);
|
currentAccountId.set(accountId);
|
||||||
|
|
||||||
// Only update the non-React part (header span)
|
// Only update the non-React part (header span)
|
||||||
currentAccountNameSpan?.classList.add('loading-inline');
|
currentAccountNameSpan?.classList.add("loading-inline");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const account = await fetchAccountDetails(accountId);
|
const account = await fetchAccountDetails(accountId);
|
||||||
|
|
||||||
if (!account || !currentAccountNameSpan) {
|
if (!account || !currentAccountNameSpan) {
|
||||||
console.error("Account data or header element not found!");
|
console.error("Account data or header element not found!");
|
||||||
if (currentAccountNameSpan) currentAccountNameSpan.textContent = 'Error';
|
if (currentAccountNameSpan)
|
||||||
|
currentAccountNameSpan.textContent = "Error";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update header
|
// Update header - use accountNumber instead of last4
|
||||||
currentAccountNameSpan.textContent = `${account.name} (***${account.last4})`;
|
currentAccountNameSpan.textContent = `${account.name} (***${account.accountNumber.slice(-3)})`;
|
||||||
|
|
||||||
|
// Load transactions for this account
|
||||||
|
console.log("Loading transactions for account:", accountId);
|
||||||
|
await loadTransactionsForAccount(accountId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating account header:', error);
|
console.error("Error updating account header:", error);
|
||||||
if (currentAccountNameSpan) currentAccountNameSpan.textContent = 'Error';
|
if (currentAccountNameSpan)
|
||||||
|
currentAccountNameSpan.textContent = "Error";
|
||||||
} finally {
|
} finally {
|
||||||
currentAccountNameSpan?.classList.remove('loading-inline');
|
currentAccountNameSpan?.classList.remove("loading-inline");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Transaction Actions ---
|
// --- Transaction Actions ---
|
||||||
async function handleEditTransaction(txnId: string): Promise<void> {
|
async function handleEditTransaction(txnId) {
|
||||||
|
console.log("Edit transaction requested:", txnId);
|
||||||
try {
|
try {
|
||||||
const accountId = currentAccountId.get();
|
const accountId = currentAccountId.get();
|
||||||
if (!accountId) return;
|
if (!accountId) {
|
||||||
|
console.error("No account selected for editing transaction");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/accounts/${accountId}/transactions`);
|
const response = await fetch(
|
||||||
if (!response.ok) throw new Error('Failed to fetch transactions for edit');
|
`/api/accounts/${accountId}/transactions`,
|
||||||
const transactions: Transaction[] = await response.json();
|
);
|
||||||
const transaction = transactions.find(t => t.id === txnId);
|
if (!response.ok)
|
||||||
|
throw new Error("Failed to fetch transactions for edit");
|
||||||
|
const transactions = await response.json();
|
||||||
|
const transaction = transactions.find((t) => t.id === txnId);
|
||||||
|
|
||||||
if (!transaction) {
|
if (!transaction) {
|
||||||
throw new Error('Transaction not found for editing');
|
throw new Error("Transaction not found for editing");
|
||||||
}
|
}
|
||||||
|
|
||||||
startEditingTransaction(transaction);
|
startEditingTransaction(transaction);
|
||||||
|
|
||||||
// Manually expand the form section if it's collapsed
|
// Manually expand the form section if it's collapsed
|
||||||
if (addTransactionSection?.classList.contains('collapsed')) {
|
if (addTransactionSection?.classList.contains("collapsed")) {
|
||||||
addTransactionSection.classList.replace('collapsed', 'expanded');
|
addTransactionSection.classList.replace(
|
||||||
toggleAddTxnBtn?.setAttribute('aria-expanded', 'true');
|
"collapsed",
|
||||||
addTransactionSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
"expanded",
|
||||||
|
);
|
||||||
|
toggleAddTxnBtn?.setAttribute("aria-expanded", "true");
|
||||||
|
addTransactionSection.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "nearest",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error instanceof Error ? error.message : 'Failed to load transaction for editing');
|
alert(
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to load transaction for editing",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Event Listeners ---
|
// --- Event Listeners ---
|
||||||
if (accountSelect) {
|
if (accountSelect) {
|
||||||
accountSelect.addEventListener('change', (event: Event) => {
|
accountSelect.addEventListener("change", (event) => {
|
||||||
const target = event.target as HTMLSelectElement;
|
const target = event.target;
|
||||||
updateUIForAccount(target.value);
|
if (target && target.value) {
|
||||||
|
updateUIForAccount(target.value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target && target.classList.contains("edit-btn")) {
|
||||||
|
const row = target.closest("[data-txn-id]");
|
||||||
|
if (row) {
|
||||||
|
const txnId = row.dataset.txnId;
|
||||||
|
if (txnId) handleEditTransaction(txnId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- Initial Load ---
|
// --- Initial Load ---
|
||||||
const initialAccountIdValue = accountSelect?.value;
|
// Add the initial data to the page for client-side scripts to access
|
||||||
if (initialAccountIdValue) {
|
if (!document.getElementById("initial-account-data")) {
|
||||||
updateUIForAccount(initialAccountIdValue);
|
const accountDataEl = document.createElement("script");
|
||||||
} else {
|
accountDataEl.id = "initial-account-data";
|
||||||
currentAccountId.set(null);
|
accountDataEl.type = "application/json";
|
||||||
|
accountDataEl.setAttribute(
|
||||||
|
"data-account",
|
||||||
|
JSON.stringify(initialAccountData || {}),
|
||||||
|
);
|
||||||
|
document.body.appendChild(accountDataEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!document.getElementById("initial-transactions-data")) {
|
||||||
|
const txnDataEl = document.createElement("script");
|
||||||
|
txnDataEl.id = "initial-transactions-data";
|
||||||
|
txnDataEl.type = "application/json";
|
||||||
|
txnDataEl.setAttribute(
|
||||||
|
"data-transactions",
|
||||||
|
JSON.stringify(initialTransactionsData || []),
|
||||||
|
);
|
||||||
|
document.body.appendChild(txnDataEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize state on page load with server data
|
||||||
|
window.addEventListener("DOMContentLoaded", () => {
|
||||||
|
// Initialize with first account
|
||||||
|
if (initialAccountData?.id) {
|
||||||
|
console.log("Setting initial account ID:", initialAccountData.id);
|
||||||
|
|
||||||
|
// Update current account in store
|
||||||
|
currentAccountId.set(initialAccountData.id);
|
||||||
|
|
||||||
|
// Set initial transactions if we have them
|
||||||
|
if (initialTransactionsData && initialTransactionsData.length > 0) {
|
||||||
|
console.log(
|
||||||
|
"Setting initial transactions:",
|
||||||
|
initialTransactionsData.length,
|
||||||
|
);
|
||||||
|
setTransactions(initialTransactionsData);
|
||||||
|
} else {
|
||||||
|
console.log("No initial transactions, fetching from API");
|
||||||
|
loadTransactionsForAccount(initialAccountData.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("No initial account data available");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set initial account as soon as possible
|
||||||
|
if (initialAccountData?.id) {
|
||||||
|
console.log("Setting account ID immediately:", initialAccountData.id);
|
||||||
|
currentAccountId.set(initialAccountData.id);
|
||||||
|
|
||||||
|
// Also set initial transactions
|
||||||
|
if (initialTransactionsData && initialTransactionsData.length > 0) {
|
||||||
|
console.log(
|
||||||
|
"Setting transactions immediately:",
|
||||||
|
initialTransactionsData.length,
|
||||||
|
);
|
||||||
|
setTransactions(initialTransactionsData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script
|
||||||
|
id="initial-account-data"
|
||||||
|
type="application/json"
|
||||||
|
set:html={JSON.stringify(initialAccount)}
|
||||||
|
data-account={JSON.stringify(initialAccount)}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
id="initial-transactions-data"
|
||||||
|
type="application/json"
|
||||||
|
set:html={JSON.stringify(initialTransactions)}
|
||||||
|
data-transactions={JSON.stringify(initialTransactions)}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -4,43 +4,179 @@ import type { Transaction } from '../types';
|
|||||||
// Atom to hold the ID of the currently selected account
|
// Atom to hold the ID of the currently selected account
|
||||||
export const currentAccountId = atom<string | null>(null);
|
export const currentAccountId = atom<string | null>(null);
|
||||||
|
|
||||||
|
// Atom to hold the current transactions
|
||||||
|
export const currentTransactions = atom<Transaction[]>([]);
|
||||||
|
|
||||||
// Atom to hold the transaction object when editing, or null otherwise
|
// Atom to hold the transaction object when editing, or null otherwise
|
||||||
export const transactionToEdit = atom<Transaction | null>(null);
|
export const transactionToEdit = atom<Transaction | null>(null);
|
||||||
|
|
||||||
// Atom to trigger refreshes in components that depend on external changes
|
// Atom to trigger refreshes in components that depend on external changes
|
||||||
export const refreshKey = atom<number>(0);
|
export const refreshKey = atom<number>(0);
|
||||||
|
|
||||||
|
// Action to set the current transactions
|
||||||
|
export function setTransactions(transactions: Transaction[]) {
|
||||||
|
console.log('Setting transactions in store:', transactions.length, transactions);
|
||||||
|
currentTransactions.set(transactions);
|
||||||
|
}
|
||||||
|
|
||||||
// Action to increment the refresh key, forcing dependent effects to re-run
|
// Action to increment the refresh key, forcing dependent effects to re-run
|
||||||
export function triggerRefresh() {
|
export function triggerRefresh() {
|
||||||
|
console.log('Triggering transaction refresh');
|
||||||
refreshKey.set(refreshKey.get() + 1);
|
refreshKey.set(refreshKey.get() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action to set the transaction to be edited
|
// Action to set the transaction to be edited
|
||||||
export function startEditingTransaction(transaction: Transaction) {
|
export function startEditingTransaction(transaction: Transaction) {
|
||||||
transactionToEdit.set(transaction);
|
console.log('Setting transaction to edit:', transaction);
|
||||||
// Optionally, trigger UI changes like expanding the form here
|
|
||||||
// document.getElementById('add-transaction-section')?.classList.replace('collapsed', 'expanded');
|
// Create a clean copy of the transaction to avoid reference issues
|
||||||
// document.getElementById('toggle-add-txn-btn')?.setAttribute('aria-expanded', 'true');
|
const transactionCopy = { ...transaction };
|
||||||
|
|
||||||
|
// Force update to ensure subscribers get notified
|
||||||
|
transactionToEdit.set(null);
|
||||||
|
|
||||||
|
// Set after a small delay to ensure state change is detected
|
||||||
|
setTimeout(() => {
|
||||||
|
transactionToEdit.set(transactionCopy);
|
||||||
|
console.log('Transaction edit state updated:', transactionToEdit.get());
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action to clear the edit state
|
// Action to clear the edit state
|
||||||
export function cancelEditingTransaction() {
|
export function cancelEditingTransaction() {
|
||||||
|
console.log('Canceling transaction edit');
|
||||||
transactionToEdit.set(null);
|
transactionToEdit.set(null);
|
||||||
// Optionally, collapse the form
|
|
||||||
// document.getElementById('add-transaction-section')?.classList.replace('expanded', 'collapsed');
|
|
||||||
// document.getElementById('toggle-add-txn-btn')?.setAttribute('aria-expanded', 'false');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action triggered after a transaction is saved (created or updated)
|
// Action triggered after a transaction is saved (created or updated)
|
||||||
export function transactionSaved(transaction: Transaction) {
|
export function transactionSaved(transaction: Transaction) {
|
||||||
|
console.log('Transaction saved:', transaction);
|
||||||
|
|
||||||
// Clear edit state if the saved transaction was the one being edited
|
// Clear edit state if the saved transaction was the one being edited
|
||||||
if (transactionToEdit.get()?.id === transaction.id) {
|
if (transactionToEdit.get()?.id === transaction.id) {
|
||||||
transactionToEdit.set(null);
|
transactionToEdit.set(null);
|
||||||
}
|
}
|
||||||
// Potentially trigger UI updates or refreshes here
|
|
||||||
// This might involve dispatching a custom event or calling a refresh function
|
|
||||||
document.dispatchEvent(new CustomEvent('transactionSaved', { detail: { transaction } }));
|
|
||||||
|
|
||||||
// Trigger a general refresh after saving too, to update balance
|
// Add/update the transaction in the current list
|
||||||
|
const currentList = currentTransactions.get();
|
||||||
|
const existingIndex = currentList.findIndex((t) => t.id === transaction.id);
|
||||||
|
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
// Update existing transaction
|
||||||
|
const updatedList = [...currentList];
|
||||||
|
updatedList[existingIndex] = transaction;
|
||||||
|
currentTransactions.set(updatedList);
|
||||||
|
} else {
|
||||||
|
// Add new transaction
|
||||||
|
currentTransactions.set([transaction, ...currentList]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger a general refresh after saving
|
||||||
triggerRefresh();
|
triggerRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to load transactions for an account
|
||||||
|
export async function loadTransactionsForAccount(accountId: string) {
|
||||||
|
console.log('loadTransactionsForAccount called with ID:', accountId);
|
||||||
|
try {
|
||||||
|
if (!accountId) {
|
||||||
|
console.warn('No account ID provided, clearing transactions');
|
||||||
|
currentTransactions.set([]);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Fetching transactions from API for account: ${accountId}`);
|
||||||
|
const response = await fetch(`/api/accounts/${accountId}/transactions`);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('API error:', response.status, response.statusText);
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error('Error response:', errorText);
|
||||||
|
throw new Error(`Failed to fetch transactions: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions: Transaction[] = await response.json();
|
||||||
|
console.log(
|
||||||
|
`Loaded ${transactions.length} transactions for account ${accountId}:`,
|
||||||
|
transactions,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set transactions in the store
|
||||||
|
currentTransactions.set(transactions);
|
||||||
|
return transactions;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading transactions:', error);
|
||||||
|
// Don't clear transactions on error, to avoid flickering UI
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a new transaction
|
||||||
|
export async function createTransaction(transaction: Omit<Transaction, 'id'>) {
|
||||||
|
try {
|
||||||
|
console.log('Creating new transaction:', transaction);
|
||||||
|
const response = await fetch('/api/transactions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(transaction),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||||
|
throw new Error(errorData.error || `Failed to create transaction: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTransaction = await response.json();
|
||||||
|
console.log('Transaction created successfully:', newTransaction);
|
||||||
|
|
||||||
|
// Add the new transaction to the existing list
|
||||||
|
const currentList = currentTransactions.get();
|
||||||
|
currentTransactions.set([newTransaction, ...currentList]);
|
||||||
|
|
||||||
|
// Trigger refresh to update other components
|
||||||
|
triggerRefresh();
|
||||||
|
|
||||||
|
return newTransaction;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating transaction:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to update an existing transaction
|
||||||
|
export async function updateTransaction(id: string, transaction: Partial<Transaction>) {
|
||||||
|
try {
|
||||||
|
console.log(`Updating transaction ${id}:`, transaction);
|
||||||
|
const response = await fetch(`/api/transactions/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(transaction),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||||
|
throw new Error(errorData.error || `Failed to update transaction: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTransaction = await response.json();
|
||||||
|
console.log('Transaction updated successfully:', updatedTransaction);
|
||||||
|
|
||||||
|
// Update the transaction in the existing list
|
||||||
|
const currentList = currentTransactions.get();
|
||||||
|
const updatedList = currentList.map((t) =>
|
||||||
|
t.id === updatedTransaction.id ? updatedTransaction : t,
|
||||||
|
);
|
||||||
|
currentTransactions.set(updatedList);
|
||||||
|
|
||||||
|
// Trigger refresh to update other components
|
||||||
|
triggerRefresh();
|
||||||
|
|
||||||
|
return updatedTransaction;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating transaction:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
238
src/test/db-integration.test.ts
Normal file
238
src/test/db-integration.test.ts
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import supertest from 'supertest';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { accountService, transactionService } from '../data/db.service';
|
||||||
|
import { prisma } from '../data/prisma';
|
||||||
|
|
||||||
|
// Define a test server
|
||||||
|
// Note: In a real scenario, you might want to start an actual server or mock the Astro API routes
|
||||||
|
const BASE_URL = 'http://localhost:3000';
|
||||||
|
const request = supertest(BASE_URL);
|
||||||
|
|
||||||
|
// Test variables to store IDs across tests
|
||||||
|
let testAccountId = '';
|
||||||
|
let testTransactionId = '';
|
||||||
|
|
||||||
|
// Skip these tests if we detect we're not in an environment with a database
|
||||||
|
// This helps avoid failing tests in CI/CD environments without DB setup
|
||||||
|
const shouldSkipTests = process.env.NODE_ENV === 'test' && !process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
// Run this entire test suite conditionally
|
||||||
|
describe('Database Integration Tests', () => {
|
||||||
|
// Setup before all tests
|
||||||
|
beforeAll(async () => {
|
||||||
|
if (shouldSkipTests) {
|
||||||
|
console.warn('Skipping database tests: No database connection available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify database connection
|
||||||
|
try {
|
||||||
|
await prisma.$connect();
|
||||||
|
console.log('Database connection successful');
|
||||||
|
|
||||||
|
// Create a test account
|
||||||
|
const testAccount = await accountService.create({
|
||||||
|
bankName: 'Test Bank',
|
||||||
|
accountNumber: '123456',
|
||||||
|
name: 'Test Account',
|
||||||
|
type: 'CHECKING',
|
||||||
|
balance: 1000,
|
||||||
|
notes: 'Created for automated testing',
|
||||||
|
});
|
||||||
|
testAccountId = testAccount.id;
|
||||||
|
console.log(`Created test account with ID: ${testAccountId}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database connection failed:', error);
|
||||||
|
// We'll check this in the first test and skip as needed
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup after all tests
|
||||||
|
afterAll(async () => {
|
||||||
|
if (shouldSkipTests) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clean up the test data
|
||||||
|
if (testTransactionId) {
|
||||||
|
await transactionService.delete(testTransactionId);
|
||||||
|
console.log(`Cleaned up test transaction: ${testTransactionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testAccountId) {
|
||||||
|
await accountService.delete(testAccountId);
|
||||||
|
console.log(`Cleaned up test account: ${testAccountId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Cleanup failed:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Conditional test execution - checks if DB is available
|
||||||
|
it('should connect to the database', () => {
|
||||||
|
if (shouldSkipTests) {
|
||||||
|
return it.skip('Database tests are disabled in this environment');
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is just a placeholder test - real connection check happens in beforeAll
|
||||||
|
expect(prisma).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test Account API
|
||||||
|
describe('Account API', () => {
|
||||||
|
it('should get all accounts', async () => {
|
||||||
|
if (shouldSkipTests) return;
|
||||||
|
|
||||||
|
const response = await request.get('/api/accounts');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
expect(response.body.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Check if our test account is in the response
|
||||||
|
const foundAccount = response.body.find((account: any) => account.id === testAccountId);
|
||||||
|
expect(foundAccount).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get a single account by ID', async () => {
|
||||||
|
if (shouldSkipTests) return;
|
||||||
|
|
||||||
|
const response = await request.get(`/api/accounts/${testAccountId}`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.id).toBe(testAccountId);
|
||||||
|
expect(response.body.name).toBe('Test Account');
|
||||||
|
expect(response.body.bankName).toBe('Test Bank');
|
||||||
|
expect(response.body.accountNumber).toBe('123456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test Transaction API
|
||||||
|
describe('Transaction API', () => {
|
||||||
|
it('should create a new transaction', async () => {
|
||||||
|
if (shouldSkipTests) return;
|
||||||
|
|
||||||
|
const transactionData = {
|
||||||
|
accountId: testAccountId,
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
description: 'Test Transaction',
|
||||||
|
amount: -50.25,
|
||||||
|
category: 'Testing',
|
||||||
|
type: 'WITHDRAWAL',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request
|
||||||
|
.post('/api/transactions')
|
||||||
|
.send(transactionData)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.set('Accept', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body.id).toBeDefined();
|
||||||
|
expect(response.body.description).toBe('Test Transaction');
|
||||||
|
expect(response.body.amount).toBe(-50.25);
|
||||||
|
|
||||||
|
// Save the transaction ID for later tests
|
||||||
|
testTransactionId = response.body.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get account transactions', async () => {
|
||||||
|
if (shouldSkipTests) return;
|
||||||
|
|
||||||
|
const response = await request.get(`/api/accounts/${testAccountId}/transactions`);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(Array.isArray(response.body)).toBe(true);
|
||||||
|
|
||||||
|
// Check if our test transaction is in the response
|
||||||
|
const foundTransaction = response.body.find((txn: any) => txn.id === testTransactionId);
|
||||||
|
expect(foundTransaction).toBeDefined();
|
||||||
|
expect(foundTransaction.description).toBe('Test Transaction');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update a transaction', async () => {
|
||||||
|
if (shouldSkipTests) return;
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
description: 'Updated Transaction',
|
||||||
|
amount: -75.5,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request
|
||||||
|
.put(`/api/transactions/${testTransactionId}`)
|
||||||
|
.send(updateData)
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.set('Accept', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.description).toBe('Updated Transaction');
|
||||||
|
expect(response.body.amount).toBe(-75.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify account balance updates after transaction changes', async () => {
|
||||||
|
if (shouldSkipTests) return;
|
||||||
|
|
||||||
|
// Get the latest account data
|
||||||
|
const account = await accountService.getById(testAccountId);
|
||||||
|
expect(account).toBeDefined();
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
// The account should have been debited by the transaction amount
|
||||||
|
expect(account.balance).toBe(1000 - 75.5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a transaction', async () => {
|
||||||
|
if (shouldSkipTests) return;
|
||||||
|
|
||||||
|
// Get the initial account data
|
||||||
|
const accountBefore = await accountService.getById(testAccountId);
|
||||||
|
const initialBalance = accountBefore?.balance || 0;
|
||||||
|
|
||||||
|
const response = await request.delete(`/api/transactions/${testTransactionId}`);
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
|
||||||
|
// Verify the transaction is gone
|
||||||
|
const transactionCheck = await transactionService.getById(testTransactionId);
|
||||||
|
expect(transactionCheck).toBeNull();
|
||||||
|
|
||||||
|
// Verify account balance was restored
|
||||||
|
const accountAfter = await accountService.getById(testAccountId);
|
||||||
|
expect(accountAfter?.balance).toBe(initialBalance + 75.5);
|
||||||
|
|
||||||
|
// Clear the testTransactionId since it's been deleted
|
||||||
|
testTransactionId = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test error handling
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should handle invalid transaction creation', async () => {
|
||||||
|
if (shouldSkipTests) return;
|
||||||
|
|
||||||
|
// Missing required fields
|
||||||
|
const invalidData = {
|
||||||
|
accountId: testAccountId,
|
||||||
|
// Missing date, description, amount
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request
|
||||||
|
.post('/api/transactions')
|
||||||
|
.send(invalidData)
|
||||||
|
.set('Content-Type', 'application/json');
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle non-existent account', async () => {
|
||||||
|
if (shouldSkipTests) return;
|
||||||
|
|
||||||
|
const response = await request.get('/api/accounts/non-existent-id');
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.error).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
22
src/types.ts
22
src/types.ts
@@ -1,14 +1,28 @@
|
|||||||
export interface Account {
|
export interface Account {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
bankName: string;
|
||||||
last4: string;
|
accountNumber: string; // Last 6 digits
|
||||||
balance: number;
|
name: string; // Friendly name
|
||||||
|
type?: string; // CHECKING, SAVINGS, etc.
|
||||||
|
status?: string; // ACTIVE, CLOSED
|
||||||
|
currency?: string; // Default: USD
|
||||||
|
balance: number; // Current balance
|
||||||
|
notes?: string; // Optional notes
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
id: string;
|
id: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
date: string; // ISO date string e.g., "2023-11-28"
|
date: string | Date; // ISO date string or Date object
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
category?: string; // Optional category
|
||||||
|
status?: string; // PENDING, CLEARED
|
||||||
|
type?: string; // DEPOSIT, WITHDRAWAL, TRANSFER
|
||||||
|
notes?: string; // Optional notes
|
||||||
|
tags?: string; // Optional comma-separated tags
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/utils.ts
36
src/utils.ts
@@ -6,12 +6,32 @@ export function formatCurrency(amount: number): string {
|
|||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic date formatting
|
// Enhanced date formatting with error handling
|
||||||
export function formatDate(dateString: string): string {
|
export function formatDate(dateString: string | Date | null): string {
|
||||||
const date = new Date(`${dateString}T00:00:00`); // Ensure correct parsing as local date
|
if (!dateString) {
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
return 'Invalid date';
|
||||||
year: 'numeric',
|
}
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
try {
|
||||||
}).format(date);
|
// Handle Date objects directly
|
||||||
|
const date =
|
||||||
|
dateString instanceof Date
|
||||||
|
? dateString
|
||||||
|
: new Date(typeof dateString === 'string' ? dateString : '');
|
||||||
|
|
||||||
|
// Check for invalid date
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
console.warn('Invalid date encountered:', dateString);
|
||||||
|
return 'Invalid date';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error formatting date:', error);
|
||||||
|
return 'Invalid date';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
tsconfig.node.json
Normal file
14
tsconfig.node.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"target": "ES2020",
|
||||||
|
"outDir": "dist",
|
||||||
|
"esModuleInterop": true
|
||||||
|
},
|
||||||
|
"ts-node": {
|
||||||
|
"esm": true,
|
||||||
|
"experimentalSpecifierResolution": "node"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user