mirror of
https://github.com/acedanger/finance.git
synced 2025-12-05 22:50:12 -08:00
transitioned from astro to react
This commit is contained in:
50
README.md
50
README.md
@@ -80,16 +80,56 @@ import { transactionService } from '../../../../data/db.service';
|
|||||||
|
|
||||||
### Enforcing Path Aliases with Biome.js
|
### Enforcing Path Aliases with Biome.js
|
||||||
|
|
||||||
This project uses [Biome.js](https://biomejs.dev/) for code formatting and linting. Biome enforces the use of path aliases instead of relative imports. To run Biome checks:
|
This project uses [Biome.js](https://biomejs.dev/) for code formatting and linting. Biome enforces the use of path aliases instead of relative imports.
|
||||||
|
|
||||||
|
## Code Quality Tools
|
||||||
|
|
||||||
|
This project uses Biome.js for maintaining code quality, formatting, and linting. Biome provides fast, reliable tooling for JavaScript and TypeScript projects.
|
||||||
|
|
||||||
|
### Available Commands
|
||||||
|
|
||||||
|
#### Quick Commands (via npm scripts):
|
||||||
```bash
|
```bash
|
||||||
|
# Format all files (fixes formatting only)
|
||||||
|
npm run format
|
||||||
|
|
||||||
|
# Lint all files (shows issues without fixing)
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Check and fix all auto-fixable issues (recommended)
|
||||||
npm run check
|
npm run check
|
||||||
```
|
```
|
||||||
|
|
||||||
To automatically fix issues:
|
#### Direct Biome Commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run check -- --apply
|
# Format with write
|
||||||
|
npx biome format --write .
|
||||||
|
|
||||||
|
# Lint only (no fixes)
|
||||||
|
npx biome lint .
|
||||||
|
|
||||||
|
# Check and apply safe fixes
|
||||||
|
npx biome check --write .
|
||||||
|
|
||||||
|
# Check and apply all fixes (including unsafe ones)
|
||||||
|
npx biome check --write --unsafe .
|
||||||
```
|
```
|
||||||
|
|
||||||
The Biome configuration (in `biome.json`) includes rules for import sorting and path alias enforcement. To customize the rules, edit the `biome.json` file.
|
### What Biome.js Enforces:
|
||||||
|
|
||||||
|
- **Path Aliases**: Enforces use of `@components/*`, `@types`, etc. instead of relative imports
|
||||||
|
- **Node.js Import Protocols**: Requires `node:path` instead of `path` for Node.js built-ins
|
||||||
|
- **Code Formatting**: Consistent indentation, quotes, semicolons, and line width (100 chars)
|
||||||
|
- **Import Organization**: Automatic sorting and organization of imports
|
||||||
|
- **TypeScript Best Practices**: Warns about `any` types, unused imports, excessive complexity
|
||||||
|
|
||||||
|
### Pre-commit Hooks
|
||||||
|
|
||||||
|
This project uses Husky for Git hooks. Biome.js checks run automatically before commits to ensure code quality.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Biome.js configuration is in `biome.json`. Key settings:
|
||||||
|
- **Formatter**: 2-space indentation, single quotes, 100-character line width
|
||||||
|
- **Linter**: Recommended rules plus custom rules for path aliases and Node.js protocols
|
||||||
|
- **Ignored Files**: `dist/`, `node_modules/`, coverage reports
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
import { defineConfig } from 'astro/config';
|
|
||||||
|
|
||||||
import node from '@astrojs/node';
|
|
||||||
|
|
||||||
import react from '@astrojs/react';
|
|
||||||
|
|
||||||
// https://astro.build/config
|
|
||||||
export default defineConfig({
|
|
||||||
output: 'server',
|
|
||||||
adapter: node({
|
|
||||||
mode: 'standalone',
|
|
||||||
}),
|
|
||||||
integrations: [react()],
|
|
||||||
vite: {
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': '/src',
|
|
||||||
'@components': '/src/components',
|
|
||||||
'@layouts': '/src/layouts',
|
|
||||||
'@data': '/src/data',
|
|
||||||
'@pages': '/src/pages',
|
|
||||||
'@styles': '/src/styles',
|
|
||||||
'@stores': '/src/stores',
|
|
||||||
'@utils': '/src/utils',
|
|
||||||
'@types': '/src/types.ts',
|
|
||||||
// Use the browser version of react-dom/server for client-side rendering
|
|
||||||
'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'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
19
index.html
Normal file
19
index.html
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Finance App</title>
|
||||||
|
<link rel="stylesheet" href="/src/styles/radix-ui.css" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="/node_modules/@fortawesome/fontawesome-free/css/all.min.css"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="/src/styles/global.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5242
package-lock.json
generated
5242
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -3,10 +3,14 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "concurrently \"npm run server:dev\" \"npm run client:dev\"",
|
||||||
"build": "astro build",
|
"client:dev": "vite",
|
||||||
"preview": "astro preview",
|
"server:dev": "tsx watch server/index.ts",
|
||||||
"astro": "astro",
|
"build": "npm run client:build && npm run server:build",
|
||||||
|
"client:build": "vite build",
|
||||||
|
"server:build": "tsc --project server/tsconfig.json",
|
||||||
|
"preview": "npm run build && npm start",
|
||||||
|
"start": "node dist/server/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
@@ -21,9 +25,6 @@
|
|||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "^12.5.3",
|
|
||||||
"@astrojs/node": "^9.2.2",
|
|
||||||
"@astrojs/react": "^4.3.0",
|
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@nanostores/react": "^1.0.0",
|
"@nanostores/react": "^1.0.0",
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
@@ -34,27 +35,35 @@
|
|||||||
"@radix-ui/react-toast": "^1.2.14",
|
"@radix-ui/react-toast": "^1.2.14",
|
||||||
"@types/react": "^19.1.5",
|
"@types/react": "^19.1.5",
|
||||||
"@types/react-dom": "^19.1.5",
|
"@types/react-dom": "^19.1.5",
|
||||||
"astro": "^5.8.0",
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.5.0",
|
||||||
|
"express": "^4.21.2",
|
||||||
"nanostores": "^1.0.1",
|
"nanostores": "^1.0.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0"
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^6.23.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.15.21",
|
"@types/node": "^22.15.21",
|
||||||
"@types/supertest": "^6.0.3",
|
"@types/supertest": "^6.0.3",
|
||||||
"@vitejs/plugin-react": "^4.5.0",
|
"@vitejs/plugin-react": "^4.5.0",
|
||||||
"@vitest/coverage-v8": "^3.1.4",
|
"@vitest/coverage-v8": "^3.1.4",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"prisma": "^6.8.2",
|
"prisma": "^6.8.2",
|
||||||
"supertest": "^7.1.1",
|
"supertest": "^7.1.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"vitest": "^3.1.4",
|
"tsx": "^4.7.1",
|
||||||
"wrangler": "^4.16.1"
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"vitest": "^3.1.4"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "node prisma/seed.js"
|
"seed": "node prisma/seed.js"
|
||||||
|
|||||||
283
server/data/db.service.ts
Normal file
283
server/data/db.service.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import type { Account, Transaction } from '../types.js';
|
||||||
|
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' },
|
||||||
|
}) as Promise<Account[]>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get account by ID
|
||||||
|
*/
|
||||||
|
async getById(id: string): Promise<Account | null> {
|
||||||
|
return prisma.account.findUnique({
|
||||||
|
where: { id },
|
||||||
|
}) as Promise<Account | null>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}) as Promise<Account>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
}) as Promise<Account>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an account
|
||||||
|
*/
|
||||||
|
async delete(id: string): Promise<Account | null> {
|
||||||
|
return prisma.account.delete({
|
||||||
|
where: { id },
|
||||||
|
}) as Promise<Account>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as Promise<Account>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get transactions for an account
|
||||||
|
*/
|
||||||
|
async getTransactions(accountId: string): Promise<Transaction[]> {
|
||||||
|
return prisma.transaction.findMany({
|
||||||
|
where: { accountId },
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
}) as Promise<Transaction[]>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transaction services
|
||||||
|
export const transactionService = {
|
||||||
|
/**
|
||||||
|
* Get all transactions
|
||||||
|
*/
|
||||||
|
async getAll(): Promise<Transaction[]> {
|
||||||
|
return prisma.transaction.findMany({
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
}) as Promise<Transaction[]>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get transactions by account ID
|
||||||
|
*/
|
||||||
|
async getByAccountId(accountId: string): Promise<Transaction[]> {
|
||||||
|
return prisma.transaction.findMany({
|
||||||
|
where: { accountId },
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
}) as Promise<Transaction[]>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get transaction by ID
|
||||||
|
*/
|
||||||
|
async getById(id: string): Promise<Transaction | null> {
|
||||||
|
return prisma.transaction.findUnique({
|
||||||
|
where: { id },
|
||||||
|
}) as Promise<Transaction | null>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Transaction>(async (tx) => {
|
||||||
|
// Create the transaction
|
||||||
|
const transaction = await tx.transaction.create({
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the account balance
|
||||||
|
await tx.account.update({
|
||||||
|
where: { id: data.accountId },
|
||||||
|
data: {
|
||||||
|
balance: {
|
||||||
|
increment: data.amount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return transaction as 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<Transaction | null>(async (tx) => {
|
||||||
|
// Get the current transaction to calculate difference
|
||||||
|
const currentTxn = await tx.transaction.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentTxn) return null;
|
||||||
|
|
||||||
|
// Amount is guaranteed to be defined at this point since we checked above
|
||||||
|
const amount = data.amount as number; // Use type assertion instead of non-null assertion
|
||||||
|
const amountDifference = amount - Number(currentTxn.amount);
|
||||||
|
|
||||||
|
// Update transaction
|
||||||
|
const updatedTxn = await tx.transaction.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update account balance
|
||||||
|
await tx.account.update({
|
||||||
|
where: { id: data.accountId || currentTxn.accountId },
|
||||||
|
data: {
|
||||||
|
balance: {
|
||||||
|
increment: amountDifference,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedTxn as Transaction;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If amount isn't changing, just update the transaction
|
||||||
|
return prisma.transaction.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
}) as Promise<Transaction>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a transaction and adjust account balance
|
||||||
|
*/
|
||||||
|
async delete(id: string): Promise<Transaction | null> {
|
||||||
|
return prisma.$transaction<Transaction | null>(async (tx) => {
|
||||||
|
// Get transaction before deleting
|
||||||
|
const transaction = await tx.transaction.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!transaction) return null;
|
||||||
|
|
||||||
|
// Delete the transaction
|
||||||
|
const deletedTxn = await tx.transaction.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adjust the account balance (reverse the transaction amount)
|
||||||
|
await tx.account.update({
|
||||||
|
where: { id: transaction.accountId },
|
||||||
|
data: {
|
||||||
|
balance: {
|
||||||
|
decrement: Number(transaction.amount),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return deletedTxn as Transaction;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
13
server/data/prisma.ts
Normal file
13
server/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;
|
||||||
|
}
|
||||||
39
server/index.ts
Normal file
39
server/index.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import cors from 'cors';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
config();
|
||||||
|
|
||||||
|
// Import API routes
|
||||||
|
import accountsRouter from './routes/accounts.js';
|
||||||
|
import transactionsRouter from './routes/transactions.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api/accounts', accountsRouter);
|
||||||
|
app.use('/api/transactions', transactionsRouter);
|
||||||
|
|
||||||
|
// Serve static files in production
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
app.use(express.static(join(__dirname, '../client')));
|
||||||
|
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(join(__dirname, '../client/index.html'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
98
server/routes/accounts.ts
Normal file
98
server/routes/accounts.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { AccountStatus, AccountType, accountService } from '../data/db.service.js';
|
||||||
|
import type { Account } from '../types.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/accounts - Get all accounts
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
|
||||||
|
router.get('/', async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
console.log('GET /api/accounts - Fetching all accounts');
|
||||||
|
const accounts = await accountService.getAll();
|
||||||
|
console.log('GET /api/accounts - Found accounts:', accounts?.length ?? 0);
|
||||||
|
|
||||||
|
res.json(accounts || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching accounts:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Failed to fetch accounts',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/accounts - Create new account
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
|
||||||
|
router.post('/', async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const accountData = req.body as Omit<Account, 'id' | 'createdAt' | 'updatedAt'>;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!accountData.name || !accountData.bankName || !accountData.accountNumber) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required fields: name, bankName, and accountNumber are required',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default values and ensure proper type casting
|
||||||
|
const data = {
|
||||||
|
...accountData,
|
||||||
|
balance: accountData.balance ? Number(accountData.balance) : 0,
|
||||||
|
type: (accountData.type as AccountType) || AccountType.CHECKING,
|
||||||
|
status: (accountData.status as AccountStatus) || AccountStatus.ACTIVE,
|
||||||
|
notes: accountData.notes || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the account
|
||||||
|
const newAccount = await accountService.create(data);
|
||||||
|
|
||||||
|
res.status(201).json(newAccount);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating account:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create account' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/accounts/:id - Get single account
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
|
||||||
|
router.get('/:id', async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ error: 'Account ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await accountService.getById(id);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(account);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching account:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch account' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/accounts/:id/transactions - Get transactions for account
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
|
||||||
|
router.get('/:id/transactions', async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ error: 'Account ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactions = await accountService.getTransactions(id);
|
||||||
|
res.json(transactions || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching account transactions:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch account transactions' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
189
server/routes/transactions.ts
Normal file
189
server/routes/transactions.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { accountService, transactionService } from '../data/db.service.js';
|
||||||
|
import type { Transaction, TransactionStatus, TransactionType } from '../types.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Helper function to transform update data
|
||||||
|
function transformUpdateData(updateData: Partial<Omit<Transaction, 'id'>>) {
|
||||||
|
const typedUpdateData: {
|
||||||
|
accountId?: string;
|
||||||
|
date?: Date;
|
||||||
|
description?: string;
|
||||||
|
amount?: number;
|
||||||
|
category?: string;
|
||||||
|
status?: TransactionStatus;
|
||||||
|
type?: TransactionType;
|
||||||
|
notes?: string;
|
||||||
|
tags?: string;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
// Convert string date to Date object if provided
|
||||||
|
if (updateData.date) {
|
||||||
|
typedUpdateData.date =
|
||||||
|
typeof updateData.date === 'string' ? new Date(updateData.date) : updateData.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert amount to number if provided
|
||||||
|
if (updateData.amount !== undefined) {
|
||||||
|
typedUpdateData.amount = Number(updateData.amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy other fields
|
||||||
|
if (updateData.accountId) typedUpdateData.accountId = updateData.accountId;
|
||||||
|
if (updateData.description) typedUpdateData.description = updateData.description;
|
||||||
|
if (updateData.category !== undefined)
|
||||||
|
typedUpdateData.category = updateData.category || undefined;
|
||||||
|
if (updateData.status) typedUpdateData.status = updateData.status as TransactionStatus;
|
||||||
|
if (updateData.type) typedUpdateData.type = updateData.type as TransactionType;
|
||||||
|
if (updateData.notes !== undefined) typedUpdateData.notes = updateData.notes || undefined;
|
||||||
|
if (updateData.tags !== undefined) typedUpdateData.tags = updateData.tags || undefined;
|
||||||
|
|
||||||
|
return typedUpdateData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/transactions - Create new transaction
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
|
||||||
|
router.post('/', async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const transaction = req.body as Omit<Transaction, 'id'>;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (
|
||||||
|
!transaction.accountId ||
|
||||||
|
!transaction.date ||
|
||||||
|
!transaction.description ||
|
||||||
|
transaction.amount === undefined
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate account exists
|
||||||
|
const account = await accountService.getById(transaction.accountId);
|
||||||
|
if (!account) {
|
||||||
|
return res.status(404).json({ error: 'Account not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert string date to Date object if needed
|
||||||
|
const transactionDate =
|
||||||
|
typeof transaction.date === 'string' ? new Date(transaction.date) : transaction.date;
|
||||||
|
|
||||||
|
// Create new transaction with database service
|
||||||
|
const newTransaction = await transactionService.create({
|
||||||
|
accountId: transaction.accountId,
|
||||||
|
date: transactionDate,
|
||||||
|
description: transaction.description,
|
||||||
|
amount: Number(transaction.amount),
|
||||||
|
category: transaction.category || undefined,
|
||||||
|
status: transaction.status as TransactionStatus,
|
||||||
|
type: transaction.type as TransactionType,
|
||||||
|
notes: transaction.notes || undefined,
|
||||||
|
tags: transaction.tags || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert Decimal to number for response
|
||||||
|
const response = {
|
||||||
|
...newTransaction,
|
||||||
|
amount: Number(newTransaction.amount),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(201).json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating transaction:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create transaction' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/transactions/:id - Get single transaction
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
|
||||||
|
router.get('/:id', async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ error: 'Transaction ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = await transactionService.getById(id);
|
||||||
|
|
||||||
|
if (!transaction) {
|
||||||
|
return res.status(404).json({ error: 'Transaction not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Decimal to number for response
|
||||||
|
const response = {
|
||||||
|
...transaction,
|
||||||
|
amount: Number(transaction.amount),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching transaction:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch transaction' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/transactions/:id - Update transaction
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
|
||||||
|
router.put('/:id', async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ error: 'Transaction ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if transaction exists
|
||||||
|
const existingTransaction = await transactionService.getById(id);
|
||||||
|
if (!existingTransaction) {
|
||||||
|
return res.status(404).json({ error: 'Transaction not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = req.body as Partial<Omit<Transaction, 'id'>>;
|
||||||
|
const typedUpdateData = transformUpdateData(updateData);
|
||||||
|
|
||||||
|
const updatedTransaction = await transactionService.update(id, typedUpdateData);
|
||||||
|
|
||||||
|
if (!updatedTransaction) {
|
||||||
|
return res.status(404).json({ error: 'Transaction not found or could not be updated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert Decimal to number for response
|
||||||
|
const response = {
|
||||||
|
...updatedTransaction,
|
||||||
|
amount: Number(updatedTransaction.amount),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating transaction:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update transaction' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/transactions/:id - Delete transaction
|
||||||
|
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
|
||||||
|
router.delete('/:id', async (req: any, res: any) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ error: 'Transaction ID is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if transaction exists
|
||||||
|
const existingTransaction = await transactionService.getById(id);
|
||||||
|
if (!existingTransaction) {
|
||||||
|
return res.status(404).json({ error: 'Transaction not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await transactionService.delete(id);
|
||||||
|
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting transaction:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete transaction' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
30
server/tsconfig.json
Normal file
30
server/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"outDir": "../dist/server",
|
||||||
|
"rootDir": ".",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowJs": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"../dist",
|
||||||
|
"../src"
|
||||||
|
]
|
||||||
|
}
|
||||||
56
server/types.ts
Normal file
56
server/types.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Decimal } from '@prisma/client/runtime/library';
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: string;
|
||||||
|
bankName: string;
|
||||||
|
accountNumber: string; // Last 6 digits
|
||||||
|
name: string; // Friendly name
|
||||||
|
type?: string; // CHECKING, SAVINGS, etc.
|
||||||
|
status?: string; // ACTIVE, CLOSED
|
||||||
|
currency?: string; // Default: USD
|
||||||
|
balance: number | Decimal; // Current balance - can be Prisma Decimal or number
|
||||||
|
notes?: string | null; // Optional notes - accepts null for Prisma compatibility
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: string;
|
||||||
|
accountId: string;
|
||||||
|
date: string | Date; // ISO date string or Date object
|
||||||
|
description: string;
|
||||||
|
amount: number | Decimal; // Amount - can be Prisma Decimal or number
|
||||||
|
category?: string | null; // Optional category - accepts null for Prisma compatibility
|
||||||
|
status?: string; // PENDING, CLEARED
|
||||||
|
type?: string; // DEPOSIT, WITHDRAWAL, TRANSFER
|
||||||
|
notes?: string | null; // Optional notes - accepts null for Prisma compatibility
|
||||||
|
tags?: string | null; // Optional comma-separated tags - accepts null for Prisma compatibility
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type definitions for Transaction status and type enums to match db.service.ts
|
||||||
|
export enum TransactionStatus {
|
||||||
|
PENDING = 'PENDING',
|
||||||
|
CLEARED = 'CLEARED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TransactionType {
|
||||||
|
DEPOSIT = 'DEPOSIT',
|
||||||
|
WITHDRAWAL = 'WITHDRAWAL',
|
||||||
|
TRANSFER = 'TRANSFER',
|
||||||
|
UNSPECIFIED = 'UNSPECIFIED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AccountType {
|
||||||
|
CHECKING = 'CHECKING',
|
||||||
|
SAVINGS = 'SAVINGS',
|
||||||
|
CREDIT_CARD = 'CREDIT_CARD',
|
||||||
|
INVESTMENT = 'INVESTMENT',
|
||||||
|
OTHER = 'OTHER',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AccountStatus {
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
CLOSED = 'CLOSED',
|
||||||
|
}
|
||||||
14
src/App.tsx
Normal file
14
src/App.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import Dashboard from './pages/Dashboard.tsx';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
---
|
|
||||||
import type { Account } from "@types";
|
|
||||||
// biome-ignore lint/correctness/noUnusedImports: formatCurrency is used in the template
|
|
||||||
import { formatCurrency } from "@utils";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
account: Account;
|
|
||||||
}
|
|
||||||
const { account } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class="account-summary">
|
|
||||||
<h4>Account Summary</h4>
|
|
||||||
<p>
|
|
||||||
Balance: <span id="account-balance"
|
|
||||||
>{formatCurrency(Number(account.balance))}</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
---
|
|
||||||
import type { Account, Transaction } from "@types";
|
|
||||||
import TransactionTable from "./TransactionTable";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
account: Account;
|
|
||||||
transactions: Transaction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { account, transactions } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<main class="main-content">
|
|
||||||
<header class="main-header">
|
|
||||||
<h1>
|
|
||||||
Transactions for <span id="current-account-name"
|
|
||||||
>{account.name} (***{account.accountNumber.slice(-3)})</span
|
|
||||||
>
|
|
||||||
</h1>
|
|
||||||
</header>
|
|
||||||
<TransactionTable client:load />
|
|
||||||
</main>
|
|
||||||
34
src/components/MainContent.tsx
Normal file
34
src/components/MainContent.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { currentAccount } from '@stores/transactionStore';
|
||||||
|
import TransactionTable from './TransactionTable';
|
||||||
|
|
||||||
|
export default function MainContent() {
|
||||||
|
const account = useStore(currentAccount);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return (
|
||||||
|
<main className="main-content">
|
||||||
|
<header className="main-header">
|
||||||
|
<h1>No account selected</h1>
|
||||||
|
</header>
|
||||||
|
<div className="no-account">
|
||||||
|
Please select an account from the sidebar to view transactions.
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="main-content">
|
||||||
|
<header className="main-header">
|
||||||
|
<h1>
|
||||||
|
Transactions for{' '}
|
||||||
|
<span id="current-account-name">
|
||||||
|
{account.name} (***{account.accountNumber.slice(-3)})
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<TransactionTable />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
---
|
|
||||||
import type { Account } from "@types";
|
|
||||||
import AddTransactionForm from "./AddTransactionForm";
|
|
||||||
import AccountSummary from "./AccountSummary";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
accounts: Account[];
|
|
||||||
initialAccount: Account;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { accounts, initialAccount } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<h2>My finances</h2>
|
|
||||||
</div>
|
|
||||||
<nav class="account-nav">
|
|
||||||
<h3>Accounts</h3>
|
|
||||||
<select id="account-select" name="account" class="form-input">
|
|
||||||
{
|
|
||||||
accounts.map((account) => (
|
|
||||||
<option
|
|
||||||
value={account.id}
|
|
||||||
selected={account.id === initialAccount.id}
|
|
||||||
>
|
|
||||||
{account.name} (***{account.accountNumber.slice(-3)})
|
|
||||||
</option>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Add Transaction Section with Toggle - Moved up to be right after account dropdown -->
|
|
||||||
<section id="add-transaction-section" class="add-transaction-section">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="toggle-form-btn"
|
|
||||||
id="toggle-add-txn-btn"
|
|
||||||
aria-controls="add-transaction-form"
|
|
||||||
aria-expanded="false"
|
|
||||||
>
|
|
||||||
Add Transaction
|
|
||||||
</button>
|
|
||||||
<div id="add-transaction-form" class="collapsible-form collapsed">
|
|
||||||
<AddTransactionForm client:load />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Account Summary Section - Always visible -->
|
|
||||||
<div class="account-summary-section" id="account-summary-section">
|
|
||||||
<AccountSummary client:load />
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Toggle button for sidebar on mobile -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="sidebar-toggle"
|
|
||||||
id="sidebar-toggle"
|
|
||||||
aria-controls="sidebar-content"
|
|
||||||
aria-expanded="true"
|
|
||||||
>
|
|
||||||
<span>Toggle sidebar</span>
|
|
||||||
<span class="sidebar-toggle-icon">▲</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Add Transaction form toggle
|
|
||||||
const toggleAddTxnBtn = document.getElementById("toggle-add-txn-btn");
|
|
||||||
const addTransactionForm = document.getElementById("add-transaction-form");
|
|
||||||
|
|
||||||
toggleAddTxnBtn?.addEventListener("click", () => {
|
|
||||||
const isExpanded =
|
|
||||||
toggleAddTxnBtn.getAttribute("aria-expanded") === "true";
|
|
||||||
toggleAddTxnBtn.setAttribute(
|
|
||||||
"aria-expanded",
|
|
||||||
isExpanded ? "false" : "true",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
addTransactionForm?.classList.add("collapsed");
|
|
||||||
} else {
|
|
||||||
addTransactionForm?.classList.remove("collapsed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sidebar toggle for mobile
|
|
||||||
const sidebarToggleBtn = document.getElementById("sidebar-toggle");
|
|
||||||
const sidebar = document.querySelector(".sidebar");
|
|
||||||
|
|
||||||
sidebarToggleBtn?.addEventListener("click", () => {
|
|
||||||
const isExpanded =
|
|
||||||
sidebarToggleBtn.getAttribute("aria-expanded") === "true";
|
|
||||||
sidebarToggleBtn.setAttribute(
|
|
||||||
"aria-expanded",
|
|
||||||
isExpanded ? "false" : "true",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
sidebar?.classList.add("sidebar-collapsed");
|
|
||||||
} else {
|
|
||||||
sidebar?.classList.remove("sidebar-collapsed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if we're on mobile and collapse sidebar by default
|
|
||||||
const checkMobile = () => {
|
|
||||||
const isMobile = window.innerWidth < 1024;
|
|
||||||
|
|
||||||
if (isMobile && sidebar && sidebarToggleBtn) {
|
|
||||||
// Start with sidebar collapsed on mobile
|
|
||||||
sidebar.classList.add("sidebar-collapsed");
|
|
||||||
sidebarToggleBtn.setAttribute("aria-expanded", "false");
|
|
||||||
} else if (sidebar && sidebarToggleBtn) {
|
|
||||||
sidebar.classList.remove("sidebar-collapsed");
|
|
||||||
sidebarToggleBtn.setAttribute("aria-expanded", "true");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check on load and window resize
|
|
||||||
window.addEventListener("load", checkMobile);
|
|
||||||
window.addEventListener("resize", checkMobile);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sidebar {
|
|
||||||
padding: 20px;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
border-right: 1px solid #e5e7eb;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-collapsed {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-nav {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.account-nav h3 {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure account summary is always visible */
|
|
||||||
.account-summary-section {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
padding-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-transaction-section {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.add-transaction-section .toggle-form-btn {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.collapsible-form.collapsed {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-toggle {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
padding: 10px;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 100;
|
|
||||||
width: auto;
|
|
||||||
min-width: 140px;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-toggle span {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-toggle-icon {
|
|
||||||
margin-left: 8px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.sidebar-toggle {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
94
src/components/Sidebar.tsx
Normal file
94
src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { currentAccountId, loadTransactionsForAccount } from '@stores/transactionStore';
|
||||||
|
import type { Account } from '@types';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import AccountSummary from './AccountSummary';
|
||||||
|
import AddTransactionForm from './AddTransactionForm';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accounts: Account[];
|
||||||
|
initialAccount: Account;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Sidebar({ accounts, initialAccount }: Props) {
|
||||||
|
const [isFormExpanded, setIsFormExpanded] = useState(false);
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||||
|
const accountId = useStore(currentAccountId);
|
||||||
|
|
||||||
|
const handleAccountChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const selectedAccountId = event.target.value;
|
||||||
|
if (selectedAccountId) {
|
||||||
|
loadTransactionsForAccount(selectedAccountId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleForm = () => {
|
||||||
|
setIsFormExpanded(!isFormExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
setIsSidebarCollapsed(!isSidebarCollapsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<aside className={`sidebar ${isSidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
||||||
|
<div className="sidebar-header">
|
||||||
|
<h2>My finances</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="account-nav">
|
||||||
|
<h3>Accounts</h3>
|
||||||
|
<select
|
||||||
|
id="account-select"
|
||||||
|
name="account"
|
||||||
|
className="form-input"
|
||||||
|
value={accountId || initialAccount.id}
|
||||||
|
onChange={handleAccountChange}
|
||||||
|
>
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<option key={account.id} value={account.id}>
|
||||||
|
{account.name} (***{account.accountNumber.slice(-3)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section id="add-transaction-section" className="add-transaction-section">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toggle-form-btn"
|
||||||
|
id="toggle-add-txn-btn"
|
||||||
|
aria-controls="add-transaction-form"
|
||||||
|
aria-expanded={isFormExpanded}
|
||||||
|
onClick={toggleForm}
|
||||||
|
>
|
||||||
|
Add Transaction
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id="add-transaction-form"
|
||||||
|
className={`collapsible-form ${isFormExpanded ? '' : 'collapsed'}`}
|
||||||
|
>
|
||||||
|
<AddTransactionForm />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="account-summary-section" id="account-summary-section">
|
||||||
|
<AccountSummary />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sidebar-toggle"
|
||||||
|
id="sidebar-toggle"
|
||||||
|
aria-controls="sidebar-content"
|
||||||
|
aria-expanded={!isSidebarCollapsed}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
>
|
||||||
|
<span>Toggle sidebar</span>
|
||||||
|
<span className="sidebar-toggle-icon">{isSidebarCollapsed ? '▼' : '▲'}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
---
|
|
||||||
import type { Transaction } from "@types";
|
|
||||||
import { formatDate, formatCurrency } from "@utils";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
transactions: Transaction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { transactions } = Astro.props;
|
|
||||||
|
|
||||||
// Sort transactions by date descending for display
|
|
||||||
const sortedTransactions = [...transactions].sort(
|
|
||||||
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: UI/UX Improvements
|
|
||||||
// - Add sorting functionality for all columns
|
|
||||||
// - Implement pagination for large transaction lists
|
|
||||||
// - Add transaction filtering capabilities
|
|
||||||
// - Implement row hover actions
|
|
||||||
// - Add transaction details expansion/collapse
|
|
||||||
// - Consider adding bulk actions (delete, categorize)
|
|
||||||
---
|
|
||||||
|
|
||||||
<section class="transaction-list" id="transaction-section">
|
|
||||||
<table id="transaction-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Date</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th class="amount-col">Amount</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="transaction-table-body">
|
|
||||||
{
|
|
||||||
sortedTransactions.map((txn) => (
|
|
||||||
<tr data-txn-id={txn.id}>
|
|
||||||
<td>{formatDate(txn.date)}</td>
|
|
||||||
<td>{txn.description}</td>
|
|
||||||
<td
|
|
||||||
class={`amount-col ${Number(txn.amount) >= 0 ? "amount-positive" : "amount-negative"}`}
|
|
||||||
>
|
|
||||||
{formatCurrency(Number(txn.amount))}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
class="action-btn edit-btn"
|
|
||||||
title="Edit transaction"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="action-btn delete-btn"
|
|
||||||
title="Delete transaction"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
{
|
|
||||||
sortedTransactions.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colspan="4"
|
|
||||||
style="text-align: center; font-style: italic; color: #777;"
|
|
||||||
>
|
|
||||||
No transactions found for this account.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
export interface Props {
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Accessibility Improvements
|
|
||||||
// - Add ARIA landmarks for main regions
|
|
||||||
// - Implement keyboard navigation
|
|
||||||
// - Add skip navigation links
|
|
||||||
// - Ensure proper heading hierarchy
|
|
||||||
// - Add focus management for modals/dialogs
|
|
||||||
// - Implement proper announcements for dynamic content
|
|
||||||
// - Add high contrast theme support
|
|
||||||
|
|
||||||
const { title } = Astro.props;
|
|
||||||
---
|
|
||||||
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="description" content="Astro description" />
|
|
||||||
<meta name="viewport" content="width=device-width" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="generator" content={Astro.generator} />
|
|
||||||
<title>{title}</title>
|
|
||||||
<link rel="stylesheet" href="/src/styles/radix-ui.css" />
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="/node_modules/@fortawesome/fontawesome-free/css/all.min.css"
|
|
||||||
/>
|
|
||||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main>
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
16
src/main.tsx
Normal file
16
src/main.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
if (!rootElement) {
|
||||||
|
throw new Error('Root element not found');
|
||||||
|
}
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
79
src/pages/Dashboard.tsx
Normal file
79
src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import MainContent from '@components/MainContent';
|
||||||
|
import Sidebar from '@components/Sidebar';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import {
|
||||||
|
currentAccountId,
|
||||||
|
loadAccounts,
|
||||||
|
loadTransactionsForAccount,
|
||||||
|
} from '@stores/transactionStore';
|
||||||
|
import type { Account } from '@types';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const accountId = useStore(currentAccountId);
|
||||||
|
|
||||||
|
// Load accounts on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAccounts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/accounts');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch accounts');
|
||||||
|
}
|
||||||
|
const fetchedAccounts = await response.json();
|
||||||
|
setAccounts(fetchedAccounts);
|
||||||
|
|
||||||
|
// Load accounts into store
|
||||||
|
loadAccounts(fetchedAccounts);
|
||||||
|
|
||||||
|
// Set initial account if available
|
||||||
|
if (fetchedAccounts.length > 0 && !accountId) {
|
||||||
|
loadTransactionsForAccount(fetchedAccounts[0].id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching accounts:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load accounts');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAccounts();
|
||||||
|
}, [accountId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard-layout">
|
||||||
|
<div className="loading">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="dashboard-layout">
|
||||||
|
<div className="error">Error: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialAccount: Account = accounts[0] || {
|
||||||
|
id: '',
|
||||||
|
name: 'No accounts available',
|
||||||
|
accountNumber: '000000',
|
||||||
|
balance: 0,
|
||||||
|
bankName: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<div className="dashboard-layout">
|
||||||
|
<Sidebar accounts={accounts} initialAccount={initialAccount} />
|
||||||
|
<MainContent />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { accountService } from '@data/db.service';
|
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params }) => {
|
|
||||||
try {
|
|
||||||
const account = await accountService.getById(params.id as string);
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Account not found' }), {
|
|
||||||
status: 404,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(account), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching account details:', error);
|
|
||||||
return new Response(JSON.stringify({ error: 'Failed to fetch account details' }), {
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { transactionService } from '@data/db.service';
|
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params }) => {
|
|
||||||
try {
|
|
||||||
const accountTransactions = await transactionService.getByAccountId(params.id as string);
|
|
||||||
|
|
||||||
// Convert Decimal to number for each transaction in response
|
|
||||||
const response = accountTransactions.map((transaction) => ({
|
|
||||||
...transaction,
|
|
||||||
amount: Number(transaction.amount),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(response), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'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,75 +0,0 @@
|
|||||||
import { AccountStatus, AccountType, accountService } from '@data/db.service';
|
|
||||||
import type { Account } from '@types';
|
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ params, request }) => {
|
|
||||||
try {
|
|
||||||
console.log('GET /api/accounts - Fetching all accounts');
|
|
||||||
const accounts = await accountService.getAll();
|
|
||||||
console.log('GET /api/accounts - Found accounts:', accounts?.length ?? 0);
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(accounts || []), {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching accounts:', error);
|
|
||||||
// Always return a proper JSON response, even in error cases
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Failed to fetch accounts',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
|
||||||
try {
|
|
||||||
const accountData = (await request.json()) as Omit<Account, 'id' | 'createdAt' | 'updatedAt'>;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (!accountData.name || !accountData.bankName || !accountData.accountNumber) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: 'Missing required fields: name, bankName, and accountNumber are required',
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set default values and ensure proper type casting
|
|
||||||
const data = {
|
|
||||||
...accountData,
|
|
||||||
balance: accountData.balance ? Number(accountData.balance) : 0,
|
|
||||||
type: (accountData.type as AccountType) || AccountType.CHECKING,
|
|
||||||
status: (accountData.status as AccountStatus) || AccountStatus.ACTIVE,
|
|
||||||
notes: accountData.notes || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the account
|
|
||||||
const newAccount = await accountService.create(data);
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(newAccount), {
|
|
||||||
status: 201,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating account:', error);
|
|
||||||
return new Response(JSON.stringify({ error: 'Failed to create account' }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { transactionService } from '@data/db.service';
|
|
||||||
import type { Transaction, TransactionStatus, TransactionType } from '@types';
|
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
|
|
||||||
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' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert Decimal to number for response
|
|
||||||
const response = {
|
|
||||||
...transaction,
|
|
||||||
amount: Number(transaction.amount),
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(response), {
|
|
||||||
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' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateRequestParams = (id: string | undefined) => {
|
|
||||||
if (!id) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Transaction ID is required' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateTransaction = async (
|
|
||||||
id: string,
|
|
||||||
transactionService: typeof import('@data/db.service').transactionService,
|
|
||||||
) => {
|
|
||||||
const existingTransaction = await transactionService.getById(id);
|
|
||||||
if (!existingTransaction) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
|
|
||||||
status: 404,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return existingTransaction;
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepareUpdateData = (updates: Partial<Transaction>) => {
|
|
||||||
const updatedData: Partial<{
|
|
||||||
accountId: string;
|
|
||||||
date: Date;
|
|
||||||
description: string;
|
|
||||||
amount: number;
|
|
||||||
category: string | undefined;
|
|
||||||
status: TransactionStatus | undefined;
|
|
||||||
type: TransactionType | undefined;
|
|
||||||
notes: string | undefined;
|
|
||||||
tags: string | undefined;
|
|
||||||
}> = {};
|
|
||||||
|
|
||||||
if (updates.accountId !== undefined) updatedData.accountId = updates.accountId;
|
|
||||||
if (updates.description !== undefined) updatedData.description = updates.description;
|
|
||||||
if (updates.amount !== undefined) updatedData.amount = Number(updates.amount);
|
|
||||||
if (updates.category !== undefined) updatedData.category = updates.category || undefined;
|
|
||||||
if (updates.notes !== undefined) updatedData.notes = updates.notes || undefined;
|
|
||||||
if (updates.tags !== undefined) updatedData.tags = updates.tags || undefined;
|
|
||||||
|
|
||||||
if (updates.date !== undefined) {
|
|
||||||
updatedData.date = typeof updates.date === 'string' ? new Date(updates.date) : updates.date;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.status !== undefined) {
|
|
||||||
updatedData.status = updates.status as TransactionStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.type !== undefined) {
|
|
||||||
updatedData.type = updates.type as TransactionType;
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PUT: APIRoute = async ({ request, params }) => {
|
|
||||||
const validationError = validateRequestParams(params.id);
|
|
||||||
if (validationError) return validationError;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updates = (await request.json()) as Partial<Transaction>;
|
|
||||||
const existingTransaction = await validateTransaction(params.id as string, transactionService);
|
|
||||||
if (existingTransaction instanceof Response) return existingTransaction;
|
|
||||||
|
|
||||||
const updatedData = prepareUpdateData(updates);
|
|
||||||
const updatedTransaction = await transactionService.update(params.id as string, updatedData);
|
|
||||||
|
|
||||||
if (!updatedTransaction) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Failed to update transaction' }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
...updatedTransaction,
|
|
||||||
amount: Number(updatedTransaction.amount),
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(response), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating transaction:', error);
|
|
||||||
return new Response(JSON.stringify({ error: 'Failed to update transaction' }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DELETE: APIRoute = async ({ params }) => {
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Transaction ID is required' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Delete the transaction using the service
|
|
||||||
// The service will automatically handle account balance adjustments
|
|
||||||
const deletedTransaction = await transactionService.delete(id);
|
|
||||||
|
|
||||||
if (!deletedTransaction) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Transaction not found' }), {
|
|
||||||
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' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* TODO: Security Improvements
|
|
||||||
* - Add input validation and sanitization
|
|
||||||
* - Implement rate limiting for API endpoints
|
|
||||||
* - Add request authentication
|
|
||||||
* - Implement CSRF protection
|
|
||||||
* - Add request logging and monitoring
|
|
||||||
* - Implement secure session management
|
|
||||||
* - Add API versioning
|
|
||||||
* - Set up proper CORS configuration
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { accountService, transactionService } from '@data/db.service';
|
|
||||||
import type { Transaction, TransactionStatus, TransactionType } from '@types';
|
|
||||||
import type { APIRoute } from 'astro';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: API Improvements
|
|
||||||
* - Add request rate limiting
|
|
||||||
* - Implement proper API authentication
|
|
||||||
* - Add input sanitization
|
|
||||||
* - Implement request validation middleware
|
|
||||||
* - Add API versioning
|
|
||||||
* - Consider implementing GraphQL for more flexible queries
|
|
||||||
* - Add proper logging and monitoring
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
|
||||||
try {
|
|
||||||
const transaction = (await request.json()) as Omit<Transaction, 'id'>;
|
|
||||||
|
|
||||||
// Validate required fields
|
|
||||||
if (
|
|
||||||
!transaction.accountId ||
|
|
||||||
!transaction.date ||
|
|
||||||
!transaction.description ||
|
|
||||||
transaction.amount === undefined
|
|
||||||
) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Missing required fields' }), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate account exists
|
|
||||||
const account = await accountService.getById(transaction.accountId);
|
|
||||||
if (!account) {
|
|
||||||
return new Response(JSON.stringify({ error: 'Account not found' }), {
|
|
||||||
status: 404,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert string date to Date object if needed
|
|
||||||
const transactionDate =
|
|
||||||
typeof transaction.date === 'string' ? new Date(transaction.date) : transaction.date;
|
|
||||||
|
|
||||||
// Create new transaction with database service
|
|
||||||
// The database service will also update the account balance
|
|
||||||
const newTransaction = await transactionService.create({
|
|
||||||
accountId: transaction.accountId,
|
|
||||||
date: transactionDate,
|
|
||||||
description: transaction.description,
|
|
||||||
amount: Number(transaction.amount),
|
|
||||||
category: transaction.category || undefined,
|
|
||||||
status: transaction.status as TransactionStatus,
|
|
||||||
type: transaction.type as TransactionType,
|
|
||||||
notes: transaction.notes || undefined,
|
|
||||||
tags: transaction.tags || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert Decimal to number for response
|
|
||||||
const response = {
|
|
||||||
...newTransaction,
|
|
||||||
amount: Number(newTransaction.amount),
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(response), {
|
|
||||||
status: 201,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating transaction:', error);
|
|
||||||
return new Response(JSON.stringify({ error: 'Failed to create transaction' }), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
---
|
|
||||||
import BaseLayout from "@layouts/BaseLayout.astro";
|
|
||||||
import MainContent from "@components/MainContent.astro";
|
|
||||||
import Sidebar from "@components/Sidebar.astro";
|
|
||||||
import AddTransactionForm from "@components/AddTransactionForm";
|
|
||||||
import type { Account, Transaction } from "@types";
|
|
||||||
|
|
||||||
// Create an instance of Astro's built-in fetch which handles SSR correctly
|
|
||||||
let accounts: Account[] = [];
|
|
||||||
try {
|
|
||||||
const accountsResponse = await fetch(new URL("/api/accounts", Astro.url));
|
|
||||||
if (!accountsResponse.ok) {
|
|
||||||
const error = await accountsResponse.text();
|
|
||||||
console.error("Failed to fetch accounts:", error);
|
|
||||||
// Continue with empty accounts array
|
|
||||||
} else {
|
|
||||||
accounts = await accountsResponse.json();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching accounts:", error);
|
|
||||||
// Continue with empty accounts array
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize with first account or empty account if none exist
|
|
||||||
const initialAccount: Account = accounts[0] || {
|
|
||||||
id: "",
|
|
||||||
name: "No accounts available",
|
|
||||||
accountNumber: "000000",
|
|
||||||
balance: 0,
|
|
||||||
bankName: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch initial transactions if we have an account
|
|
||||||
let initialTransactions: Transaction[] = [];
|
|
||||||
if (initialAccount.id) {
|
|
||||||
const transactionsResponse = await fetch(
|
|
||||||
new URL(`/api/accounts/${initialAccount.id}/transactions`, Astro.url),
|
|
||||||
);
|
|
||||||
initialTransactions = await transactionsResponse.json();
|
|
||||||
}
|
|
||||||
---
|
|
||||||
|
|
||||||
<BaseLayout title="Bank Transactions Dashboard">
|
|
||||||
<div class="dashboard-layout">
|
|
||||||
<Sidebar accounts={accounts} initialAccount={initialAccount} />
|
|
||||||
<MainContent
|
|
||||||
account={initialAccount}
|
|
||||||
transactions={initialTransactions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</BaseLayout>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Import store actions - done directly to avoid TypeScript import issues
|
|
||||||
import {
|
|
||||||
currentAccountId,
|
|
||||||
setTransactions,
|
|
||||||
loadTransactionsForAccount,
|
|
||||||
startEditingTransaction,
|
|
||||||
} from "@stores/transactionStore";
|
|
||||||
|
|
||||||
// Access server-rendered data which is available as globals
|
|
||||||
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 ---
|
|
||||||
const accountSelect = document.getElementById(
|
|
||||||
"account-select",
|
|
||||||
) as HTMLSelectElement | null;
|
|
||||||
const currentAccountNameSpan = document.getElementById(
|
|
||||||
"current-account-name",
|
|
||||||
) as HTMLSpanElement | null;
|
|
||||||
const addTransactionSection = document.getElementById(
|
|
||||||
"add-transaction-section",
|
|
||||||
) as HTMLElement | null;
|
|
||||||
const toggleAddTxnBtn = document.getElementById(
|
|
||||||
"toggle-add-txn-btn",
|
|
||||||
) as HTMLButtonElement | null;
|
|
||||||
|
|
||||||
console.log("Initial setup - Account:", initialAccountData);
|
|
||||||
console.log("Initial setup - Transactions:", initialTransactionsData);
|
|
||||||
|
|
||||||
// --- Helper Functions ---
|
|
||||||
async function fetchAccountDetails(accountId: string) {
|
|
||||||
console.log("Fetching details for account:", accountId);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/accounts/${accountId}`);
|
|
||||||
if (!response.ok)
|
|
||||||
throw new Error("Failed to fetch account details");
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching account:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Update UI Function ---
|
|
||||||
async function updateUIForAccount(accountId: string) {
|
|
||||||
console.log("Updating UI for account:", accountId);
|
|
||||||
|
|
||||||
// Update the store with the current account ID
|
|
||||||
currentAccountId.set(accountId);
|
|
||||||
|
|
||||||
// Only update the non-React part (header span)
|
|
||||||
currentAccountNameSpan?.classList.add("loading-inline");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const account = await fetchAccountDetails(accountId);
|
|
||||||
|
|
||||||
if (!account || !currentAccountNameSpan) {
|
|
||||||
console.error("Account data or header element not found!");
|
|
||||||
if (currentAccountNameSpan)
|
|
||||||
currentAccountNameSpan.textContent = "Error";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update header - use accountNumber instead of 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) {
|
|
||||||
console.error("Error updating account header:", error);
|
|
||||||
if (currentAccountNameSpan)
|
|
||||||
currentAccountNameSpan.textContent = "Error";
|
|
||||||
} finally {
|
|
||||||
currentAccountNameSpan?.classList.remove("loading-inline");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Transaction Actions ---
|
|
||||||
async function handleEditTransaction(txnId: string) {
|
|
||||||
console.log("Edit transaction requested:", txnId);
|
|
||||||
try {
|
|
||||||
const accountId = currentAccountId.get();
|
|
||||||
if (!accountId) {
|
|
||||||
console.error("No account selected for editing transaction");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/accounts/${accountId}/transactions`,
|
|
||||||
);
|
|
||||||
if (!response.ok)
|
|
||||||
throw new Error("Failed to fetch transactions for edit");
|
|
||||||
const transactions = await response.json();
|
|
||||||
const transaction = transactions.find(
|
|
||||||
(t: { id: string }) => t.id === txnId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!transaction) {
|
|
||||||
throw new Error("Transaction not found for editing");
|
|
||||||
}
|
|
||||||
|
|
||||||
startEditingTransaction(transaction);
|
|
||||||
|
|
||||||
// Manually expand the form section if it's collapsed
|
|
||||||
if (addTransactionSection?.classList.contains("collapsed")) {
|
|
||||||
addTransactionSection.classList.replace(
|
|
||||||
"collapsed",
|
|
||||||
"expanded",
|
|
||||||
);
|
|
||||||
toggleAddTxnBtn?.setAttribute("aria-expanded", "true");
|
|
||||||
addTransactionSection.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "nearest",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert(
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to load transaction for editing",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Event Listeners ---
|
|
||||||
if (accountSelect) {
|
|
||||||
accountSelect.addEventListener("change", (event: Event) => {
|
|
||||||
const target = event.target as HTMLSelectElement;
|
|
||||||
if (target?.value) {
|
|
||||||
updateUIForAccount(target.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("click", (event: Event) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (target?.classList?.contains("edit-btn")) {
|
|
||||||
const row = target.closest("[data-txn-id]") as HTMLElement;
|
|
||||||
if (row) {
|
|
||||||
const txnId = row.dataset.txnId;
|
|
||||||
if (txnId) handleEditTransaction(txnId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Initial Load ---
|
|
||||||
// Add the initial data to the page for client-side scripts to access
|
|
||||||
if (!document.getElementById("initial-account-data")) {
|
|
||||||
const accountDataEl = document.createElement("script");
|
|
||||||
accountDataEl.id = "initial-account-data";
|
|
||||||
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
|
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { Transaction } from '@types';
|
import type { Account, Transaction } from '@types';
|
||||||
import { atom } from 'nanostores';
|
import { atom } from 'nanostores';
|
||||||
|
|
||||||
// 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 account object
|
||||||
|
export const currentAccount = atom<Account | null>(null);
|
||||||
|
|
||||||
|
// Atom to hold all available accounts
|
||||||
|
export const allAccounts = atom<Account[]>([]);
|
||||||
|
|
||||||
// Atom to hold the current transactions
|
// Atom to hold the current transactions
|
||||||
export const currentTransactions = atom<Transaction[]>([]);
|
export const currentTransactions = atom<Transaction[]>([]);
|
||||||
|
|
||||||
@@ -82,9 +88,21 @@ export async function loadTransactionsForAccount(accountId: string) {
|
|||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
console.warn('No account ID provided, clearing transactions');
|
console.warn('No account ID provided, clearing transactions');
|
||||||
currentTransactions.set([]);
|
currentTransactions.set([]);
|
||||||
|
currentAccountId.set(null);
|
||||||
|
currentAccount.set(null);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the current account ID
|
||||||
|
currentAccountId.set(accountId);
|
||||||
|
|
||||||
|
// Find and set the current account from allAccounts
|
||||||
|
const accounts = allAccounts.get();
|
||||||
|
const account = accounts.find((acc) => acc.id === accountId);
|
||||||
|
if (account) {
|
||||||
|
currentAccount.set(account);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Fetching transactions from API for account: ${accountId}`);
|
console.log(`Fetching transactions from API for account: ${accountId}`);
|
||||||
const response = await fetch(`/api/accounts/${accountId}/transactions`);
|
const response = await fetch(`/api/accounts/${accountId}/transactions`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -110,6 +128,32 @@ export async function loadTransactionsForAccount(accountId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to load all accounts
|
||||||
|
export async function loadAccounts(accounts?: Account[]) {
|
||||||
|
if (accounts) {
|
||||||
|
// Use provided accounts
|
||||||
|
allAccounts.set(accounts);
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Fetching accounts from API');
|
||||||
|
const response = await fetch('/api/accounts');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch accounts: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchedAccounts: Account[] = await response.json();
|
||||||
|
console.log(`Loaded ${fetchedAccounts.length} accounts:`, fetchedAccounts);
|
||||||
|
|
||||||
|
allAccounts.set(fetchedAccounts);
|
||||||
|
return fetchedAccounts;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading accounts:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to create a new transaction
|
// Helper to create a new transaction
|
||||||
export async function createTransaction(transaction: Omit<Transaction, 'id'>) {
|
export async function createTransaction(transaction: Omit<Transaction, 'id'>) {
|
||||||
try {
|
try {
|
||||||
@@ -180,3 +224,38 @@ export async function updateTransaction(id: string, transaction: Partial<Transac
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to delete a transaction
|
||||||
|
export async function deleteTransaction(id: string) {
|
||||||
|
try {
|
||||||
|
console.log(`Deleting transaction ${id}`);
|
||||||
|
const response = await fetch(`/api/transactions/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||||
|
throw new Error(errorData.error || `Failed to delete transaction: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Transaction deleted successfully');
|
||||||
|
|
||||||
|
// Remove the transaction from the existing list
|
||||||
|
const currentList = currentTransactions.get();
|
||||||
|
const updatedList = currentList.filter((t) => t.id !== id);
|
||||||
|
currentTransactions.set(updatedList);
|
||||||
|
|
||||||
|
// Clear edit state if the deleted transaction was being edited
|
||||||
|
if (transactionToEdit.get()?.id === id) {
|
||||||
|
transactionToEdit.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger refresh to update other components
|
||||||
|
triggerRefresh();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting transaction:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
{
|
{
|
||||||
"extends": "astro/tsconfigs/strict",
|
|
||||||
"include": [".astro/types.d.ts", "**/*"],
|
|
||||||
"exclude": ["dist"],
|
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "react",
|
"allowImportingTsExtensions": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"],
|
"@/*": ["src/*"],
|
||||||
@@ -14,8 +25,9 @@
|
|||||||
"@pages/*": ["src/pages/*"],
|
"@pages/*": ["src/pages/*"],
|
||||||
"@styles/*": ["src/styles/*"],
|
"@styles/*": ["src/styles/*"],
|
||||||
"@stores/*": ["src/stores/*"],
|
"@stores/*": ["src/stores/*"],
|
||||||
"@utils": ["src/utils"],
|
"@utils": ["src/utils.ts"],
|
||||||
"@types": ["src/types.ts"]
|
"@types": ["src/types.ts"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
33
vite.config.ts
Normal file
33
vite.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { resolve } from 'node:path';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './src'),
|
||||||
|
'@components': resolve(__dirname, './src/components'),
|
||||||
|
'@layouts': resolve(__dirname, './src/layouts'),
|
||||||
|
'@data': resolve(__dirname, './src/data'),
|
||||||
|
'@pages': resolve(__dirname, './src/pages'),
|
||||||
|
'@styles': resolve(__dirname, './src/styles'),
|
||||||
|
'@stores': resolve(__dirname, './src/stores'),
|
||||||
|
'@utils': resolve(__dirname, './src/utils'),
|
||||||
|
'@types': resolve(__dirname, './src/types.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 4321,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist/client',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user