mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 21:52:40 +00:00
feat: make API URL configurable and improve code quality
- Add configurable API URL support via environment variables - Update nginx configuration with environment variable substitution - Create nginx template for dynamic proxy configuration - Update Docker configuration for environment variable handling - Fix hardcoded localhost:8000 references in error messages - Add proper TypeScript types for health check API - Format all code with Prettier for consistency - Update documentation with configuration instructions - Improve error messages to be environment-agnostic - Fix duplicate imports and type safety issues BREAKING: API URL is now configurable via VITE_API_URL (dev) and API_BACKEND_URL (prod)
This commit is contained in:
committed by
Elisiário Couto
parent
abf39abe74
commit
37949a4e1f
34
frontend/Dockerfile
Normal file
34
frontend/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm i
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy server configuration template
|
||||
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
||||
|
||||
# Set default API backend URL (can be overridden at runtime)
|
||||
ENV API_BACKEND_URL=http://leggend:8000
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -13,16 +13,18 @@ A modern React dashboard for the Leggen Open Banking CLI tool. This frontend pro
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- Leggen API server running on `localhost:8000`
|
||||
- Leggen API server running (configurable via environment variables)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Start the development server:**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
@@ -69,7 +71,7 @@ src/
|
||||
|
||||
## API Integration
|
||||
|
||||
The frontend connects to the Leggen API server running on `localhost:8000`. The API client handles:
|
||||
The frontend connects to the Leggen API server (configurable via environment variables). The API client handles:
|
||||
|
||||
- Account retrieval and management
|
||||
- Transaction fetching with filtering
|
||||
@@ -78,7 +80,27 @@ The frontend connects to the Leggen API server running on `localhost:8000`. The
|
||||
|
||||
## Configuration
|
||||
|
||||
The API base URL is configured in `src/lib/api.ts`. Update the `API_BASE_URL` constant if your Leggen server runs on a different port or host.
|
||||
### API URL Configuration
|
||||
|
||||
The frontend supports configurable API URLs through environment variables:
|
||||
|
||||
**Development:**
|
||||
|
||||
- Set `VITE_API_URL` to call external APIs during development
|
||||
- Example: `VITE_API_URL=https://staging-api.example.com npm run dev`
|
||||
|
||||
**Production:**
|
||||
|
||||
- Uses relative URLs (`/api/v1`) that nginx proxies to the backend
|
||||
- Configure nginx proxy target via `API_BACKEND_URL` environment variable
|
||||
- Default: `http://leggend:8000`
|
||||
|
||||
**Docker Compose:**
|
||||
|
||||
```bash
|
||||
# Override API backend URL
|
||||
API_BACKEND_URL=https://prod-api.example.com docker-compose up
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
33
frontend/default.conf.template
Normal file
33
frontend/default.conf.template
Normal file
@@ -0,0 +1,33 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend (configurable via API_BACKEND_URL env var)
|
||||
location /api/ {
|
||||
proxy_pass ${API_BACKEND_URL};
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { globalIgnores } from "eslint/config";
|
||||
|
||||
export default tseslint.config([
|
||||
globalIgnores(['dist']),
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactHooks.configs["recommended-latest"],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
@@ -20,4 +20,4 @@ export default tseslint.config([
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
289
frontend/package-lock.json
generated
289
frontend/package-lock.json
generated
@@ -2253,18 +2253,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
|
||||
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -2298,9 +2286,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.214",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.214.tgz",
|
||||
"integrity": "sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q==",
|
||||
"version": "1.5.215",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz",
|
||||
"integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
@@ -3114,15 +3102,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
|
||||
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
|
||||
"dev": true,
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
@@ -3216,257 +3201,6 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-darwin-arm64": "1.30.1",
|
||||
"lightningcss-darwin-x64": "1.30.1",
|
||||
"lightningcss-freebsd-x64": "1.30.1",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.1",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.1",
|
||||
"lightningcss-linux-arm64-musl": "1.30.1",
|
||||
"lightningcss-linux-x64-gnu": "1.30.1",
|
||||
"lightningcss-linux-x64-musl": "1.30.1",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.1",
|
||||
"lightningcss-win32-x64-msvc": "1.30.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
|
||||
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
|
||||
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
|
||||
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
|
||||
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
|
||||
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
|
||||
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
|
||||
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
|
||||
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
|
||||
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.30.1",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
|
||||
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -4443,15 +4177,6 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss/node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
|
||||
@@ -3,4 +3,4 @@ export default {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import Dashboard from "./components/Dashboard";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Building2,
|
||||
RefreshCw,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
import { formatCurrency, formatDate } from '../lib/utils';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
import type { Account, Balance } from '../types/api';
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { formatCurrency, formatDate } from "../lib/utils";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import type { Account, Balance } from "../types/api";
|
||||
|
||||
export default function AccountsOverview() {
|
||||
const {
|
||||
data: accounts,
|
||||
isLoading: accountsLoading,
|
||||
error: accountsError,
|
||||
refetch: refetchAccounts
|
||||
refetch: refetchAccounts,
|
||||
} = useQuery<Account[]>({
|
||||
queryKey: ['accounts'],
|
||||
queryKey: ["accounts"],
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
const {
|
||||
data: balances
|
||||
} = useQuery<Balance[]>({
|
||||
queryKey: ['balances'],
|
||||
const { data: balances } = useQuery<Balance[]>({
|
||||
queryKey: ["balances"],
|
||||
queryFn: () => apiClient.getBalances(),
|
||||
});
|
||||
|
||||
@@ -44,9 +42,12 @@ export default function AccountsOverview() {
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<div>
|
||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Failed to load accounts</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Failed to load accounts
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Unable to connect to the Leggen API. Make sure the server is running on localhost:8000.
|
||||
Unable to connect to the Leggen API. Please check your
|
||||
configuration and ensure the API server is running.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetchAccounts()}
|
||||
@@ -61,13 +62,15 @@ export default function AccountsOverview() {
|
||||
);
|
||||
}
|
||||
|
||||
const totalBalance = accounts?.reduce((sum, account) => {
|
||||
// Get the first available balance from the balances array
|
||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
return sum + primaryBalance;
|
||||
}, 0) || 0;
|
||||
const totalBalance =
|
||||
accounts?.reduce((sum, account) => {
|
||||
// Get the first available balance from the balances array
|
||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
return sum + primaryBalance;
|
||||
}, 0) || 0;
|
||||
const totalAccounts = accounts?.length || 0;
|
||||
const uniqueBanks = new Set(accounts?.map(acc => acc.institution_id) || []).size;
|
||||
const uniqueBanks = new Set(accounts?.map((acc) => acc.institution_id) || [])
|
||||
.size;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -90,8 +93,12 @@ export default function AccountsOverview() {
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Total Accounts</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{totalAccounts}</p>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Total Accounts
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{totalAccounts}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 rounded-full">
|
||||
<CreditCard className="h-6 w-6 text-blue-600" />
|
||||
@@ -102,7 +109,9 @@ export default function AccountsOverview() {
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">Connected Banks</p>
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
Connected Banks
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{uniqueBanks}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-purple-100 rounded-full">
|
||||
@@ -116,70 +125,87 @@ export default function AccountsOverview() {
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Bank Accounts</h3>
|
||||
<p className="text-sm text-gray-600">Manage your connected bank accounts</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Manage your connected bank accounts
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!accounts || accounts.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<CreditCard className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No accounts found</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No accounts found
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Connect your first bank account to get started with Leggen.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{accounts.map((account) => {
|
||||
// Get balance from account's balances array or fallback to balances query
|
||||
const accountBalance = account.balances?.[0];
|
||||
const fallbackBalance = balances?.find(b => b.account_id === account.id);
|
||||
const balance = accountBalance?.amount || fallbackBalance?.balance_amount || 0;
|
||||
const currency = accountBalance?.currency || fallbackBalance?.currency || account.currency || 'EUR';
|
||||
const isPositive = balance >= 0;
|
||||
{accounts.map((account) => {
|
||||
// Get balance from account's balances array or fallback to balances query
|
||||
const accountBalance = account.balances?.[0];
|
||||
const fallbackBalance = balances?.find(
|
||||
(b) => b.account_id === account.id,
|
||||
);
|
||||
const balance =
|
||||
accountBalance?.amount || fallbackBalance?.balance_amount || 0;
|
||||
const currency =
|
||||
accountBalance?.currency ||
|
||||
fallbackBalance?.currency ||
|
||||
account.currency ||
|
||||
"EUR";
|
||||
const isPositive = balance >= 0;
|
||||
|
||||
return (
|
||||
<div key={account.id} className="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-gray-100 rounded-full">
|
||||
<Building2 className="h-6 w-6 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{account.name || 'Unnamed Account'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{account.institution_id} • {account.status}
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
IBAN: {account.iban}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={account.id}
|
||||
className="p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-gray-100 rounded-full">
|
||||
<Building2 className="h-6 w-6 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{account.name || "Unnamed Account"}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{account.institution_id} • {account.status}
|
||||
</p>
|
||||
{account.iban && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
IBAN: {account.iban}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<p className={`text-lg font-semibold ${
|
||||
isPositive ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{formatCurrency(balance, currency)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Updated {formatDate(account.last_accessed || account.created)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="text-right">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||
)}
|
||||
<p
|
||||
className={`text-lg font-semibold ${
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(balance, currency)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
Updated{" "}
|
||||
{formatDate(account.last_accessed || account.created)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CreditCard,
|
||||
TrendingUp,
|
||||
@@ -11,60 +11,63 @@ import {
|
||||
BarChart3,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Bell
|
||||
} from 'lucide-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
import AccountsOverview from './AccountsOverview';
|
||||
import TransactionsList from './TransactionsList';
|
||||
import Notifications from './Notifications';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { Account } from '../types/api';
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import AccountsOverview from "./AccountsOverview";
|
||||
import TransactionsList from "./TransactionsList";
|
||||
import Notifications from "./Notifications";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { Account } from "../types/api";
|
||||
|
||||
type TabType = 'overview' | 'transactions' | 'analytics' | 'notifications';
|
||||
type TabType = "overview" | "transactions" | "analytics" | "notifications";
|
||||
|
||||
export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('overview');
|
||||
const [activeTab, setActiveTab] = useState<TabType>("overview");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const { data: accounts } = useQuery<Account[]>({
|
||||
queryKey: ['accounts'],
|
||||
queryKey: ["accounts"],
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
const { data: healthStatus, isLoading: healthLoading, isError: healthError } = useQuery({
|
||||
queryKey: ['health'],
|
||||
const {
|
||||
data: healthStatus,
|
||||
isLoading: healthLoading,
|
||||
isError: healthError,
|
||||
} = useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/v1/health`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Health check failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
return await apiClient.getHealth();
|
||||
},
|
||||
refetchInterval: 30000, // Check every 30 seconds
|
||||
retry: 3,
|
||||
});
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Overview', icon: Home, id: 'overview' as TabType },
|
||||
{ name: 'Transactions', icon: List, id: 'transactions' as TabType },
|
||||
{ name: 'Analytics', icon: BarChart3, id: 'analytics' as TabType },
|
||||
{ name: 'Notifications', icon: Bell, id: 'notifications' as TabType },
|
||||
{ name: "Overview", icon: Home, id: "overview" as TabType },
|
||||
{ name: "Transactions", icon: List, id: "transactions" as TabType },
|
||||
{ name: "Analytics", icon: BarChart3, id: "analytics" as TabType },
|
||||
{ name: "Notifications", icon: Bell, id: "notifications" as TabType },
|
||||
];
|
||||
|
||||
const totalBalance = accounts?.reduce((sum, account) => {
|
||||
// Get the first available balance from the balances array
|
||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
return sum + primaryBalance;
|
||||
}, 0) || 0;
|
||||
const totalBalance =
|
||||
accounts?.reduce((sum, account) => {
|
||||
// Get the first available balance from the balances array
|
||||
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||
return sum + primaryBalance;
|
||||
}, 0) || 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
{/* Sidebar */}
|
||||
<div className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
|
||||
sidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<CreditCard className="h-8 w-8 text-blue-600" />
|
||||
@@ -91,7 +94,7 @@ export default function Dashboard() {
|
||||
"flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors",
|
||||
activeTab === item.id
|
||||
? "bg-blue-100 text-blue-700"
|
||||
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
||||
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<item.icon className="mr-3 h-5 w-5" />
|
||||
@@ -105,13 +108,15 @@ export default function Dashboard() {
|
||||
<div className="px-6 py-4 border-t border-gray-200 mt-auto">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-600">Total Balance</span>
|
||||
<span className="text-sm font-medium text-gray-600">
|
||||
Total Balance
|
||||
</span>
|
||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
||||
{new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
{new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(totalBalance)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
@@ -142,7 +147,7 @@ export default function Dashboard() {
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-gray-900 lg:ml-0 ml-4">
|
||||
{navigation.find(item => item.id === activeTab)?.name}
|
||||
{navigation.find((item) => item.id === activeTab)?.name}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -152,7 +157,7 @@ export default function Dashboard() {
|
||||
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
|
||||
<span className="text-sm text-gray-600">Checking...</span>
|
||||
</>
|
||||
) : healthError || !healthStatus?.success ? (
|
||||
) : healthError || healthStatus?.status !== "healthy" ? (
|
||||
<>
|
||||
<WifiOff className="h-4 w-4 text-red-500" />
|
||||
<span className="text-sm text-red-500">Disconnected</span>
|
||||
@@ -171,15 +176,19 @@ export default function Dashboard() {
|
||||
{/* Main content area */}
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<ErrorBoundary>
|
||||
{activeTab === 'overview' && <AccountsOverview />}
|
||||
{activeTab === 'transactions' && <TransactionsList />}
|
||||
{activeTab === 'analytics' && (
|
||||
{activeTab === "overview" && <AccountsOverview />}
|
||||
{activeTab === "transactions" && <TransactionsList />}
|
||||
{activeTab === "analytics" && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Analytics</h3>
|
||||
<p className="text-gray-600">Analytics dashboard coming soon...</p>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Analytics
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Analytics dashboard coming soon...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'notifications' && <Notifications />}
|
||||
{activeTab === "notifications" && <Notifications />}
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component } from 'react';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { Component } from "react";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -24,7 +24,7 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
console.error("ErrorBoundary caught an error:", error, errorInfo);
|
||||
this.setState({ error, errorInfo });
|
||||
}
|
||||
|
||||
@@ -43,9 +43,12 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<div>
|
||||
<AlertTriangle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Something went wrong</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Something went wrong
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
An error occurred while rendering this component. Please try refreshing or check the console for more details.
|
||||
An error occurred while rendering this component. Please try
|
||||
refreshing or check the console for more details.
|
||||
</p>
|
||||
|
||||
{this.state.error && (
|
||||
@@ -55,7 +58,9 @@ class ErrorBoundary extends Component<Props, State> {
|
||||
</p>
|
||||
{this.state.error.stack && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-sm text-red-600 cursor-pointer">Stack trace</summary>
|
||||
<summary className="text-sm text-red-600 cursor-pointer">
|
||||
Stack trace
|
||||
</summary>
|
||||
<pre className="text-xs text-red-700 mt-1 whitespace-pre-wrap">
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function LoadingSpinner({ message = 'Loading...' }: LoadingSpinnerProps) {
|
||||
export default function LoadingSpinner({
|
||||
message = "Loading...",
|
||||
}: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Bell,
|
||||
MessageSquare,
|
||||
@@ -9,24 +9,26 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Settings,
|
||||
TestTube
|
||||
} from 'lucide-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
import type { NotificationSettings, NotificationService } from '../types/api';
|
||||
TestTube,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import type { NotificationSettings, NotificationService } from "../types/api";
|
||||
|
||||
export default function Notifications() {
|
||||
const [testService, setTestService] = useState('');
|
||||
const [testMessage, setTestMessage] = useState('Test notification from Leggen');
|
||||
const [testService, setTestService] = useState("");
|
||||
const [testMessage, setTestMessage] = useState(
|
||||
"Test notification from Leggen",
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: settings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
refetch: refetchSettings
|
||||
refetch: refetchSettings,
|
||||
} = useQuery<NotificationSettings>({
|
||||
queryKey: ['notificationSettings'],
|
||||
queryKey: ["notificationSettings"],
|
||||
queryFn: apiClient.getNotificationSettings,
|
||||
});
|
||||
|
||||
@@ -34,9 +36,9 @@ export default function Notifications() {
|
||||
data: services,
|
||||
isLoading: servicesLoading,
|
||||
error: servicesError,
|
||||
refetch: refetchServices
|
||||
refetch: refetchServices,
|
||||
} = useQuery<NotificationService[]>({
|
||||
queryKey: ['notificationServices'],
|
||||
queryKey: ["notificationServices"],
|
||||
queryFn: apiClient.getNotificationServices,
|
||||
});
|
||||
|
||||
@@ -44,18 +46,18 @@ export default function Notifications() {
|
||||
mutationFn: apiClient.testNotification,
|
||||
onSuccess: () => {
|
||||
// Could show a success toast here
|
||||
console.log('Test notification sent successfully');
|
||||
console.log("Test notification sent successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to send test notification:', error);
|
||||
console.error("Failed to send test notification:", error);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteServiceMutation = useMutation({
|
||||
mutationFn: apiClient.deleteNotificationService,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationSettings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationServices'] });
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -73,9 +75,12 @@ export default function Notifications() {
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<div>
|
||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Failed to load notifications</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Failed to load notifications
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Unable to connect to the Leggen API. Make sure the server is running on localhost:8000.
|
||||
Unable to connect to the Leggen API. Please check your
|
||||
configuration and ensure the API server is running.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -103,7 +108,11 @@ export default function Notifications() {
|
||||
};
|
||||
|
||||
const handleDeleteService = (serviceName: string) => {
|
||||
if (confirm(`Are you sure you want to delete the ${serviceName} notification service?`)) {
|
||||
if (
|
||||
confirm(
|
||||
`Are you sure you want to delete the ${serviceName} notification service?`,
|
||||
)
|
||||
) {
|
||||
deleteServiceMutation.mutate(serviceName);
|
||||
}
|
||||
};
|
||||
@@ -114,7 +123,9 @@ export default function Notifications() {
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<TestTube className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Test Notifications</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Test Notifications
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -130,7 +141,7 @@ export default function Notifications() {
|
||||
<option value="">Select a service...</option>
|
||||
{services?.map((service) => (
|
||||
<option key={service.name} value={service.name}>
|
||||
{service.name} {service.enabled ? '(Enabled)' : '(Disabled)'}
|
||||
{service.name} {service.enabled ? "(Enabled)" : "(Disabled)"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -157,7 +168,7 @@ export default function Notifications() {
|
||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
{testMutation.isPending ? 'Sending...' : 'Send Test Notification'}
|
||||
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,15 +178,21 @@ export default function Notifications() {
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bell className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Notification Services</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Notification Services
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">Manage your notification services</p>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Manage your notification services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!services || services.length === 0 ? (
|
||||
<div className="p-6 text-center">
|
||||
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No notification services configured</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No notification services configured
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
Configure notification services in your backend to receive alerts.
|
||||
</p>
|
||||
@@ -183,13 +200,16 @@ export default function Notifications() {
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{services.map((service) => (
|
||||
<div key={service.name} className="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div
|
||||
key={service.name}
|
||||
className="p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-gray-100 rounded-full">
|
||||
{service.name.toLowerCase().includes('discord') ? (
|
||||
{service.name.toLowerCase().includes("discord") ? (
|
||||
<MessageSquare className="h-6 w-6 text-gray-600" />
|
||||
) : service.name.toLowerCase().includes('telegram') ? (
|
||||
) : service.name.toLowerCase().includes("telegram") ? (
|
||||
<Send className="h-6 w-6 text-gray-600" />
|
||||
) : (
|
||||
<Bell className="h-6 w-6 text-gray-600" />
|
||||
@@ -200,24 +220,28 @@ export default function Notifications() {
|
||||
{service.name}
|
||||
</h4>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
service.enabled
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
service.enabled
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-red-100 text-red-800"
|
||||
}`}
|
||||
>
|
||||
{service.enabled ? (
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{service.enabled ? 'Enabled' : 'Disabled'}
|
||||
{service.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
service.configured
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{service.configured ? 'Configured' : 'Not Configured'}
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
service.configured
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}
|
||||
>
|
||||
{service.configured ? "Configured" : "Not Configured"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,13 +268,17 @@ export default function Notifications() {
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center space-x-2 mb-4">
|
||||
<Settings className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Notification Settings</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Notification Settings
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{settings && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Filters</h4>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
||||
Filters
|
||||
</h4>
|
||||
<div className="bg-gray-50 rounded-md p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -259,9 +287,8 @@ export default function Notifications() {
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">
|
||||
{settings.filters.case_insensitive.length > 0
|
||||
? settings.filters.case_insensitive.join(', ')
|
||||
: 'None'
|
||||
}
|
||||
? settings.filters.case_insensitive.join(", ")
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -269,10 +296,10 @@ export default function Notifications() {
|
||||
Case Sensitive Filters
|
||||
</label>
|
||||
<p className="text-sm text-gray-900">
|
||||
{settings.filters.case_sensitive && settings.filters.case_sensitive.length > 0
|
||||
? settings.filters.case_sensitive.join(', ')
|
||||
: 'None'
|
||||
}
|
||||
{settings.filters.case_sensitive &&
|
||||
settings.filters.case_sensitive.length > 0
|
||||
? settings.filters.case_sensitive.join(", ")
|
||||
: "None"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,7 +307,10 @@ export default function Notifications() {
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>Configure notification settings through your backend API to customize filters and service configurations.</p>
|
||||
<p>
|
||||
Configure notification settings through your backend API to
|
||||
customize filters and service configurations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Filter,
|
||||
Search,
|
||||
@@ -8,24 +8,22 @@ import {
|
||||
Calendar,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { apiClient } from '../lib/api';
|
||||
import { formatCurrency, formatDate } from '../lib/utils';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
import type { Account, Transaction } from '../types/api';
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { apiClient } from "../lib/api";
|
||||
import { formatCurrency, formatDate } from "../lib/utils";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import type { Account, Transaction } from "../types/api";
|
||||
|
||||
export default function TransactionsList() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const {
|
||||
data: accounts
|
||||
} = useQuery<Account[]>({
|
||||
queryKey: ['accounts'],
|
||||
const { data: accounts } = useQuery<Account[]>({
|
||||
queryKey: ["accounts"],
|
||||
queryFn: apiClient.getAccounts,
|
||||
});
|
||||
|
||||
@@ -33,29 +31,34 @@ export default function TransactionsList() {
|
||||
data: transactions,
|
||||
isLoading: transactionsLoading,
|
||||
error: transactionsError,
|
||||
refetch: refetchTransactions
|
||||
refetch: refetchTransactions,
|
||||
} = useQuery<Transaction[]>({
|
||||
queryKey: ['transactions', selectedAccount, startDate, endDate],
|
||||
queryFn: () => apiClient.getTransactions({
|
||||
accountId: selectedAccount || undefined,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
queryKey: ["transactions", selectedAccount, startDate, endDate],
|
||||
queryFn: () =>
|
||||
apiClient.getTransactions({
|
||||
accountId: selectedAccount || undefined,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const filteredTransactions = (transactions || []).filter(transaction => {
|
||||
const filteredTransactions = (transactions || []).filter((transaction) => {
|
||||
// Additional validation (API client should have already filtered out invalid ones)
|
||||
if (!transaction || !transaction.account_id) {
|
||||
console.warn('Invalid transaction found after API filtering:', transaction);
|
||||
console.warn(
|
||||
"Invalid transaction found after API filtering:",
|
||||
transaction,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const description = transaction.description || '';
|
||||
const creditorName = transaction.creditor_name || '';
|
||||
const debtorName = transaction.debtor_name || '';
|
||||
const reference = transaction.reference || '';
|
||||
const description = transaction.description || "";
|
||||
const creditorName = transaction.creditor_name || "";
|
||||
const debtorName = transaction.debtor_name || "";
|
||||
const reference = transaction.reference || "";
|
||||
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
const matchesSearch =
|
||||
searchTerm === "" ||
|
||||
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
@@ -65,13 +68,14 @@ export default function TransactionsList() {
|
||||
});
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
setSelectedAccount('');
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setSearchTerm("");
|
||||
setSelectedAccount("");
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchTerm || selectedAccount || startDate || endDate;
|
||||
const hasActiveFilters =
|
||||
searchTerm || selectedAccount || startDate || endDate;
|
||||
|
||||
if (transactionsLoading) {
|
||||
return (
|
||||
@@ -87,7 +91,9 @@ export default function TransactionsList() {
|
||||
<div className="flex items-center justify-center text-center">
|
||||
<div>
|
||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Failed to load transactions</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
Failed to load transactions
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Unable to fetch transactions from the Leggen API.
|
||||
</p>
|
||||
@@ -163,11 +169,12 @@ export default function TransactionsList() {
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">All accounts</option>
|
||||
{accounts?.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name || 'Unnamed Account'} ({account.institution_id})
|
||||
</option>
|
||||
))}
|
||||
{accounts?.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name || "Unnamed Account"} (
|
||||
{account.institution_id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -209,10 +216,11 @@ export default function TransactionsList() {
|
||||
{/* Results Summary */}
|
||||
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {filteredTransactions.length} transaction{filteredTransactions.length !== 1 ? 's' : ''}
|
||||
Showing {filteredTransactions.length} transaction
|
||||
{filteredTransactions.length !== 1 ? "s" : ""}
|
||||
{selectedAccount && accounts && (
|
||||
<span className="ml-1">
|
||||
for {accounts.find(acc => acc.id === selectedAccount)?.name}
|
||||
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -225,28 +233,39 @@ export default function TransactionsList() {
|
||||
<div className="text-gray-400 mb-4">
|
||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No transactions found</h3>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
No transactions found
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
{hasActiveFilters ?
|
||||
"Try adjusting your filters to see more results." :
|
||||
"No transactions are available for the selected criteria."
|
||||
}
|
||||
{hasActiveFilters
|
||||
? "Try adjusting your filters to see more results."
|
||||
: "No transactions are available for the selected criteria."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
||||
{filteredTransactions.map((transaction) => {
|
||||
const account = accounts?.find(acc => acc.id === transaction.account_id);
|
||||
const account = accounts?.find(
|
||||
(acc) => acc.id === transaction.account_id,
|
||||
);
|
||||
const isPositive = transaction.amount > 0;
|
||||
|
||||
return (
|
||||
<div key={transaction.internal_transaction_id || `${transaction.account_id}-${transaction.date}-${transaction.amount}`} className="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div
|
||||
key={
|
||||
transaction.internal_transaction_id ||
|
||||
`${transaction.account_id}-${transaction.date}-${transaction.amount}`
|
||||
}
|
||||
className="p-6 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className={`p-2 rounded-full ${
|
||||
isPositive ? 'bg-green-100' : 'bg-red-100'
|
||||
}`}>
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
isPositive ? "bg-green-100" : "bg-red-100"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? (
|
||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
@@ -259,15 +278,20 @@ export default function TransactionsList() {
|
||||
{transaction.description}
|
||||
</h4>
|
||||
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
{account && (
|
||||
<p>{account.name || 'Unnamed Account'} • {account.institution_id}</p>
|
||||
)}
|
||||
|
||||
{(transaction.creditor_name || transaction.debtor_name) && (
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
{account && (
|
||||
<p>
|
||||
{isPositive ? 'From: ' : 'To: '}
|
||||
{transaction.creditor_name || transaction.debtor_name}
|
||||
{account.name || "Unnamed Account"} •{" "}
|
||||
{account.institution_id}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(transaction.creditor_name ||
|
||||
transaction.debtor_name) && (
|
||||
<p>
|
||||
{isPositive ? "From: " : "To: "}
|
||||
{transaction.creditor_name ||
|
||||
transaction.debtor_name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -284,19 +308,25 @@ export default function TransactionsList() {
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4">
|
||||
<p className={`text-lg font-semibold ${
|
||||
isPositive ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{isPositive ? '+' : ''}{formatCurrency(transaction.amount, transaction.currency)}
|
||||
<p
|
||||
className={`text-lg font-semibold ${
|
||||
isPositive ? "text-green-600" : "text-red-600"
|
||||
}`}
|
||||
>
|
||||
{isPositive ? "+" : ""}
|
||||
{formatCurrency(transaction.amount, transaction.currency)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{transaction.date ? formatDate(transaction.date) : 'No date'}
|
||||
{transaction.date
|
||||
? formatDate(transaction.date)
|
||||
: "No date"}
|
||||
</p>
|
||||
{transaction.booking_date && transaction.booking_date !== transaction.date && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Booked: {formatDate(transaction.booking_date)}
|
||||
</p>
|
||||
)}
|
||||
{transaction.booking_date &&
|
||||
transaction.booking_date !== transaction.date && (
|
||||
<p className="text-xs text-gray-400">
|
||||
Booked: {formatDate(transaction.booking_date)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import axios from 'axios';
|
||||
import type { Account, Transaction, Balance, ApiResponse, NotificationSettings, NotificationTest, NotificationService, NotificationServicesResponse } from '../types/api';
|
||||
import axios from "axios";
|
||||
import type {
|
||||
Account,
|
||||
Transaction,
|
||||
Balance,
|
||||
ApiResponse,
|
||||
NotificationSettings,
|
||||
NotificationTest,
|
||||
NotificationService,
|
||||
NotificationServicesResponse,
|
||||
HealthData,
|
||||
} from "../types/api";
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
|
||||
// Use VITE_API_URL for development, relative URLs for production
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
export const apiClient = {
|
||||
// Get all accounts
|
||||
getAccounts: async (): Promise<Account[]> => {
|
||||
const response = await api.get<ApiResponse<Account[]>>('/accounts');
|
||||
const response = await api.get<ApiResponse<Account[]>>("/accounts");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
@@ -25,13 +36,15 @@ export const apiClient = {
|
||||
|
||||
// Get all balances
|
||||
getBalances: async (): Promise<Balance[]> => {
|
||||
const response = await api.get<ApiResponse<Balance[]>>('/balances');
|
||||
const response = await api.get<ApiResponse<Balance[]>>("/balances");
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get balances for specific account
|
||||
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
|
||||
const response = await api.get<ApiResponse<Balance[]>>(`/accounts/${accountId}/balances`);
|
||||
const response = await api.get<ApiResponse<Balance[]>>(
|
||||
`/accounts/${accountId}/balances`,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
@@ -46,43 +59,57 @@ export const apiClient = {
|
||||
}): Promise<Transaction[]> => {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params?.accountId) queryParams.append('account_id', params.accountId);
|
||||
if (params?.startDate) queryParams.append('start_date', params.startDate);
|
||||
if (params?.endDate) queryParams.append('end_date', params.endDate);
|
||||
if (params?.page) queryParams.append('page', params.page.toString());
|
||||
if (params?.perPage) queryParams.append('per_page', params.perPage.toString());
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
if (params?.accountId) queryParams.append("account_id", params.accountId);
|
||||
if (params?.startDate) queryParams.append("start_date", params.startDate);
|
||||
if (params?.endDate) queryParams.append("end_date", params.endDate);
|
||||
if (params?.page) queryParams.append("page", params.page.toString());
|
||||
if (params?.perPage)
|
||||
queryParams.append("per_page", params.perPage.toString());
|
||||
if (params?.search) queryParams.append("search", params.search);
|
||||
|
||||
const response = await api.get<ApiResponse<Transaction[]>>(`/transactions?${queryParams.toString()}`);
|
||||
const response = await api.get<ApiResponse<Transaction[]>>(
|
||||
`/transactions?${queryParams.toString()}`,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get transaction by ID
|
||||
getTransaction: async (id: string): Promise<Transaction> => {
|
||||
const response = await api.get<ApiResponse<Transaction>>(`/transactions/${id}`);
|
||||
const response = await api.get<ApiResponse<Transaction>>(
|
||||
`/transactions/${id}`,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Get notification settings
|
||||
getNotificationSettings: async (): Promise<NotificationSettings> => {
|
||||
const response = await api.get<ApiResponse<NotificationSettings>>('/notifications/settings');
|
||||
const response = await api.get<ApiResponse<NotificationSettings>>(
|
||||
"/notifications/settings",
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Update notification settings
|
||||
updateNotificationSettings: async (settings: NotificationSettings): Promise<NotificationSettings> => {
|
||||
const response = await api.put<ApiResponse<NotificationSettings>>('/notifications/settings', settings);
|
||||
updateNotificationSettings: async (
|
||||
settings: NotificationSettings,
|
||||
): Promise<NotificationSettings> => {
|
||||
const response = await api.put<ApiResponse<NotificationSettings>>(
|
||||
"/notifications/settings",
|
||||
settings,
|
||||
);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Test notification
|
||||
testNotification: async (test: NotificationTest): Promise<void> => {
|
||||
await api.post('/notifications/test', test);
|
||||
await api.post("/notifications/test", test);
|
||||
},
|
||||
|
||||
// Get notification services
|
||||
getNotificationServices: async (): Promise<NotificationService[]> => {
|
||||
const response = await api.get<ApiResponse<NotificationServicesResponse>>('/notifications/services');
|
||||
const response = await api.get<ApiResponse<NotificationServicesResponse>>(
|
||||
"/notifications/services",
|
||||
);
|
||||
// Convert object to array format
|
||||
const servicesData = response.data.data;
|
||||
return Object.values(servicesData);
|
||||
@@ -92,6 +119,12 @@ export const apiClient = {
|
||||
deleteNotificationService: async (service: string): Promise<void> => {
|
||||
await api.delete(`/notifications/settings/${service}`);
|
||||
},
|
||||
|
||||
// Health check
|
||||
getHealth: async (): Promise<HealthData> => {
|
||||
const response = await api.get<ApiResponse<HealthData>>("/health");
|
||||
return response.data.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default apiClient;
|
||||
|
||||
@@ -1,58 +1,62 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency: string = 'EUR'): string {
|
||||
export function formatCurrency(
|
||||
amount: number,
|
||||
currency: string = "EUR",
|
||||
): string {
|
||||
// Validate currency code - must be 3 letters and a valid ISO 4217 code
|
||||
const validCurrency = currency && /^[A-Z]{3}$/.test(currency) ? currency : 'EUR';
|
||||
const validCurrency =
|
||||
currency && /^[A-Z]{3}$/.test(currency) ? currency : "EUR";
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: validCurrency,
|
||||
}).format(amount);
|
||||
} catch {
|
||||
// Fallback if currency is still invalid
|
||||
console.warn(`Invalid currency code: ${currency}, falling back to EUR`);
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
} catch {
|
||||
// Fallback if currency is still invalid
|
||||
console.warn(`Invalid currency code: ${currency}, falling back to EUR`);
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(amount);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(date: string): string {
|
||||
if (!date) return 'No date';
|
||||
if (!date) return "No date";
|
||||
|
||||
const parsedDate = new Date(date);
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
console.warn('Invalid date string:', date);
|
||||
return 'Invalid date';
|
||||
console.warn("Invalid date string:", date);
|
||||
return "Invalid date";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}).format(parsedDate);
|
||||
}
|
||||
|
||||
export function formatDateTime(date: string): string {
|
||||
if (!date) return 'No date';
|
||||
if (!date) return "No date";
|
||||
|
||||
const parsedDate = new Date(date);
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
console.warn('Invalid date string:', date);
|
||||
return 'Invalid date';
|
||||
console.warn("Invalid date string:", date);
|
||||
return "Invalid date";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(parsedDate);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -125,3 +125,11 @@ export interface NotificationService {
|
||||
export interface NotificationServicesResponse {
|
||||
[serviceName: string]: NotificationService;
|
||||
}
|
||||
|
||||
// Health check response data
|
||||
export interface HealthData {
|
||||
status: string;
|
||||
config_loaded?: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
plugins: [require("@tailwindcss/forms")],
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user