mirror of
https://github.com/elisiariocouto/leggen.git
synced 2025-12-13 23:12:16 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3352e110b8 | ||
|
|
74a700ff87 | ||
|
|
66db34c712 | ||
|
|
eb27f19196 | ||
|
|
969776fb53 | ||
|
|
077e2bb1ad | ||
|
|
da98b7b2b7 | ||
|
|
2467cb2f5a | ||
|
|
5ae3a51d81 | ||
|
|
d09cf6d04c | ||
|
|
2c6e099596 | ||
|
|
990d0295b3 | ||
|
|
318ca517f7 | ||
|
|
0e645d9bae | ||
|
|
d51aa9429e | ||
|
|
c8f0a103c6 | ||
|
|
5987a759b8 | ||
|
|
6bfbed8fb6 | ||
|
|
b7e4ec4a1b | ||
|
|
35b6d98e6a | ||
|
|
3e248f95a8 | ||
|
|
e136fc4b75 | ||
|
|
692bee574e | ||
|
|
482f16c77e | ||
|
|
c6ac4455f8 | ||
|
|
ac0fedd8b2 | ||
|
|
06cf02f43f | ||
|
|
23aa8b08d4 | ||
|
|
2b69b1e27b | ||
|
|
4dec8113fe | ||
|
|
28534e97c0 | ||
|
|
43b6f32145 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -162,3 +162,5 @@ docker-compose.dev.yml
|
|||||||
nocodb/
|
nocodb/
|
||||||
sql/
|
sql/
|
||||||
leggen.db
|
leggen.db
|
||||||
|
*.db
|
||||||
|
config.toml
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
name: Static type check with mypy
|
name: Static type check with mypy
|
||||||
entry: uv run mypy leggen leggend --check-untyped-defs
|
entry: uv run mypy leggen --check-untyped-defs
|
||||||
files: "^leggen(d)?/.*"
|
files: "^leggen/.*"
|
||||||
language: "system"
|
language: "system"
|
||||||
types: ["python"]
|
types: ["python"]
|
||||||
always_run: true
|
always_run: true
|
||||||
|
|||||||
68
AGENTS.md
68
AGENTS.md
@@ -1,5 +1,55 @@
|
|||||||
# Agent Guidelines for Leggen
|
# Agent Guidelines for Leggen
|
||||||
|
|
||||||
|
## Quick Setup for Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- **uv** must be installed for Python dependency management (can be installed via `pip install uv`)
|
||||||
|
- **Configuration file**: Copy `config.example.toml` to `config.toml` before running any commands:
|
||||||
|
```bash
|
||||||
|
cp config.example.toml config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generate Mock Database
|
||||||
|
The leggen CLI provides a command to generate a mock database for testing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate sample database with default settings (3 accounts, 50 transactions each)
|
||||||
|
uv run leggen --config config.toml generate_sample_db --database /path/to/test.db --force
|
||||||
|
|
||||||
|
# Custom configuration
|
||||||
|
uv run leggen --config config.toml generate_sample_db --database ./test-data.db --accounts 5 --transactions 100 --force
|
||||||
|
```
|
||||||
|
|
||||||
|
The command outputs instructions for setting the required environment variable to use the generated database.
|
||||||
|
|
||||||
|
### Start the API Server
|
||||||
|
1. Install uv if not already installed: `pip install uv`
|
||||||
|
2. Set the database environment variable to point to your generated mock database:
|
||||||
|
```bash
|
||||||
|
export LEGGEN_DATABASE_PATH=/path/to/your/generated/database.db
|
||||||
|
```
|
||||||
|
3. Ensure the API can find the configuration file (choose one):
|
||||||
|
```bash
|
||||||
|
# Option 1: Copy config to the expected location
|
||||||
|
mkdir -p ~/.config/leggen && cp config.toml ~/.config/leggen/config.toml
|
||||||
|
|
||||||
|
# Option 2: Set environment variable to current config file
|
||||||
|
export LEGGEN_CONFIG_FILE=./config.toml
|
||||||
|
```
|
||||||
|
4. Start the API server:
|
||||||
|
```bash
|
||||||
|
uv run leggen server
|
||||||
|
```
|
||||||
|
- For development mode with auto-reload: `uv run leggen server --reload`
|
||||||
|
- API will be available at `http://localhost:8000` with docs at `http://localhost:8000/docs`
|
||||||
|
|
||||||
|
### Start the Frontend
|
||||||
|
1. Navigate to the frontend directory: `cd frontend`
|
||||||
|
2. Install npm dependencies: `npm install`
|
||||||
|
3. Start the development server: `npm run dev`
|
||||||
|
- Frontend will be available at `http://localhost:3000`
|
||||||
|
- The frontend is configured to connect to the API at `http://localhost:8000/api/v1`
|
||||||
|
|
||||||
## Build/Lint/Test Commands
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
### Frontend (React/TypeScript)
|
### Frontend (React/TypeScript)
|
||||||
@@ -10,7 +60,7 @@
|
|||||||
### Backend (Python)
|
### Backend (Python)
|
||||||
- **Lint**: `uv run ruff check .`
|
- **Lint**: `uv run ruff check .`
|
||||||
- **Format**: `uv run ruff format .`
|
- **Format**: `uv run ruff format .`
|
||||||
- **Type check**: `uv run mypy leggen leggend --check-untyped-defs`
|
- **Type check**: `uv run mypy leggen --check-untyped-defs`
|
||||||
- **All checks**: `uv run pre-commit run --all-files`
|
- **All checks**: `uv run pre-commit run --all-files`
|
||||||
- **Run all tests**: `uv run pytest`
|
- **Run all tests**: `uv run pytest`
|
||||||
- **Run single test**: `uv run pytest tests/unit/test_api_accounts.py::TestAccountsAPI::test_get_all_accounts_success -v`
|
- **Run single test**: `uv run pytest tests/unit/test_api_accounts.py::TestAccountsAPI::test_get_all_accounts_success -v`
|
||||||
@@ -37,6 +87,20 @@
|
|||||||
|
|
||||||
### General
|
### General
|
||||||
- **Formatting**: ruff for Python, ESLint for TypeScript
|
- **Formatting**: ruff for Python, ESLint for TypeScript
|
||||||
- **Commits**: Use conventional commits, run pre-commit hooks before pushing
|
- **Commits**: Use conventional commits with optional scopes, run pre-commit hooks before pushing
|
||||||
|
- Format: `type(scope): Description starting with uppercase and ending with period.`
|
||||||
|
- Scopes: `cli`, `api`, `frontend` (optional)
|
||||||
|
- Types: `feat`, `fix`, `refactor` (avoid too many different types)
|
||||||
|
- Examples:
|
||||||
|
- `feat(frontend): Add support for S3 backups.`
|
||||||
|
- `fix(api): Resolve authentication timeout issues.`
|
||||||
|
- `refactor(cli): Improve error handling for missing config.`
|
||||||
- Avoid including specific numbers, counts, or data-dependent information that may become outdated
|
- Avoid including specific numbers, counts, or data-dependent information that may become outdated
|
||||||
- **Security**: Never log sensitive data, use environment variables for secrets
|
- **Security**: Never log sensitive data, use environment variables for secrets
|
||||||
|
|
||||||
|
## Contributing Guidelines
|
||||||
|
|
||||||
|
This repository follows conventional changelog practices. Refer to `CONTRIBUTING.md` for detailed contribution guidelines including:
|
||||||
|
- Commit message format and scoping
|
||||||
|
- Release process using `scripts/release.sh`
|
||||||
|
- Pre-commit hooks setup with `pre-commit install`
|
||||||
|
|||||||
88
CHANGELOG.md
88
CHANGELOG.md
@@ -1,4 +1,92 @@
|
|||||||
|
|
||||||
|
## 2025.9.11 (2025/09/15)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **config:** Add Pydantic validation and fix telegram config field mappings. ([2c6e0995](https://github.com/elisiariocouto/leggen/commit/2c6e0995968c9c9917992fd15ec10a89933c0c21))
|
||||||
|
- **config:** Fix example config file. ([d09cf6d0](https://github.com/elisiariocouto/leggen/commit/d09cf6d04ccb6233981f273cd88e0b8ffe074d71))
|
||||||
|
- **docs:** Remove test files and update gitignore ([692bee57](https://github.com/elisiariocouto/leggen/commit/692bee574ee8de16496a3c733bad53be3b256990))
|
||||||
|
- **frontend:** Align balance calculation between sidebar and Analytics page ([35b6d98e](https://github.com/elisiariocouto/leggen/commit/35b6d98e6a37b1e9caf8a232ffe66380e7203cad))
|
||||||
|
- **frontend:** Add ignore rules for eslint on shadcn components. ([74a700ff](https://github.com/elisiariocouto/leggen/commit/74a700ff87b2504c3d394cddd9935c56c3c7a00d))
|
||||||
|
- Resolve all CI failures - linting, typing, and test issues ([c8f0a103](https://github.com/elisiariocouto/leggen/commit/c8f0a103c6ccdb722bbab1ac6973827b41fddc19))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **analytics:** Fix transaction limits and improve chart legends ([e136fc4b](https://github.com/elisiariocouto/leggen/commit/e136fc4b75243b35a77bc0bf0260808006987d7a))
|
||||||
|
- **docs:** Add comprehensive copilot agent setup instructions ([c6ac4455](https://github.com/elisiariocouto/leggen/commit/c6ac4455f848dd429100dd3fc6d43de8c4e5aa6b))
|
||||||
|
- **docs:** Add configuration file setup to agent instructions ([482f16c7](https://github.com/elisiariocouto/leggen/commit/482f16c77eef1f477ba49475fe30f809de9a05d7))
|
||||||
|
- **frontend:** Enhance transactions page with advanced filtering and UI improvements. ([969776fb](https://github.com/elisiariocouto/leggen/commit/969776fb53261acca2f77b0c761584e201fde118))
|
||||||
|
- **frontend:** Replace heavy filter UI with modern shadcn/ui inline filter bar. ([eb27f191](https://github.com/elisiariocouto/leggen/commit/eb27f19196d92a6ae5220b81709fded499a12f4f))
|
||||||
|
- **frontend:** Complete shadcn/ui migration with dark mode support and analytics updates. ([66db34c7](https://github.com/elisiariocouto/leggen/commit/66db34c712300ff4b5dbe7e06246f16d6f6a8469))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Sort imports, fix deprecated pydantic option. ([2467cb2f](https://github.com/elisiariocouto/leggen/commit/2467cb2f5af07a7262b3221bf61b58ad4017659a))
|
||||||
|
- Check import order using ruff. ([da98b7b2](https://github.com/elisiariocouto/leggen/commit/da98b7b2b77c5b37792dedff11f8256da3b086f7))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **analytics:** Simplify analytics endpoints and eliminate client-side processing. ([077e2bb1](https://github.com/elisiariocouto/leggen/commit/077e2bb1adbdb73ffde17635bd918cd40fe7fb5a))
|
||||||
|
- Unify leggen and leggend packages into single leggen package ([318ca517](https://github.com/elisiariocouto/leggen/commit/318ca517f7ea599b37a8deb47ad80218fbae008f))
|
||||||
|
- Consolidate database layer and eliminate wrapper complexity. ([5ae3a51d](https://github.com/elisiariocouto/leggen/commit/5ae3a51d8138b9aa28dbceabf575ab2577402e70))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.11 (2025/09/15)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **config:** Add Pydantic validation and fix telegram config field mappings. ([2c6e0995](https://github.com/elisiariocouto/leggen/commit/2c6e0995968c9c9917992fd15ec10a89933c0c21))
|
||||||
|
- **config:** Fix example config file. ([d09cf6d0](https://github.com/elisiariocouto/leggen/commit/d09cf6d04ccb6233981f273cd88e0b8ffe074d71))
|
||||||
|
- **docs:** Remove test files and update gitignore ([692bee57](https://github.com/elisiariocouto/leggen/commit/692bee574ee8de16496a3c733bad53be3b256990))
|
||||||
|
- **frontend:** Align balance calculation between sidebar and Analytics page ([35b6d98e](https://github.com/elisiariocouto/leggen/commit/35b6d98e6a37b1e9caf8a232ffe66380e7203cad))
|
||||||
|
- **frontend:** Add ignore rules for eslint on shadcn components. ([74a700ff](https://github.com/elisiariocouto/leggen/commit/74a700ff87b2504c3d394cddd9935c56c3c7a00d))
|
||||||
|
- Resolve all CI failures - linting, typing, and test issues ([c8f0a103](https://github.com/elisiariocouto/leggen/commit/c8f0a103c6ccdb722bbab1ac6973827b41fddc19))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **analytics:** Fix transaction limits and improve chart legends ([e136fc4b](https://github.com/elisiariocouto/leggen/commit/e136fc4b75243b35a77bc0bf0260808006987d7a))
|
||||||
|
- **docs:** Add comprehensive copilot agent setup instructions ([c6ac4455](https://github.com/elisiariocouto/leggen/commit/c6ac4455f848dd429100dd3fc6d43de8c4e5aa6b))
|
||||||
|
- **docs:** Add configuration file setup to agent instructions ([482f16c7](https://github.com/elisiariocouto/leggen/commit/482f16c77eef1f477ba49475fe30f809de9a05d7))
|
||||||
|
- **frontend:** Enhance transactions page with advanced filtering and UI improvements. ([969776fb](https://github.com/elisiariocouto/leggen/commit/969776fb53261acca2f77b0c761584e201fde118))
|
||||||
|
- **frontend:** Replace heavy filter UI with modern shadcn/ui inline filter bar. ([eb27f191](https://github.com/elisiariocouto/leggen/commit/eb27f19196d92a6ae5220b81709fded499a12f4f))
|
||||||
|
- **frontend:** Complete shadcn/ui migration with dark mode support and analytics updates. ([66db34c7](https://github.com/elisiariocouto/leggen/commit/66db34c712300ff4b5dbe7e06246f16d6f6a8469))
|
||||||
|
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- Sort imports, fix deprecated pydantic option. ([2467cb2f](https://github.com/elisiariocouto/leggen/commit/2467cb2f5af07a7262b3221bf61b58ad4017659a))
|
||||||
|
- Check import order using ruff. ([da98b7b2](https://github.com/elisiariocouto/leggen/commit/da98b7b2b77c5b37792dedff11f8256da3b086f7))
|
||||||
|
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **analytics:** Simplify analytics endpoints and eliminate client-side processing. ([077e2bb1](https://github.com/elisiariocouto/leggen/commit/077e2bb1adbdb73ffde17635bd918cd40fe7fb5a))
|
||||||
|
- Unify leggen and leggend packages into single leggen package ([318ca517](https://github.com/elisiariocouto/leggen/commit/318ca517f7ea599b37a8deb47ad80218fbae008f))
|
||||||
|
- Consolidate database layer and eliminate wrapper complexity. ([5ae3a51d](https://github.com/elisiariocouto/leggen/commit/5ae3a51d8138b9aa28dbceabf575ab2577402e70))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.10 (2025/09/13)
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 2025.9.10 (2025/09/13)
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- **frontend:** Update dependencies. ([06cf02f4](https://github.com/elisiariocouto/leggen/commit/06cf02f43ff72e4e01692e3a94a06be48d9acb1f))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 2025.9.9 (2025/09/11)
|
## 2025.9.9 (2025/09/11)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ FROM python:3.13-alpine
|
|||||||
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
|
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
|
||||||
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
|
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
|
||||||
LABEL org.opencontainers.image.licenses="MIT"
|
LABEL org.opencontainers.image.licenses="MIT"
|
||||||
LABEL org.opencontainers.image.title="Leggend API"
|
LABEL org.opencontainers.image.title="Leggen API"
|
||||||
LABEL org.opencontainers.image.description="Open Banking API for Leggen"
|
LABEL org.opencontainers.image.description="Open Banking API for Leggen"
|
||||||
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
|
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
|
||||||
|
|
||||||
@@ -30,4 +30,4 @@ EXPOSE 8000
|
|||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD wget -q --spider http://127.0.0.1:8000/api/v1/health || exit 1
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD wget -q --spider http://127.0.0.1:8000/api/v1/health || exit 1
|
||||||
|
|
||||||
CMD ["/app/.venv/bin/leggend"]
|
CMD ["/app/.venv/bin/leggen", "server"]
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
An Open Banking CLI and API service for managing bank connections and transactions.
|
An Open Banking CLI and API service for managing bank connections and transactions.
|
||||||
|
|
||||||
This tool provides **FastAPI backend service** (`leggend`), a **React Web Interface** and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
|
This tool provides a **unified command-line interface** (`leggen`) with both CLI commands and an integrated **FastAPI backend service**, plus a **React Web Interface** to connect to banks using the GoCardless Open Banking API.
|
||||||
|
|
||||||
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
|
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
|
||||||
|
|
||||||
## 🛠️ Technologies
|
## 🛠️ Technologies
|
||||||
|
|
||||||
### 🔌 API & Backend
|
### 🔌 API & Backend
|
||||||
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (`leggend` service)
|
- [FastAPI](https://fastapi.tiangolo.com/): High-performance async API backend (integrated into `leggen server`)
|
||||||
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
|
- [GoCardless Open Banking API](https://developer.gocardless.com/bank-account-data/overview): for connecting to banks
|
||||||
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
|
- [APScheduler](https://apscheduler.readthedocs.io/): Background job scheduling with configurable cron
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ For development or local installation:
|
|||||||
uv sync # or pip install -e .
|
uv sync # or pip install -e .
|
||||||
|
|
||||||
# Start the API service
|
# Start the API service
|
||||||
uv run leggend --reload # Development mode with auto-reload
|
uv run leggen server --reload # Development mode with auto-reload
|
||||||
|
|
||||||
# Use the CLI (in another terminal)
|
# Use the CLI (in another terminal)
|
||||||
uv run leggen --help
|
uv run leggen --help
|
||||||
@@ -152,19 +152,19 @@ case-sensitive = ["SpecificStore"]
|
|||||||
|
|
||||||
## 📖 Usage
|
## 📖 Usage
|
||||||
|
|
||||||
### API Service (`leggend`)
|
### API Service (`leggen server`)
|
||||||
|
|
||||||
Start the FastAPI backend service:
|
Start the FastAPI backend service:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Production mode
|
# Production mode
|
||||||
leggend
|
leggen server
|
||||||
|
|
||||||
# Development mode with auto-reload
|
# Development mode with auto-reload
|
||||||
leggend --reload
|
leggen server --reload
|
||||||
|
|
||||||
# Custom host and port
|
# Custom host and port
|
||||||
leggend --host 127.0.0.1 --port 8080
|
leggen server --host 127.0.0.1 --port 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
|
**API Documentation**: Visit `http://localhost:8000/docs` for interactive API documentation.
|
||||||
@@ -207,7 +207,7 @@ leggen sync --force --wait
|
|||||||
leggen --api-url http://localhost:8080 status
|
leggen --api-url http://localhost:8080 status
|
||||||
|
|
||||||
# Set via environment variable
|
# Set via environment variable
|
||||||
export LEGGEND_API_URL=http://localhost:8080
|
export LEGGEN_API_URL=http://localhost:8080
|
||||||
leggen status
|
leggen status
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ docker compose -f compose.dev.yml ps
|
|||||||
|
|
||||||
# Check logs
|
# Check logs
|
||||||
docker compose -f compose.dev.yml logs frontend
|
docker compose -f compose.dev.yml logs frontend
|
||||||
docker compose -f compose.dev.yml logs leggend
|
docker compose -f compose.dev.yml logs leggen-server
|
||||||
|
|
||||||
# Stop development services
|
# Stop development services
|
||||||
docker compose -f compose.dev.yml down
|
docker compose -f compose.dev.yml down
|
||||||
@@ -239,7 +239,7 @@ docker compose ps
|
|||||||
|
|
||||||
# Check logs
|
# Check logs
|
||||||
docker compose logs frontend
|
docker compose logs frontend
|
||||||
docker compose logs leggend
|
docker compose logs leggen-server
|
||||||
|
|
||||||
# Access the web interface at http://localhost:3000
|
# Access the web interface at http://localhost:3000
|
||||||
# API documentation at http://localhost:8000/docs
|
# API documentation at http://localhost:8000/docs
|
||||||
@@ -290,7 +290,7 @@ cd leggen
|
|||||||
uv sync
|
uv sync
|
||||||
|
|
||||||
# Start API service with auto-reload
|
# Start API service with auto-reload
|
||||||
uv run leggend --reload
|
uv run leggen server --reload
|
||||||
|
|
||||||
# Use CLI commands
|
# Use CLI commands
|
||||||
uv run leggen status
|
uv run leggen status
|
||||||
@@ -333,13 +333,10 @@ The test suite includes:
|
|||||||
leggen/ # CLI application
|
leggen/ # CLI application
|
||||||
├── commands/ # CLI command implementations
|
├── commands/ # CLI command implementations
|
||||||
├── utils/ # Shared utilities
|
├── utils/ # Shared utilities
|
||||||
└── api_client.py # API client for leggend service
|
├── api/ # FastAPI API routes and models
|
||||||
|
|
||||||
leggend/ # FastAPI backend service
|
|
||||||
├── api/ # API routes and models
|
|
||||||
├── services/ # Business logic
|
├── services/ # Business logic
|
||||||
├── background/ # Background job scheduler
|
├── background/ # Background job scheduler
|
||||||
└── main.py # FastAPI application
|
└── api_client.py # API client for server communication
|
||||||
|
|
||||||
tests/ # Test suite
|
tests/ # Test suite
|
||||||
├── conftest.py # Shared test fixtures
|
├── conftest.py # Shared test fixtures
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:80"
|
- "127.0.0.1:3000:80"
|
||||||
environment:
|
environment:
|
||||||
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggend:8000}
|
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggen-server:8000}
|
||||||
depends_on:
|
depends_on:
|
||||||
leggend:
|
leggen-server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
# FastAPI backend service
|
# FastAPI backend service
|
||||||
leggend:
|
leggen-server:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:80"
|
- "127.0.0.1:3000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
leggend:
|
leggen-server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
# FastAPI backend service
|
# FastAPI backend service
|
||||||
leggend:
|
leggen-server:
|
||||||
image: ghcr.io/elisiariocouto/leggen:latest
|
image: ghcr.io/elisiariocouto/leggen:latest
|
||||||
restart: "unless-stopped"
|
restart: "unless-stopped"
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ enabled = true
|
|||||||
|
|
||||||
# Optional: Telegram notifications
|
# Optional: Telegram notifications
|
||||||
[notifications.telegram]
|
[notifications.telegram]
|
||||||
token = "your-bot-token"
|
api-key = "your-bot-token"
|
||||||
chat_id = 12345
|
chat-id = 12345
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
# Optional: Transaction filters for notifications
|
# Optional: Transaction filters for notifications
|
||||||
|
|||||||
7
frontend/.claude/settings.local.json
Normal file
7
frontend/.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": ["Bash(find:*)"],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ COPY --from=builder /app/dist /usr/share/nginx/html
|
|||||||
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
COPY default.conf.template /etc/nginx/templates/default.conf.template
|
||||||
|
|
||||||
# Set default API backend URL (can be overridden at runtime)
|
# Set default API backend URL (can be overridden at runtime)
|
||||||
ENV API_BACKEND_URL=http://leggend:8000
|
ENV API_BACKEND_URL=http://leggen-server:8000
|
||||||
|
|
||||||
# Expose port 80
|
# Expose port 80
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ The frontend supports configurable API URLs through environment variables:
|
|||||||
|
|
||||||
- Uses relative URLs (`/api/v1`) that nginx proxies to the backend
|
- Uses relative URLs (`/api/v1`) that nginx proxies to the backend
|
||||||
- Configure nginx proxy target via `API_BACKEND_URL` environment variable
|
- Configure nginx proxy target via `API_BACKEND_URL` environment variable
|
||||||
- Default: `http://leggend:8000`
|
- Default: `http://leggen-server:8000`
|
||||||
|
|
||||||
**Docker Compose:**
|
**Docker Compose:**
|
||||||
|
|
||||||
|
|||||||
22
frontend/components.json
Normal file
22
frontend/components.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -19,5 +19,17 @@ export default tseslint.config([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/components/**/*.{ts,tsx}", "src/contexts/**/*.{ts,tsx}"],
|
||||||
|
rules: {
|
||||||
|
"react-refresh/only-export-components": "off",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
1415
frontend/package-lock.json
generated
1415
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,10 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tanstack/react-query": "^5.87.1",
|
"@tanstack/react-query": "^5.87.1",
|
||||||
"@tanstack/react-router": "^1.131.36",
|
"@tanstack/react-router": "^1.131.36",
|
||||||
@@ -17,12 +21,19 @@
|
|||||||
"@tanstack/router-cli": "^1.131.36",
|
"@tanstack/router-cli": "^1.131.36",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^0.542.0",
|
"lucide-react": "^0.542.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
|
"react-day-picker": "^9.10.0",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"tailwindcss": "^3.4.17"
|
"recharts": "^3.2.0",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import Dashboard from "./components/Dashboard";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
retry: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
<Dashboard />
|
|
||||||
</div>
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -13,9 +13,52 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import { formatCurrency, formatDate } from "../lib/utils";
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "./ui/card";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
import type { Account, Balance } from "../types/api";
|
import type { Account, Balance } from "../types/api";
|
||||||
|
|
||||||
|
// Helper function to get status indicator color and styles
|
||||||
|
const getStatusIndicator = (status: string) => {
|
||||||
|
const statusLower = status.toLowerCase();
|
||||||
|
|
||||||
|
switch (statusLower) {
|
||||||
|
case "ready":
|
||||||
|
return {
|
||||||
|
color: "bg-green-500",
|
||||||
|
tooltip: "Ready",
|
||||||
|
};
|
||||||
|
case "pending":
|
||||||
|
return {
|
||||||
|
color: "bg-yellow-500",
|
||||||
|
tooltip: "Pending",
|
||||||
|
};
|
||||||
|
case "error":
|
||||||
|
case "failed":
|
||||||
|
return {
|
||||||
|
color: "bg-red-500",
|
||||||
|
tooltip: "Error",
|
||||||
|
};
|
||||||
|
case "inactive":
|
||||||
|
return {
|
||||||
|
color: "bg-gray-500",
|
||||||
|
tooltip: "Inactive",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: "bg-blue-500",
|
||||||
|
tooltip: status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export default function AccountsOverview() {
|
export default function AccountsOverview() {
|
||||||
const {
|
const {
|
||||||
data: accounts,
|
data: accounts,
|
||||||
@@ -71,35 +114,28 @@ export default function AccountsOverview() {
|
|||||||
|
|
||||||
if (accountsLoading) {
|
if (accountsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow">
|
<Card>
|
||||||
<LoadingSpinner message="Loading accounts..." />
|
<LoadingSpinner message="Loading accounts..." />
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountsError) {
|
if (accountsError) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Alert variant="destructive">
|
||||||
<div className="flex items-center justify-center text-center">
|
<AlertCircle className="h-4 w-4" />
|
||||||
<div>
|
<AlertTitle>Failed to load accounts</AlertTitle>
|
||||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
<AlertDescription className="space-y-3">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<p>
|
||||||
Failed to load accounts
|
Unable to connect to the Leggen API. Please check your configuration
|
||||||
</h3>
|
and ensure the API server is running.
|
||||||
<p className="text-gray-600 mb-4">
|
</p>
|
||||||
Unable to connect to the Leggen API. Please check your
|
<Button onClick={() => refetchAccounts()} variant="outline" size="sm">
|
||||||
configuration and ensure the API server is running.
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
</p>
|
Retry
|
||||||
<button
|
</Button>
|
||||||
onClick={() => refetchAccounts()}
|
</AlertDescription>
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
</Alert>
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,192 +153,229 @@ export default function AccountsOverview() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Card>
|
||||||
<div className="flex items-center justify-between">
|
<CardContent className="p-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-medium text-gray-600">Total Balance</p>
|
<div>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
{formatCurrency(totalBalance)}
|
Total Balance
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">
|
||||||
|
{formatCurrency(totalBalance)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-full">
|
||||||
|
<TrendingUp className="h-6 w-6 text-green-600" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-green-100 rounded-full">
|
</CardContent>
|
||||||
<TrendingUp className="h-6 w-6 text-green-600" />
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Card>
|
||||||
<div className="flex items-center justify-between">
|
<CardContent className="p-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-medium text-gray-600">
|
<div>
|
||||||
Total Accounts
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
</p>
|
Total Accounts
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
</p>
|
||||||
{totalAccounts}
|
<p className="text-2xl font-bold text-foreground">
|
||||||
</p>
|
{totalAccounts}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-blue-100 dark:bg-blue-900/20 rounded-full">
|
||||||
|
<CreditCard className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-blue-100 rounded-full">
|
</CardContent>
|
||||||
<CreditCard className="h-6 w-6 text-blue-600" />
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Card>
|
||||||
<div className="flex items-center justify-between">
|
<CardContent className="p-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm font-medium text-gray-600">
|
<div>
|
||||||
Connected Banks
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
</p>
|
Connected Banks
|
||||||
<p className="text-2xl font-bold text-gray-900">{uniqueBanks}</p>
|
</p>
|
||||||
|
<p className="text-2xl font-bold text-foreground">
|
||||||
|
{uniqueBanks}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-purple-100 dark:bg-purple-900/20 rounded-full">
|
||||||
|
<Building2 className="h-6 w-6 text-purple-600" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 bg-purple-100 rounded-full">
|
</CardContent>
|
||||||
<Building2 className="h-6 w-6 text-purple-600" />
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Accounts List */}
|
{/* Accounts List */}
|
||||||
<div className="bg-white rounded-lg shadow">
|
<Card>
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<CardHeader>
|
||||||
<h3 className="text-lg font-medium text-gray-900">Bank Accounts</h3>
|
<CardTitle>Bank Accounts</CardTitle>
|
||||||
<p className="text-sm text-gray-600">
|
<CardDescription>Manage your connected bank accounts</CardDescription>
|
||||||
Manage your connected bank accounts
|
</CardHeader>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!accounts || accounts.length === 0 ? (
|
{!accounts || accounts.length === 0 ? (
|
||||||
<div className="p-6 text-center">
|
<CardContent className="p-6 text-center">
|
||||||
<CreditCard className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
<CreditCard className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
No accounts found
|
No accounts found
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-muted-foreground">
|
||||||
Connect your first bank account to get started with Leggen.
|
Connect your first bank account to get started with Leggen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</CardContent>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-200">
|
<CardContent className="p-0">
|
||||||
{accounts.map((account) => {
|
<div className="divide-y divide-border">
|
||||||
// Get balance from account's balances array or fallback to balances query
|
{accounts.map((account) => {
|
||||||
const accountBalance = account.balances?.[0];
|
// Get balance from account's balances array or fallback to balances query
|
||||||
const fallbackBalance = balances?.find(
|
const accountBalance = account.balances?.[0];
|
||||||
(b) => b.account_id === account.id,
|
const fallbackBalance = balances?.find(
|
||||||
);
|
(b) => b.account_id === account.id,
|
||||||
const balance =
|
);
|
||||||
accountBalance?.amount || fallbackBalance?.balance_amount || 0;
|
const balance =
|
||||||
const currency =
|
accountBalance?.amount ||
|
||||||
accountBalance?.currency ||
|
fallbackBalance?.balance_amount ||
|
||||||
fallbackBalance?.currency ||
|
0;
|
||||||
account.currency ||
|
const currency =
|
||||||
"EUR";
|
accountBalance?.currency ||
|
||||||
const isPositive = balance >= 0;
|
fallbackBalance?.currency ||
|
||||||
|
account.currency ||
|
||||||
|
"EUR";
|
||||||
|
const isPositive = balance >= 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className="p-6 hover:bg-gray-50 transition-colors"
|
className="p-4 sm:p-6 hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
{/* Mobile layout - stack vertically */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
<div className="p-3 bg-gray-100 rounded-full">
|
<div className="flex items-start sm:items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||||
<Building2 className="h-6 w-6 text-gray-600" />
|
<div className="flex-shrink-0 p-2 sm:p-3 bg-muted rounded-full">
|
||||||
</div>
|
<Building2 className="h-5 w-5 sm:h-6 sm:w-6 text-muted-foreground" />
|
||||||
<div className="flex-1">
|
</div>
|
||||||
{editingAccountId === account.id ? (
|
<div className="flex-1 min-w-0">
|
||||||
<div className="space-y-2">
|
{editingAccountId === account.id ? (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="space-y-2">
|
||||||
<input
|
<div className="flex items-center space-x-2">
|
||||||
type="text"
|
<input
|
||||||
value={editingName}
|
type="text"
|
||||||
onChange={(e) => setEditingName(e.target.value)}
|
value={editingName}
|
||||||
className="flex-1 px-3 py-1 text-lg font-medium border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
onChange={(e) =>
|
||||||
placeholder="Account name"
|
setEditingName(e.target.value)
|
||||||
name="search"
|
}
|
||||||
autoComplete="off"
|
className="flex-1 px-3 py-1 text-base sm:text-lg font-medium border border-input rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||||
onKeyDown={(e) => {
|
placeholder="Account name"
|
||||||
if (e.key === "Enter") handleEditSave();
|
name="search"
|
||||||
if (e.key === "Escape") handleEditCancel();
|
autoComplete="off"
|
||||||
}}
|
onKeyDown={(e) => {
|
||||||
autoFocus
|
if (e.key === "Enter") handleEditSave();
|
||||||
/>
|
if (e.key === "Escape") handleEditCancel();
|
||||||
<button
|
}}
|
||||||
onClick={handleEditSave}
|
autoFocus
|
||||||
disabled={
|
/>
|
||||||
!editingName.trim() ||
|
<button
|
||||||
updateAccountMutation.isPending
|
onClick={handleEditSave}
|
||||||
}
|
disabled={
|
||||||
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
!editingName.trim() ||
|
||||||
title="Save changes"
|
updateAccountMutation.isPending
|
||||||
>
|
}
|
||||||
<Check className="h-4 w-4" />
|
className="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
</button>
|
title="Save changes"
|
||||||
<button
|
>
|
||||||
onClick={handleEditCancel}
|
<Check className="h-4 w-4" />
|
||||||
className="p-1 text-gray-600 hover:text-gray-700"
|
</button>
|
||||||
title="Cancel editing"
|
<button
|
||||||
>
|
onClick={handleEditCancel}
|
||||||
<X className="h-4 w-4" />
|
className="p-1 text-gray-600 hover:text-gray-700"
|
||||||
</button>
|
title="Cancel editing"
|
||||||
</div>
|
>
|
||||||
<p className="text-sm text-gray-600">
|
<X className="h-4 w-4" />
|
||||||
{account.institution_id} • {account.status}
|
</button>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
) : (
|
{account.institution_id}
|
||||||
<div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<h4 className="text-lg font-medium text-gray-900">
|
|
||||||
{account.name || "Unnamed Account"}
|
|
||||||
</h4>
|
|
||||||
<button
|
|
||||||
onClick={() => handleEditStart(account)}
|
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
|
||||||
title="Edit account name"
|
|
||||||
>
|
|
||||||
<Edit2 className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<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>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<div>
|
||||||
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
|
<h4 className="text-base sm:text-lg font-medium text-foreground truncate">
|
||||||
|
{account.name || "Unnamed Account"}
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditStart(account)}
|
||||||
|
className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Edit account name"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
|
{account.institution_id}
|
||||||
|
</p>
|
||||||
|
{account.iban && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1 font-mono break-all sm:break-normal">
|
||||||
|
IBAN: {account.iban}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right">
|
{/* Balance and date section */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:text-right flex-shrink-0">
|
||||||
{isPositive ? (
|
{/* Mobile: date/status on left, balance on right */}
|
||||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
{/* Desktop: balance on top, date/status on bottom */}
|
||||||
) : (
|
|
||||||
<TrendingDown className="h-4 w-4 text-red-500" />
|
{/* Date and status indicator - left on mobile, bottom on desktop */}
|
||||||
)}
|
<div className="flex items-center space-x-2 order-1 sm:order-2">
|
||||||
<p
|
<div
|
||||||
className={`text-lg font-semibold ${
|
className={`w-3 h-3 rounded-full ${getStatusIndicator(account.status).color} relative group cursor-help`}
|
||||||
isPositive ? "text-green-600" : "text-red-600"
|
role="img"
|
||||||
}`}
|
aria-label={`Account status: ${getStatusIndicator(account.status).tooltip}`}
|
||||||
>
|
>
|
||||||
{formatCurrency(balance, currency)}
|
{/* Tooltip */}
|
||||||
</p>
|
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 hidden group-hover:block bg-gray-900 text-white text-xs rounded py-1 px-2 whitespace-nowrap z-10">
|
||||||
|
{getStatusIndicator(account.status).tooltip}
|
||||||
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-2 border-transparent border-t-gray-900"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
Updated{" "}
|
||||||
|
{formatDate(
|
||||||
|
account.last_accessed || account.created,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Balance - right on mobile, top on desktop */}
|
||||||
|
<div className="flex items-center space-x-2 order-2 sm:order-1">
|
||||||
|
{isPositive ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-4 w-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={`text-base sm:text-lg font-semibold ${
|
||||||
|
isPositive ? "text-green-600" : "text-red-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatCurrency(balance, currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
Updated{" "}
|
|
||||||
{formatDate(account.last_accessed || account.created)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
CreditCard,
|
|
||||||
TrendingUp,
|
|
||||||
Activity,
|
|
||||||
Menu,
|
|
||||||
X,
|
|
||||||
Home,
|
|
||||||
List,
|
|
||||||
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";
|
|
||||||
|
|
||||||
type TabType = "overview" | "transactions" | "analytics" | "notifications";
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabType>("overview");
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: accounts } = useQuery<Account[]>({
|
|
||||||
queryKey: ["accounts"],
|
|
||||||
queryFn: apiClient.getAccounts,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: healthStatus,
|
|
||||||
isLoading: healthLoading,
|
|
||||||
isError: healthError,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["health"],
|
|
||||||
queryFn: async () => {
|
|
||||||
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 },
|
|
||||||
];
|
|
||||||
|
|
||||||
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="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" />
|
|
||||||
<h1 className="text-xl font-bold text-gray-900">Leggen</h1>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
|
|
||||||
>
|
|
||||||
<X className="h-6 w-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className="px-6 py-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{navigation.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
onClick={() => {
|
|
||||||
setActiveTab(item.id);
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"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",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className="mr-3 h-5 w-5" />
|
|
||||||
{item.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Account Summary in Sidebar */}
|
|
||||||
<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>
|
|
||||||
<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",
|
|
||||||
}).format(totalBalance)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
{accounts?.length || 0} accounts
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overlay for mobile */}
|
|
||||||
{sidebarOpen && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
|
||||||
onClick={() => setSidebarOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="flex flex-col flex-1 overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between h-16 px-6">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setSidebarOpen(true)}
|
|
||||||
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="flex items-center space-x-1">
|
|
||||||
{healthLoading ? (
|
|
||||||
<>
|
|
||||||
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
|
|
||||||
<span className="text-sm text-gray-600">Checking...</span>
|
|
||||||
</>
|
|
||||||
) : healthError || healthStatus?.status !== "healthy" ? (
|
|
||||||
<>
|
|
||||||
<WifiOff className="h-4 w-4 text-red-500" />
|
|
||||||
<span className="text-sm text-red-500">Disconnected</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Wifi className="h-4 w-4 text-green-500" />
|
|
||||||
<span className="text-sm text-gray-600">Connected</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Main content area */}
|
|
||||||
<main className="flex-1 overflow-y-auto p-6">
|
|
||||||
<ErrorBoundary>
|
|
||||||
{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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeTab === "notifications" && <Notifications />}
|
|
||||||
</ErrorBoundary>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
70
frontend/src/components/FiltersSkeleton.tsx
Normal file
70
frontend/src/components/FiltersSkeleton.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export default function FiltersSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow animate-pulse">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-32"></div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-24"></div>
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
|
{/* Quick Date Filters Skeleton */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-32 mb-3"></div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-28"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-24"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded-lg w-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter Fields Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="sm:col-span-2 lg:col-span-1">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount Range Filters Skeleton */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 mb-1"></div>
|
||||||
|
<div className="h-10 bg-gray-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Summary Skeleton */}
|
||||||
|
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-48"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useLocation } from "@tanstack/react-router";
|
|||||||
import { Menu, Activity, Wifi, WifiOff } from "lucide-react";
|
import { Menu, Activity, Wifi, WifiOff } from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
|
import { ThemeToggle } from "./ui/theme-toggle";
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: "Overview", to: "/" },
|
{ name: "Overview", to: "/" },
|
||||||
@@ -31,25 +32,27 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
<header className="bg-card shadow-sm border-b border-border">
|
||||||
<div className="flex items-center justify-between h-16 px-6">
|
<div className="flex items-center justify-between h-16 px-6">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(true)}
|
onClick={() => setSidebarOpen(true)}
|
||||||
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
|
className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Menu className="h-6 w-6" />
|
<Menu className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-lg font-semibold text-gray-900 lg:ml-0 ml-4">
|
<h2 className="text-lg font-semibold text-card-foreground lg:ml-0 ml-4">
|
||||||
{currentPage}
|
{currentPage}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-3">
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
{healthLoading ? (
|
{healthLoading ? (
|
||||||
<>
|
<>
|
||||||
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
|
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
|
||||||
<span className="text-sm text-gray-600">Checking...</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
Checking...
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
) : healthError || healthStatus?.status !== "healthy" ? (
|
) : healthError || healthStatus?.status !== "healthy" ? (
|
||||||
<>
|
<>
|
||||||
@@ -59,10 +62,11 @@ export default function Header({ setSidebarOpen }: HeaderProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Wifi className="h-4 w-4 text-green-500" />
|
<Wifi className="h-4 w-4 text-green-500" />
|
||||||
<span className="text-sm text-gray-600">Connected</span>
|
<span className="text-sm text-muted-foreground">Connected</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "./ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { Label } from "./ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "./ui/select";
|
||||||
import type { NotificationSettings, NotificationService } from "../types/api";
|
import type { NotificationSettings, NotificationService } from "../types/api";
|
||||||
|
|
||||||
export default function Notifications() {
|
export default function Notifications() {
|
||||||
@@ -63,38 +81,35 @@ export default function Notifications() {
|
|||||||
|
|
||||||
if (settingsLoading || servicesLoading) {
|
if (settingsLoading || servicesLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow">
|
<Card>
|
||||||
<LoadingSpinner message="Loading notifications..." />
|
<LoadingSpinner message="Loading notifications..." />
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settingsError || servicesError) {
|
if (settingsError || servicesError) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Alert variant="destructive">
|
||||||
<div className="flex items-center justify-center text-center">
|
<AlertCircle className="h-4 w-4" />
|
||||||
<div>
|
<AlertTitle>Failed to load notifications</AlertTitle>
|
||||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
<AlertDescription className="space-y-3">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<p>
|
||||||
Failed to load notifications
|
Unable to connect to the Leggen API. Please check your configuration
|
||||||
</h3>
|
and ensure the API server is running.
|
||||||
<p className="text-gray-600 mb-4">
|
</p>
|
||||||
Unable to connect to the Leggen API. Please check your
|
<Button
|
||||||
configuration and ensure the API server is running.
|
onClick={() => {
|
||||||
</p>
|
refetchSettings();
|
||||||
<button
|
refetchServices();
|
||||||
onClick={() => {
|
}}
|
||||||
refetchSettings();
|
variant="outline"
|
||||||
refetchServices();
|
size="sm"
|
||||||
}}
|
>
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
>
|
Retry
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
</Button>
|
||||||
Retry
|
</AlertDescription>
|
||||||
</button>
|
</Alert>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,201 +135,202 @@ export default function Notifications() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Test Notification Section */}
|
{/* Test Notification Section */}
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Card>
|
||||||
<div className="flex items-center space-x-2 mb-4">
|
<CardHeader>
|
||||||
<TestTube className="h-5 w-5 text-blue-600" />
|
<CardTitle className="flex items-center space-x-2">
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
<TestTube className="h-5 w-5 text-primary" />
|
||||||
Test Notifications
|
<span>Test Notifications</span>
|
||||||
</h3>
|
</CardTitle>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="service" className="text-foreground">
|
||||||
|
Service
|
||||||
|
</Label>
|
||||||
|
<Select value={testService} onValueChange={setTestService}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a service..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{services?.map((service) => (
|
||||||
|
<SelectItem key={service.name} value={service.name}>
|
||||||
|
{service.name}{" "}
|
||||||
|
{service.enabled ? "(Enabled)" : "(Disabled)"}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div>
|
||||||
<div>
|
<Label htmlFor="message" className="text-foreground">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
Message
|
||||||
Service
|
</Label>
|
||||||
</label>
|
<Input
|
||||||
<select
|
id="message"
|
||||||
value={testService}
|
type="text"
|
||||||
onChange={(e) => setTestService(e.target.value)}
|
value={testMessage}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
onChange={(e) => setTestMessage(e.target.value)}
|
||||||
|
placeholder="Test message..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleTestNotification}
|
||||||
|
disabled={!testService || testMutation.isPending}
|
||||||
>
|
>
|
||||||
<option value="">Select a service...</option>
|
<Send className="h-4 w-4 mr-2" />
|
||||||
{services?.map((service) => (
|
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
|
||||||
<option key={service.name} value={service.name}>
|
</Button>
|
||||||
{service.name} {service.enabled ? "(Enabled)" : "(Disabled)"}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
<div>
|
</Card>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Message
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={testMessage}
|
|
||||||
onChange={(e) => setTestMessage(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="Test message..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<button
|
|
||||||
onClick={handleTestNotification}
|
|
||||||
disabled={!testService || testMutation.isPending}
|
|
||||||
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"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notification Services */}
|
{/* Notification Services */}
|
||||||
<div className="bg-white rounded-lg shadow">
|
<Card>
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
<CardHeader>
|
||||||
<div className="flex items-center space-x-2">
|
<CardTitle className="flex items-center space-x-2">
|
||||||
<Bell className="h-5 w-5 text-blue-600" />
|
<Bell className="h-5 w-5 text-primary" />
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
<span>Notification Services</span>
|
||||||
Notification Services
|
</CardTitle>
|
||||||
</h3>
|
<CardDescription>Manage your notification services</CardDescription>
|
||||||
</div>
|
</CardHeader>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
|
||||||
Manage your notification services
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!services || services.length === 0 ? (
|
{!services || services.length === 0 ? (
|
||||||
<div className="p-6 text-center">
|
<CardContent className="text-center">
|
||||||
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
<Bell className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
No notification services configured
|
No notification services configured
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-muted-foreground">
|
||||||
Configure notification services in your backend to receive alerts.
|
Configure notification services in your backend to receive alerts.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</CardContent>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-200">
|
<CardContent className="p-0">
|
||||||
{services.map((service) => (
|
<div className="divide-y divide-border">
|
||||||
<div
|
{services.map((service) => (
|
||||||
key={service.name}
|
<div
|
||||||
className="p-6 hover:bg-gray-50 transition-colors"
|
key={service.name}
|
||||||
>
|
className="p-6 hover:bg-accent transition-colors"
|
||||||
<div className="flex items-center justify-between">
|
>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center justify-between">
|
||||||
<div className="p-3 bg-gray-100 rounded-full">
|
<div className="flex items-center space-x-4">
|
||||||
{service.name.toLowerCase().includes("discord") ? (
|
<div className="p-3 bg-muted rounded-full">
|
||||||
<MessageSquare className="h-6 w-6 text-gray-600" />
|
{service.name.toLowerCase().includes("discord") ? (
|
||||||
) : service.name.toLowerCase().includes("telegram") ? (
|
<MessageSquare className="h-6 w-6 text-muted-foreground" />
|
||||||
<Send className="h-6 w-6 text-gray-600" />
|
) : service.name.toLowerCase().includes("telegram") ? (
|
||||||
) : (
|
<Send className="h-6 w-6 text-muted-foreground" />
|
||||||
<Bell className="h-6 w-6 text-gray-600" />
|
) : (
|
||||||
)}
|
<Bell className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
)}
|
||||||
<div>
|
</div>
|
||||||
<h4 className="text-lg font-medium text-gray-900 capitalize">
|
<div>
|
||||||
{service.name}
|
<h4 className="text-lg font-medium text-foreground capitalize">
|
||||||
</h4>
|
{service.name}
|
||||||
<div className="flex items-center space-x-2 mt-1">
|
</h4>
|
||||||
<span
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span
|
||||||
service.enabled
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
? "bg-green-100 text-green-800"
|
service.enabled
|
||||||
: "bg-red-100 text-red-800"
|
? "bg-green-100 text-green-800"
|
||||||
}`}
|
: "bg-red-100 text-red-800"
|
||||||
>
|
}`}
|
||||||
{service.enabled ? (
|
>
|
||||||
<CheckCircle className="h-3 w-3 mr-1" />
|
{service.enabled ? (
|
||||||
) : (
|
<CheckCircle className="h-3 w-3 mr-1" />
|
||||||
<AlertCircle className="h-3 w-3 mr-1" />
|
) : (
|
||||||
)}
|
<AlertCircle className="h-3 w-3 mr-1" />
|
||||||
{service.enabled ? "Enabled" : "Disabled"}
|
)}
|
||||||
</span>
|
{service.enabled ? "Enabled" : "Disabled"}
|
||||||
<span
|
</span>
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
<span
|
||||||
service.configured
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||||
? "bg-blue-100 text-blue-800"
|
service.configured
|
||||||
: "bg-yellow-100 text-yellow-800"
|
? "bg-blue-100 text-blue-800"
|
||||||
}`}
|
: "bg-yellow-100 text-yellow-800"
|
||||||
>
|
}`}
|
||||||
{service.configured ? "Configured" : "Not Configured"}
|
>
|
||||||
</span>
|
{service.configured
|
||||||
|
? "Configured"
|
||||||
|
: "Not Configured"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<Button
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteService(service.name)}
|
onClick={() => handleDeleteService(service.name)}
|
||||||
disabled={deleteServiceMutation.isPending}
|
disabled={deleteServiceMutation.isPending}
|
||||||
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
|
variant="ghost"
|
||||||
title={`Delete ${service.name} service`}
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Notification Settings */}
|
{/* Notification Settings */}
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Card>
|
||||||
<div className="flex items-center space-x-2 mb-4">
|
<CardHeader>
|
||||||
<Settings className="h-5 w-5 text-blue-600" />
|
<CardTitle className="flex items-center space-x-2">
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
<Settings className="h-5 w-5 text-primary" />
|
||||||
Notification Settings
|
<span>Notification Settings</span>
|
||||||
</h3>
|
</CardTitle>
|
||||||
</div>
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
{settings && (
|
{settings && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">
|
<h4 className="text-sm font-medium text-foreground mb-2">
|
||||||
Filters
|
Filters
|
||||||
</h4>
|
</h4>
|
||||||
<div className="bg-gray-50 rounded-md p-4">
|
<div className="bg-muted rounded-md p-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
Case Insensitive Filters
|
Case Insensitive Filters
|
||||||
</label>
|
</Label>
|
||||||
<p className="text-sm text-gray-900">
|
<p className="text-sm text-foreground">
|
||||||
{settings.filters.case_insensitive.length > 0
|
{settings.filters.case_insensitive.length > 0
|
||||||
? settings.filters.case_insensitive.join(", ")
|
? settings.filters.case_insensitive.join(", ")
|
||||||
: "None"}
|
: "None"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-600 mb-1">
|
<Label className="text-xs font-medium text-muted-foreground mb-1 block">
|
||||||
Case Sensitive Filters
|
Case Sensitive Filters
|
||||||
</label>
|
</Label>
|
||||||
<p className="text-sm text-gray-900">
|
<p className="text-sm text-foreground">
|
||||||
{settings.filters.case_sensitive &&
|
{settings.filters.case_sensitive &&
|
||||||
settings.filters.case_sensitive.length > 0
|
settings.filters.case_sensitive.length > 0
|
||||||
? settings.filters.case_sensitive.join(", ")
|
? settings.filters.case_sensitive.join(", ")
|
||||||
: "None"}
|
: "None"}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-muted-foreground">
|
||||||
<p>
|
<p>
|
||||||
Configure notification settings through your backend API to
|
Configure notification settings through your backend API to
|
||||||
customize filters and service configurations.
|
customize filters and service configurations.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { X, Copy, Check } from "lucide-react";
|
import { X, Copy, Check } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
import type { RawTransactionData } from "../types/api";
|
import type { RawTransactionData } from "../types/api";
|
||||||
|
|
||||||
interface RawTransactionModalProps {
|
interface RawTransactionModalProps {
|
||||||
@@ -38,26 +39,27 @@ export default function RawTransactionModal({
|
|||||||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||||
{/* Background overlay */}
|
{/* Background overlay */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
className="fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Modal panel */}
|
{/* Modal panel */}
|
||||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
|
<div className="inline-block align-bottom bg-card rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full border">
|
||||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
<div className="bg-card px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
<h3 className="text-lg font-medium text-foreground">
|
||||||
Raw Transaction Data
|
Raw Transaction Data
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<Button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
disabled={!rawTransaction}
|
disabled={!rawTransaction}
|
||||||
className="inline-flex items-center px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<Check className="h-4 w-4 mr-1 text-green-600" />
|
<Check className="h-4 w-4 mr-1 text-green-600 dark:text-green-400" />
|
||||||
Copied!
|
Copied!
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -66,37 +68,34 @@ export default function RawTransactionModal({
|
|||||||
Copy JSON
|
Copy JSON
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button onClick={onClose} variant="ghost" size="sm">
|
||||||
onClick={onClose}
|
|
||||||
className="inline-flex items-center p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5" />
|
<X className="h-5 w-5" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-muted-foreground">
|
||||||
Transaction ID:{" "}
|
Transaction ID:{" "}
|
||||||
<code className="bg-gray-100 px-2 py-1 rounded text-xs">
|
<code className="bg-muted px-2 py-1 rounded text-xs text-foreground">
|
||||||
{transactionId}
|
{transactionId}
|
||||||
</code>
|
</code>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rawTransaction ? (
|
{rawTransaction ? (
|
||||||
<div className="bg-gray-50 rounded-lg p-4 overflow-auto max-h-96">
|
<div className="bg-muted rounded-lg p-4 overflow-auto max-h-96">
|
||||||
<pre className="text-sm text-gray-800 whitespace-pre-wrap">
|
<pre className="text-sm text-foreground whitespace-pre-wrap">
|
||||||
{JSON.stringify(rawTransaction, null, 2)}
|
{JSON.stringify(rawTransaction, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-gray-50 rounded-lg p-8 text-center">
|
<div className="bg-muted rounded-lg p-8 text-center">
|
||||||
<p className="text-gray-600">
|
<p className="text-foreground">
|
||||||
Raw transaction data is not available for this transaction.
|
Raw transaction data is not available for this transaction.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
Try refreshing the page or check if the transaction was
|
Try refreshing the page or check if the transaction was
|
||||||
fetched with summary_only=false.
|
fetched with summary_only=false.
|
||||||
</p>
|
</p>
|
||||||
@@ -104,14 +103,14 @@ export default function RawTransactionModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
<div className="bg-muted/50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm"
|
className="w-full sm:ml-3 sm:w-auto"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,22 +43,22 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
"fixed inset-y-0 left-0 z-50 w-64 bg-card 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",
|
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 justify-between h-16 px-6 border-b border-border">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
className="flex items-center space-x-2 hover:opacity-80 transition-opacity"
|
||||||
>
|
>
|
||||||
<CreditCard className="h-8 w-8 text-blue-600" />
|
<CreditCard className="h-8 w-8 text-primary" />
|
||||||
<h1 className="text-xl font-bold text-gray-900">Leggen</h1>
|
<h1 className="text-xl font-bold text-card-foreground">Leggen</h1>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className="lg:hidden p-1 rounded-md text-gray-400 hover:text-gray-500"
|
className="lg:hidden p-1 rounded-md text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-6 w-6" />
|
<X className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
@@ -71,11 +71,12 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
|||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className={`flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors ${
|
className={cn(
|
||||||
|
"flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors",
|
||||||
location.pathname === item.to
|
location.pathname === item.to
|
||||||
? "bg-blue-100 text-blue-700"
|
? "bg-primary text-primary-foreground"
|
||||||
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100"
|
: "text-card-foreground hover:text-card-foreground hover:bg-accent",
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="mr-3 h-5 w-5" />
|
<item.icon className="mr-3 h-5 w-5" />
|
||||||
{item.name}
|
{item.name}
|
||||||
@@ -85,18 +86,18 @@ export default function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Account Summary in Sidebar */}
|
{/* Account Summary in Sidebar */}
|
||||||
<div className="px-6 py-4 border-t border-gray-200 mt-auto">
|
<div className="px-6 py-4 border-t border-border mt-auto">
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-muted rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-gray-600">
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
Total Balance
|
Total Balance
|
||||||
</span>
|
</span>
|
||||||
<TrendingUp className="h-4 w-4 text-green-500" />
|
<TrendingUp className="h-4 w-4 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-gray-900 mt-1">
|
<p className="text-2xl font-bold text-foreground mt-1">
|
||||||
{formatCurrency(totalBalance)}
|
{formatCurrency(totalBalance)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{accounts?.length || 0} accounts
|
{accounts?.length || 0} accounts
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
103
frontend/src/components/TransactionSkeleton.tsx
Normal file
103
frontend/src/components/TransactionSkeleton.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
interface TransactionSkeletonProps {
|
||||||
|
rows?: number;
|
||||||
|
view?: "table" | "mobile";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TransactionSkeleton({
|
||||||
|
rows = 5,
|
||||||
|
view = "table",
|
||||||
|
}: TransactionSkeletonProps) {
|
||||||
|
const skeletonRows = Array.from({ length: rows }, (_, index) => index);
|
||||||
|
|
||||||
|
if (view === "mobile") {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
|
||||||
|
{skeletonRows.map((_, index) => (
|
||||||
|
<div key={index} className="p-4 animate-pulse">
|
||||||
|
<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 bg-gray-200 flex-shrink-0">
|
||||||
|
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right ml-3 flex-shrink-0 space-y-2">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-20"></div>
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 ml-auto"></div>
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-12 ml-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-16 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-12 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-8 animate-pulse"></div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{skeletonRows.map((_, index) => (
|
||||||
|
<tr key={index} className="animate-pulse">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<div className="p-2 rounded-full bg-gray-200 flex-shrink-0">
|
||||||
|
<div className="h-4 w-4 bg-gray-300 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-24 ml-auto mb-1"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="h-4 bg-gray-200 rounded w-20"></div>
|
||||||
|
<div className="h-3 bg-gray-200 rounded w-16"></div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="h-6 bg-gray-200 rounded w-12"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Filter,
|
|
||||||
Search,
|
|
||||||
TrendingUp,
|
|
||||||
TrendingDown,
|
|
||||||
Calendar,
|
|
||||||
RefreshCw,
|
|
||||||
AlertCircle,
|
|
||||||
X,
|
|
||||||
Eye,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { apiClient } from "../lib/api";
|
|
||||||
import { formatCurrency, formatDate } from "../lib/utils";
|
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
|
||||||
import RawTransactionModal from "./RawTransactionModal";
|
|
||||||
import type { Account, Transaction, ApiResponse } 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 [showFilters, setShowFilters] = useState(false);
|
|
||||||
const [showRawModal, setShowRawModal] = useState(false);
|
|
||||||
const [selectedTransaction, setSelectedTransaction] =
|
|
||||||
useState<Transaction | null>(null);
|
|
||||||
|
|
||||||
const { data: accounts } = useQuery<Account[]>({
|
|
||||||
queryKey: ["accounts"],
|
|
||||||
queryFn: apiClient.getAccounts,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: transactionsResponse,
|
|
||||||
isLoading: transactionsLoading,
|
|
||||||
error: transactionsError,
|
|
||||||
refetch: refetchTransactions,
|
|
||||||
} = useQuery<ApiResponse<Transaction[]>>({
|
|
||||||
queryKey: ["transactions", selectedAccount, startDate, endDate],
|
|
||||||
queryFn: () =>
|
|
||||||
apiClient.getTransactions({
|
|
||||||
accountId: selectedAccount || undefined,
|
|
||||||
startDate: startDate || undefined,
|
|
||||||
endDate: endDate || undefined,
|
|
||||||
summaryOnly: false, // Always fetch raw transaction data
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const transactions = transactionsResponse?.data || [];
|
|
||||||
|
|
||||||
const filteredTransactions = (transactions || []).filter(
|
|
||||||
(transaction: 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,
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const description = transaction.description || "";
|
|
||||||
const creditorName = transaction.creditor_name || "";
|
|
||||||
const debtorName = transaction.debtor_name || "";
|
|
||||||
const reference = transaction.reference || "";
|
|
||||||
|
|
||||||
const matchesSearch =
|
|
||||||
searchTerm === "" ||
|
|
||||||
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
reference.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
return matchesSearch;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setSearchTerm("");
|
|
||||||
setSelectedAccount("");
|
|
||||||
setStartDate("");
|
|
||||||
setEndDate("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewRaw = (transaction: Transaction) => {
|
|
||||||
setSelectedTransaction(transaction);
|
|
||||||
setShowRawModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseModal = () => {
|
|
||||||
setShowRawModal(false);
|
|
||||||
setSelectedTransaction(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasActiveFilters =
|
|
||||||
searchTerm || selectedAccount || startDate || endDate;
|
|
||||||
|
|
||||||
if (transactionsLoading) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
|
||||||
<LoadingSpinner message="Loading transactions..." />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transactionsError) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<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>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
Unable to fetch transactions from the Leggen API.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => refetchTransactions()}
|
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="bg-white rounded-lg shadow">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900">Transactions</h3>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{hasActiveFilters && (
|
|
||||||
<button
|
|
||||||
onClick={clearFilters}
|
|
||||||
className="inline-flex items-center px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3 mr-1" />
|
|
||||||
Clear filters
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
|
||||||
className="inline-flex items-center px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
|
|
||||||
>
|
|
||||||
<Filter className="h-4 w-4 mr-2" />
|
|
||||||
Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showFilters && (
|
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
{/* Search */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Search
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
placeholder="Description, name, reference..."
|
|
||||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account Filter */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Account
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedAccount}
|
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Start Date */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Start Date
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* End Date */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
End Date
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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" : ""}
|
|
||||||
{selectedAccount && accounts && (
|
|
||||||
<span className="ml-1">
|
|
||||||
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Transactions List */}
|
|
||||||
{filteredTransactions.length === 0 ? (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 text-center">
|
|
||||||
<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>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{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: Transaction) => {
|
|
||||||
const account = accounts?.find(
|
|
||||||
(acc) => acc.id === transaction.account_id,
|
|
||||||
);
|
|
||||||
const isPositive = transaction.transaction_value > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={`${transaction.account_id}-${transaction.transaction_id}`}
|
|
||||||
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"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isPositive ? (
|
|
||||||
<TrendingUp className="h-4 w-4 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<TrendingDown className="h-4 w-4 text-red-600" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<h4 className="text-sm font-medium text-gray-900 mb-1">
|
|
||||||
{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) && (
|
|
||||||
<p>
|
|
||||||
{isPositive ? "From: " : "To: "}
|
|
||||||
{transaction.creditor_name ||
|
|
||||||
transaction.debtor_name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{transaction.reference && (
|
|
||||||
<p>Ref: {transaction.reference}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{transaction.internal_transaction_id && (
|
|
||||||
<p>ID: {transaction.internal_transaction_id}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right ml-4">
|
|
||||||
<div className="flex items-center justify-end space-x-2 mb-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleViewRaw(transaction)}
|
|
||||||
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
|
||||||
title="View raw transaction data"
|
|
||||||
>
|
|
||||||
<Eye className="h-3 w-3 mr-1" />
|
|
||||||
Raw
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className={`text-lg font-semibold ${
|
|
||||||
isPositive ? "text-green-600" : "text-red-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isPositive ? "+" : ""}
|
|
||||||
{formatCurrency(
|
|
||||||
transaction.transaction_value,
|
|
||||||
transaction.transaction_currency,
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
{transaction.transaction_date
|
|
||||||
? formatDate(transaction.transaction_date)
|
|
||||||
: "No date"}
|
|
||||||
</p>
|
|
||||||
{transaction.booking_date &&
|
|
||||||
transaction.booking_date !==
|
|
||||||
transaction.transaction_date && (
|
|
||||||
<p className="text-xs text-gray-400">
|
|
||||||
Booked: {formatDate(transaction.booking_date)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Raw Transaction Modal */}
|
|
||||||
<RawTransactionModal
|
|
||||||
isOpen={showRawModal}
|
|
||||||
onClose={handleCloseModal}
|
|
||||||
rawTransaction={selectedTransaction?.raw_transaction}
|
|
||||||
transactionId={selectedTransaction?.transaction_id || "unknown"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -13,68 +13,101 @@ import type {
|
|||||||
ColumnFiltersState,
|
ColumnFiltersState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import {
|
import {
|
||||||
Filter,
|
|
||||||
Search,
|
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
Calendar,
|
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
X,
|
|
||||||
Eye,
|
Eye,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiClient } from "../lib/api";
|
import { apiClient } from "../lib/api";
|
||||||
import { formatCurrency, formatDate } from "../lib/utils";
|
import { formatCurrency, formatDate } from "../lib/utils";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import TransactionSkeleton from "./TransactionSkeleton";
|
||||||
|
import FiltersSkeleton from "./FiltersSkeleton";
|
||||||
import RawTransactionModal from "./RawTransactionModal";
|
import RawTransactionModal from "./RawTransactionModal";
|
||||||
import type { Account, Transaction, ApiResponse } from "../types/api";
|
import { FilterBar, type FilterState } from "./filters";
|
||||||
|
import { DataTablePagination } from "./ui/data-table-pagination";
|
||||||
|
import { Card, CardContent } from "./ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import type { Account, Transaction, ApiResponse, Balance } from "../types/api";
|
||||||
|
|
||||||
export default function TransactionsTable() {
|
export default function TransactionsTable() {
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
// Filter state consolidated into a single object
|
||||||
const [selectedAccount, setSelectedAccount] = useState<string>("");
|
const [filterState, setFilterState] = useState<FilterState>({
|
||||||
const [startDate, setStartDate] = useState("");
|
searchTerm: "",
|
||||||
const [endDate, setEndDate] = useState("");
|
selectedAccount: "",
|
||||||
const [minAmount, setMinAmount] = useState("");
|
startDate: "",
|
||||||
const [maxAmount, setMaxAmount] = useState("");
|
endDate: "",
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
minAmount: "",
|
||||||
|
maxAmount: "",
|
||||||
|
});
|
||||||
|
|
||||||
const [showRawModal, setShowRawModal] = useState(false);
|
const [showRawModal, setShowRawModal] = useState(false);
|
||||||
const [selectedTransaction, setSelectedTransaction] =
|
const [selectedTransaction, setSelectedTransaction] =
|
||||||
useState<Transaction | null>(null);
|
useState<Transaction | null>(null);
|
||||||
|
const [showRunningBalance, setShowRunningBalance] = useState(true);
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [perPage, setPerPage] = useState(50);
|
const [perPage, setPerPage] = useState(50);
|
||||||
|
|
||||||
// Debounced search state
|
// Debounced search state
|
||||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
|
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(
|
||||||
|
filterState.searchTerm,
|
||||||
|
);
|
||||||
|
|
||||||
// Table state (remove pagination from table)
|
// Table state (remove pagination from table)
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
|
|
||||||
|
// Helper function to update filter state
|
||||||
|
const handleFilterChange = (key: keyof FilterState, value: string) => {
|
||||||
|
setFilterState((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to clear all filters
|
||||||
|
const handleClearFilters = () => {
|
||||||
|
setFilterState({
|
||||||
|
searchTerm: "",
|
||||||
|
selectedAccount: "",
|
||||||
|
startDate: "",
|
||||||
|
endDate: "",
|
||||||
|
minAmount: "",
|
||||||
|
maxAmount: "",
|
||||||
|
});
|
||||||
|
setColumnFilters([]);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
// Debounce search term to prevent excessive API calls
|
// Debounce search term to prevent excessive API calls
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedSearchTerm(searchTerm);
|
setDebouncedSearchTerm(filterState.searchTerm);
|
||||||
}, 300); // 300ms delay
|
}, 300); // 300ms delay
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm]);
|
}, [filterState.searchTerm]);
|
||||||
|
|
||||||
// Reset pagination when search term changes
|
// Reset pagination when search term changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debouncedSearchTerm !== searchTerm) {
|
if (debouncedSearchTerm !== filterState.searchTerm) {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}
|
}
|
||||||
}, [debouncedSearchTerm, searchTerm]);
|
}, [debouncedSearchTerm, filterState.searchTerm]);
|
||||||
|
|
||||||
const { data: accounts } = useQuery<Account[]>({
|
const { data: accounts } = useQuery<Account[]>({
|
||||||
queryKey: ["accounts"],
|
queryKey: ["accounts"],
|
||||||
queryFn: apiClient.getAccounts,
|
queryFn: apiClient.getAccounts,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: balances } = useQuery<Balance[]>({
|
||||||
|
queryKey: ["balances"],
|
||||||
|
queryFn: apiClient.getBalances,
|
||||||
|
enabled: showRunningBalance,
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: transactionsResponse,
|
data: transactionsResponse,
|
||||||
isLoading: transactionsLoading,
|
isLoading: transactionsLoading,
|
||||||
@@ -83,22 +116,30 @@ export default function TransactionsTable() {
|
|||||||
} = useQuery<ApiResponse<Transaction[]>>({
|
} = useQuery<ApiResponse<Transaction[]>>({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
"transactions",
|
"transactions",
|
||||||
selectedAccount,
|
filterState.selectedAccount,
|
||||||
startDate,
|
filterState.startDate,
|
||||||
endDate,
|
filterState.endDate,
|
||||||
currentPage,
|
currentPage,
|
||||||
perPage,
|
perPage,
|
||||||
debouncedSearchTerm,
|
debouncedSearchTerm,
|
||||||
|
filterState.minAmount,
|
||||||
|
filterState.maxAmount,
|
||||||
],
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiClient.getTransactions({
|
apiClient.getTransactions({
|
||||||
accountId: selectedAccount || undefined,
|
accountId: filterState.selectedAccount || undefined,
|
||||||
startDate: startDate || undefined,
|
startDate: filterState.startDate || undefined,
|
||||||
endDate: endDate || undefined,
|
endDate: filterState.endDate || undefined,
|
||||||
page: currentPage,
|
page: currentPage,
|
||||||
perPage: perPage,
|
perPage: perPage,
|
||||||
search: debouncedSearchTerm || undefined,
|
search: debouncedSearchTerm || undefined,
|
||||||
summaryOnly: false,
|
summaryOnly: false,
|
||||||
|
minAmount: filterState.minAmount
|
||||||
|
? parseFloat(filterState.minAmount)
|
||||||
|
: undefined,
|
||||||
|
maxAmount: filterState.maxAmount
|
||||||
|
? parseFloat(filterState.maxAmount)
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,7 +147,7 @@ export default function TransactionsTable() {
|
|||||||
const pagination = transactionsResponse?.pagination;
|
const pagination = transactionsResponse?.pagination;
|
||||||
|
|
||||||
// Check if search is currently debouncing
|
// Check if search is currently debouncing
|
||||||
const isSearchLoading = searchTerm !== debouncedSearchTerm;
|
const isSearchLoading = filterState.searchTerm !== debouncedSearchTerm;
|
||||||
|
|
||||||
// Reset pagination when total becomes 0 (no results)
|
// Reset pagination when total becomes 0 (no results)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -115,46 +156,16 @@ export default function TransactionsTable() {
|
|||||||
}
|
}
|
||||||
}, [pagination, currentPage]);
|
}, [pagination, currentPage]);
|
||||||
|
|
||||||
const clearFilters = () => {
|
// Reset pagination when filters change
|
||||||
setSearchTerm("");
|
|
||||||
setSelectedAccount("");
|
|
||||||
setStartDate("");
|
|
||||||
setEndDate("");
|
|
||||||
setMinAmount("");
|
|
||||||
setMaxAmount("");
|
|
||||||
setColumnFilters([]);
|
|
||||||
setCurrentPage(1); // Reset to first page when clearing filters
|
|
||||||
};
|
|
||||||
|
|
||||||
const setQuickDateFilter = (days: number) => {
|
|
||||||
const endDate = new Date();
|
|
||||||
const startDate = new Date();
|
|
||||||
startDate.setDate(endDate.getDate() - days);
|
|
||||||
|
|
||||||
setStartDate(startDate.toISOString().split("T")[0]);
|
|
||||||
setEndDate(endDate.toISOString().split("T")[0]);
|
|
||||||
setCurrentPage(1); // Reset to first page when changing date filters
|
|
||||||
};
|
|
||||||
|
|
||||||
const setThisMonthFilter = () => {
|
|
||||||
const now = new Date();
|
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
|
||||||
|
|
||||||
setStartDate(startOfMonth.toISOString().split("T")[0]);
|
|
||||||
setEndDate(endOfMonth.toISOString().split("T")[0]);
|
|
||||||
setCurrentPage(1); // Reset to first page when changing date filters
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reset pagination when account filter changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [selectedAccount]);
|
}, [
|
||||||
|
filterState.selectedAccount,
|
||||||
// Reset pagination when date filters change
|
filterState.startDate,
|
||||||
useEffect(() => {
|
filterState.endDate,
|
||||||
setCurrentPage(1);
|
filterState.minAmount,
|
||||||
}, [startDate, endDate]);
|
filterState.maxAmount,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleViewRaw = (transaction: Transaction) => {
|
const handleViewRaw = (transaction: Transaction) => {
|
||||||
setSelectedTransaction(transaction);
|
setSelectedTransaction(transaction);
|
||||||
@@ -167,12 +178,60 @@ export default function TransactionsTable() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const hasActiveFilters =
|
const hasActiveFilters =
|
||||||
searchTerm ||
|
filterState.searchTerm ||
|
||||||
selectedAccount ||
|
filterState.selectedAccount ||
|
||||||
startDate ||
|
filterState.startDate ||
|
||||||
endDate ||
|
filterState.endDate ||
|
||||||
minAmount ||
|
filterState.minAmount ||
|
||||||
maxAmount;
|
filterState.maxAmount;
|
||||||
|
|
||||||
|
// Calculate running balances
|
||||||
|
const calculateRunningBalances = (transactions: Transaction[]) => {
|
||||||
|
if (!balances || !showRunningBalance) return {};
|
||||||
|
|
||||||
|
const runningBalances: { [key: string]: number } = {};
|
||||||
|
const accountBalanceMap = new Map<string, number>();
|
||||||
|
|
||||||
|
// Create a map of account current balances
|
||||||
|
balances.forEach((balance) => {
|
||||||
|
if (balance.balance_type === "expected") {
|
||||||
|
accountBalanceMap.set(balance.account_id, balance.balance_amount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group transactions by account
|
||||||
|
const transactionsByAccount = new Map<string, Transaction[]>();
|
||||||
|
transactions.forEach((txn) => {
|
||||||
|
if (!transactionsByAccount.has(txn.account_id)) {
|
||||||
|
transactionsByAccount.set(txn.account_id, []);
|
||||||
|
}
|
||||||
|
transactionsByAccount.get(txn.account_id)!.push(txn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate running balance for each account
|
||||||
|
transactionsByAccount.forEach((accountTransactions, accountId) => {
|
||||||
|
const currentBalance = accountBalanceMap.get(accountId) || 0;
|
||||||
|
let runningBalance = currentBalance;
|
||||||
|
|
||||||
|
// Sort transactions by date (newest first) to work backwards
|
||||||
|
const sortedTransactions = [...accountTransactions].sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.transaction_date).getTime() -
|
||||||
|
new Date(a.transaction_date).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate running balance by working backwards from current balance
|
||||||
|
sortedTransactions.forEach((txn) => {
|
||||||
|
runningBalances[`${txn.account_id}-${txn.transaction_id}`] =
|
||||||
|
runningBalance;
|
||||||
|
runningBalance -= txn.transaction_value;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return runningBalances;
|
||||||
|
};
|
||||||
|
|
||||||
|
const runningBalances = calculateRunningBalances(transactions);
|
||||||
|
|
||||||
// Define columns
|
// Define columns
|
||||||
const columns: ColumnDef<Transaction>[] = [
|
const columns: ColumnDef<Transaction>[] = [
|
||||||
@@ -200,10 +259,10 @@ export default function TransactionsTable() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
<h4 className="text-sm font-medium text-foreground truncate">
|
||||||
{transaction.description}
|
{transaction.description}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="text-xs text-gray-500 space-y-1">
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
{account && (
|
{account && (
|
||||||
<p className="truncate">
|
<p className="truncate">
|
||||||
{account.name || "Unnamed Account"} •{" "}
|
{account.name || "Unnamed Account"} •{" "}
|
||||||
@@ -249,19 +308,42 @@ export default function TransactionsTable() {
|
|||||||
},
|
},
|
||||||
sortingFn: "basic",
|
sortingFn: "basic",
|
||||||
},
|
},
|
||||||
|
...(showRunningBalance
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "running_balance",
|
||||||
|
header: "Running Balance",
|
||||||
|
cell: ({ row }: { row: { original: Transaction } }) => {
|
||||||
|
const transaction = row.original;
|
||||||
|
const balanceKey = `${transaction.account_id}-${transaction.transaction_id}`;
|
||||||
|
const balance = runningBalances[balanceKey];
|
||||||
|
|
||||||
|
if (balance === undefined) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{formatCurrency(balance, transaction.transaction_currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
accessorKey: "transaction_date",
|
accessorKey: "transaction_date",
|
||||||
header: "Date",
|
header: "Date",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const transaction = row.original;
|
const transaction = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="text-sm text-gray-900">
|
<div className="text-sm text-foreground">
|
||||||
{transaction.transaction_date
|
{transaction.transaction_date
|
||||||
? formatDate(transaction.transaction_date)
|
? formatDate(transaction.transaction_date)
|
||||||
: "No date"}
|
: "No date"}
|
||||||
{transaction.booking_date &&
|
{transaction.booking_date &&
|
||||||
transaction.booking_date !== transaction.transaction_date && (
|
transaction.booking_date !== transaction.transaction_date && (
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-muted-foreground">
|
||||||
Booked: {formatDate(transaction.booking_date)}
|
Booked: {formatDate(transaction.booking_date)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -278,7 +360,7 @@ export default function TransactionsTable() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleViewRaw(transaction)}
|
onClick={() => handleViewRaw(transaction)}
|
||||||
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-accent transition-colors"
|
||||||
title="View raw transaction data"
|
title="View raw transaction data"
|
||||||
>
|
>
|
||||||
<Eye className="h-3 w-3 mr-1" />
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
@@ -300,9 +382,10 @@ export default function TransactionsTable() {
|
|||||||
state: {
|
state: {
|
||||||
sorting,
|
sorting,
|
||||||
columnFilters,
|
columnFilters,
|
||||||
globalFilter: searchTerm,
|
globalFilter: filterState.searchTerm,
|
||||||
},
|
},
|
||||||
onGlobalFilterChange: setSearchTerm,
|
onGlobalFilterChange: (value: string) =>
|
||||||
|
handleFilterChange("searchTerm", value),
|
||||||
globalFilterFn: (row, _columnId, filterValue) => {
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
// Custom global filter that searches multiple fields
|
// Custom global filter that searches multiple fields
|
||||||
const transaction = row.original;
|
const transaction = row.original;
|
||||||
@@ -324,204 +407,55 @@ export default function TransactionsTable() {
|
|||||||
|
|
||||||
if (transactionsLoading) {
|
if (transactionsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow">
|
<div className="space-y-6">
|
||||||
<LoadingSpinner message="Loading transactions..." />
|
<FiltersSkeleton />
|
||||||
|
<TransactionSkeleton rows={10} view="table" />
|
||||||
|
<div className="md:hidden">
|
||||||
|
<TransactionSkeleton rows={10} view="mobile" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transactionsError) {
|
if (transactionsError) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Alert variant="destructive">
|
||||||
<div className="flex items-center justify-center text-center">
|
<AlertCircle className="h-4 w-4" />
|
||||||
<div>
|
<AlertTitle>Failed to load transactions</AlertTitle>
|
||||||
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
|
<AlertDescription className="space-y-3">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<p>Unable to fetch transactions from the Leggen API.</p>
|
||||||
Failed to load transactions
|
<Button
|
||||||
</h3>
|
onClick={() => refetchTransactions()}
|
||||||
<p className="text-gray-600 mb-4">
|
variant="outline"
|
||||||
Unable to fetch transactions from the Leggen API.
|
size="sm"
|
||||||
</p>
|
>
|
||||||
<button
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
onClick={() => refetchTransactions()}
|
Retry
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
</Button>
|
||||||
>
|
</AlertDescription>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
</Alert>
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Filters */}
|
{/* New FilterBar */}
|
||||||
<div className="bg-white rounded-lg shadow">
|
<FilterBar
|
||||||
<div className="px-6 py-4 border-b border-gray-200">
|
filterState={filterState}
|
||||||
<div className="flex items-center justify-between">
|
onFilterChange={handleFilterChange}
|
||||||
<h3 className="text-lg font-medium text-gray-900">Transactions</h3>
|
onClearFilters={handleClearFilters}
|
||||||
<div className="flex items-center space-x-2">
|
accounts={accounts}
|
||||||
{hasActiveFilters && (
|
isSearchLoading={isSearchLoading}
|
||||||
<button
|
showRunningBalance={showRunningBalance}
|
||||||
onClick={clearFilters}
|
onToggleRunningBalance={() =>
|
||||||
className="inline-flex items-center px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded-full hover:bg-gray-200 transition-colors"
|
setShowRunningBalance(!showRunningBalance)
|
||||||
>
|
}
|
||||||
<X className="h-3 w-3 mr-1" />
|
/>
|
||||||
Clear filters
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowFilters(!showFilters)}
|
|
||||||
className="inline-flex items-center px-3 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 transition-colors"
|
|
||||||
>
|
|
||||||
<Filter className="h-4 w-4 mr-2" />
|
|
||||||
Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showFilters && (
|
{/* Results Summary */}
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
<Card>
|
||||||
{/* Quick Date Filters */}
|
<CardContent className="px-6 py-3 bg-muted/30 border-b border-border">
|
||||||
<div className="mb-4">
|
<p className="text-sm text-muted-foreground">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Quick Filters
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setQuickDateFilter(7)}
|
|
||||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
|
||||||
>
|
|
||||||
Last 7 days
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setQuickDateFilter(30)}
|
|
||||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
|
||||||
>
|
|
||||||
Last 30 days
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={setThisMonthFilter}
|
|
||||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded-full hover:bg-blue-200 transition-colors"
|
|
||||||
>
|
|
||||||
This month
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="sm:col-span-2 lg:col-span-1">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Search
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
placeholder="Description, name, reference..."
|
|
||||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
{isSearchLoading && (
|
|
||||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
|
||||||
<div className="animate-spin h-4 w-4 border-2 border-gray-300 border-t-blue-500 rounded-full"></div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Account Filter */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Account
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={selectedAccount}
|
|
||||||
onChange={(e) => setSelectedAccount(e.target.value)}
|
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Start Date */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Start Date
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* End Date */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
End Date
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="pl-10 pr-3 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Amount Range Filters */}
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Min Amount
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={minAmount}
|
|
||||||
onChange={(e) => setMinAmount(e.target.value)}
|
|
||||||
placeholder="0.00"
|
|
||||||
step="0.01"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Max Amount
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={maxAmount}
|
|
||||||
onChange={(e) => setMaxAmount(e.target.value)}
|
|
||||||
placeholder="1000.00"
|
|
||||||
step="0.01"
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Results Summary */}
|
|
||||||
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Showing {transactions.length} transaction
|
Showing {transactions.length} transaction
|
||||||
{transactions.length !== 1 ? "s" : ""} (
|
{transactions.length !== 1 ? "s" : ""} (
|
||||||
{pagination ? (
|
{pagination ? (
|
||||||
@@ -537,28 +471,32 @@ export default function TransactionsTable() {
|
|||||||
"loading..."
|
"loading..."
|
||||||
)}
|
)}
|
||||||
)
|
)
|
||||||
{selectedAccount && accounts && (
|
{filterState.selectedAccount && accounts && (
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
|
for{" "}
|
||||||
|
{
|
||||||
|
accounts.find((acc) => acc.id === filterState.selectedAccount)
|
||||||
|
?.name
|
||||||
|
}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</CardContent>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Responsive Table/Cards */}
|
{/* Responsive Table/Cards */}
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
{/* Desktop Table View (hidden on mobile) */}
|
{/* Desktop Table View (hidden on mobile) */}
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-border">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-muted/50">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th
|
<th
|
||||||
key={header.id}
|
key={header.id}
|
||||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider cursor-pointer hover:bg-muted"
|
||||||
onClick={header.column.getToggleSortingHandler()}
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
@@ -575,15 +513,15 @@ export default function TransactionsTable() {
|
|||||||
<ChevronUp
|
<ChevronUp
|
||||||
className={`h-3 w-3 ${
|
className={`h-3 w-3 ${
|
||||||
header.column.getIsSorted() === "asc"
|
header.column.getIsSorted() === "asc"
|
||||||
? "text-blue-600"
|
? "text-primary"
|
||||||
: "text-gray-400"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`h-3 w-3 -mt-1 ${
|
className={`h-3 w-3 -mt-1 ${
|
||||||
header.column.getIsSorted() === "desc"
|
header.column.getIsSorted() === "desc"
|
||||||
? "text-blue-600"
|
? "text-primary"
|
||||||
: "text-gray-400"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -594,20 +532,20 @@ export default function TransactionsTable() {
|
|||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-card divide-y divide-border">
|
||||||
{table.getRowModel().rows.length === 0 ? (
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={columns.length}
|
colSpan={columns.length}
|
||||||
className="px-6 py-12 text-center"
|
className="px-6 py-12 text-center"
|
||||||
>
|
>
|
||||||
<div className="text-gray-400 mb-4">
|
<div className="text-muted-foreground mb-4">
|
||||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
No transactions found
|
No transactions found
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-muted-foreground">
|
||||||
{hasActiveFilters
|
{hasActiveFilters
|
||||||
? "Try adjusting your filters to see more results."
|
? "Try adjusting your filters to see more results."
|
||||||
: "No transactions are available for the selected criteria."}
|
: "No transactions are available for the selected criteria."}
|
||||||
@@ -616,9 +554,12 @@ export default function TransactionsTable() {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<tr key={row.id} className="hover:bg-gray-50">
|
<tr key={row.id} className="hover:bg-muted/50">
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<td key={cell.id} className="px-6 py-4 whitespace-nowrap">
|
<td
|
||||||
|
key={cell.id}
|
||||||
|
className="px-6 py-4 whitespace-nowrap"
|
||||||
|
>
|
||||||
{flexRender(
|
{flexRender(
|
||||||
cell.column.columnDef.cell,
|
cell.column.columnDef.cell,
|
||||||
cell.getContext(),
|
cell.getContext(),
|
||||||
@@ -637,20 +578,20 @@ export default function TransactionsTable() {
|
|||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
{table.getRowModel().rows.length === 0 ? (
|
{table.getRowModel().rows.length === 0 ? (
|
||||||
<div className="px-6 py-12 text-center">
|
<div className="px-6 py-12 text-center">
|
||||||
<div className="text-gray-400 mb-4">
|
<div className="text-muted-foreground mb-4">
|
||||||
<TrendingUp className="h-12 w-12 mx-auto" />
|
<TrendingUp className="h-12 w-12 mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
No transactions found
|
No transactions found
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-muted-foreground">
|
||||||
{hasActiveFilters
|
{hasActiveFilters
|
||||||
? "Try adjusting your filters to see more results."
|
? "Try adjusting your filters to see more results."
|
||||||
: "No transactions are available for the selected criteria."}
|
: "No transactions are available for the selected criteria."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-gray-200">
|
<div className="divide-y divide-border">
|
||||||
{table.getRowModel().rows.map((row) => {
|
{table.getRowModel().rows.map((row) => {
|
||||||
const transaction = row.original;
|
const transaction = row.original;
|
||||||
const account = accounts?.find(
|
const account = accounts?.find(
|
||||||
@@ -661,7 +602,7 @@ export default function TransactionsTable() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.id}
|
key={row.id}
|
||||||
className="p-4 hover:bg-gray-50 transition-colors"
|
className="p-4 hover:bg-muted/50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -678,33 +619,39 @@ export default function TransactionsTable() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="text-sm font-medium text-gray-900 break-words">
|
<h4 className="text-sm font-medium text-foreground break-words">
|
||||||
{transaction.description}
|
{transaction.description}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="text-xs text-gray-500 space-y-1 mt-1">
|
<div className="text-xs text-muted-foreground space-y-1 mt-1">
|
||||||
{account && (
|
{account && (
|
||||||
<p className="break-words">
|
<p className="break-words">
|
||||||
{account.name || "Unnamed Account"} •{" "}
|
{account.name || "Unnamed Account"} •{" "}
|
||||||
{account.institution_id}
|
{account.institution_id}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{(transaction.creditor_name || transaction.debtor_name) && (
|
{(transaction.creditor_name ||
|
||||||
|
transaction.debtor_name) && (
|
||||||
<p className="break-words">
|
<p className="break-words">
|
||||||
{isPositive ? "From: " : "To: "}
|
{isPositive ? "From: " : "To: "}
|
||||||
{transaction.creditor_name || transaction.debtor_name}
|
{transaction.creditor_name ||
|
||||||
|
transaction.debtor_name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{transaction.reference && (
|
{transaction.reference && (
|
||||||
<p className="break-words">Ref: {transaction.reference}</p>
|
<p className="break-words">
|
||||||
|
Ref: {transaction.reference}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-gray-400">
|
<p className="text-muted-foreground">
|
||||||
{transaction.transaction_date
|
{transaction.transaction_date
|
||||||
? formatDate(transaction.transaction_date)
|
? formatDate(transaction.transaction_date)
|
||||||
: "No date"}
|
: "No date"}
|
||||||
{transaction.booking_date &&
|
{transaction.booking_date &&
|
||||||
transaction.booking_date !== transaction.transaction_date && (
|
transaction.booking_date !==
|
||||||
|
transaction.transaction_date && (
|
||||||
<span className="ml-2">
|
<span className="ml-2">
|
||||||
(Booked: {formatDate(transaction.booking_date)})
|
(Booked:{" "}
|
||||||
|
{formatDate(transaction.booking_date)})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
@@ -724,9 +671,20 @@ export default function TransactionsTable() {
|
|||||||
transaction.transaction_currency,
|
transaction.transaction_currency,
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
{showRunningBalance && (
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
Balance:{" "}
|
||||||
|
{formatCurrency(
|
||||||
|
runningBalances[
|
||||||
|
`${transaction.account_id}-${transaction.transaction_id}`
|
||||||
|
] || 0,
|
||||||
|
transaction.transaction_currency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleViewRaw(transaction)}
|
onClick={() => handleViewRaw(transaction)}
|
||||||
className="inline-flex items-center px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
|
className="inline-flex items-center px-2 py-1 text-xs bg-muted text-muted-foreground rounded hover:bg-accent transition-colors"
|
||||||
title="View raw transaction data"
|
title="View raw transaction data"
|
||||||
>
|
>
|
||||||
<Eye className="h-3 w-3 mr-1" />
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
@@ -743,141 +701,18 @@ export default function TransactionsTable() {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{pagination && (
|
{pagination && (
|
||||||
<div className="bg-white px-4 py-3 flex flex-col sm:flex-row items-center justify-between border-t border-gray-200 space-y-3 sm:space-y-0">
|
<DataTablePagination
|
||||||
{/* Mobile pagination controls */}
|
currentPage={pagination.page}
|
||||||
<div className="flex justify-between w-full sm:hidden">
|
totalPages={pagination.total_pages}
|
||||||
<div className="flex space-x-2">
|
pageSize={pagination.per_page}
|
||||||
<button
|
total={pagination.total}
|
||||||
onClick={() => setCurrentPage(1)}
|
hasNext={pagination.has_next}
|
||||||
disabled={pagination.page === 1}
|
hasPrev={pagination.has_prev}
|
||||||
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
onPageChange={setCurrentPage}
|
||||||
>
|
onPageSizeChange={setPerPage}
|
||||||
First
|
/>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
|
||||||
}
|
|
||||||
disabled={!pagination.has_prev}
|
|
||||||
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage((prev) => prev + 1)}
|
|
||||||
disabled={!pagination.has_next}
|
|
||||||
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(pagination.total_pages)}
|
|
||||||
disabled={pagination.page === pagination.total_pages}
|
|
||||||
className="relative inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Last
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Mobile pagination info */}
|
|
||||||
<div className="text-center w-full sm:hidden">
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Page <span className="font-medium">{pagination.page}</span> of{" "}
|
|
||||||
<span className="font-medium">{pagination.total_pages}</span>
|
|
||||||
<br />
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
Showing {(pagination.page - 1) * pagination.per_page + 1}-
|
|
||||||
{Math.min(pagination.page * pagination.per_page, pagination.total)} of {pagination.total}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Desktop pagination */}
|
|
||||||
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<p className="text-sm text-gray-700">
|
|
||||||
Showing{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{(pagination.page - 1) * pagination.per_page + 1}
|
|
||||||
</span>{" "}
|
|
||||||
to{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{Math.min(
|
|
||||||
pagination.page * pagination.per_page,
|
|
||||||
pagination.total,
|
|
||||||
)}
|
|
||||||
</span>{" "}
|
|
||||||
of <span className="font-medium">{pagination.total}</span>{" "}
|
|
||||||
results
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<label className="text-sm text-gray-700">
|
|
||||||
Rows per page:
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={perPage}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPerPage(Number(e.target.value));
|
|
||||||
setCurrentPage(1); // Reset to first page when changing page size
|
|
||||||
}}
|
|
||||||
className="border border-gray-300 rounded px-2 py-1 text-sm"
|
|
||||||
>
|
|
||||||
{[10, 25, 50, 100].map((pageSize) => (
|
|
||||||
<option key={pageSize} value={pageSize}>
|
|
||||||
{pageSize}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(1)}
|
|
||||||
disabled={pagination.page === 1}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
First
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setCurrentPage((prev) => Math.max(1, prev - 1))
|
|
||||||
}
|
|
||||||
disabled={!pagination.has_prev}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<span className="text-sm text-gray-700">
|
|
||||||
Page <span className="font-medium">{pagination.page}</span>{" "}
|
|
||||||
of{" "}
|
|
||||||
<span className="font-medium">
|
|
||||||
{pagination.total_pages}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage((prev) => prev + 1)}
|
|
||||||
disabled={!pagination.has_next}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage(pagination.total_pages)}
|
|
||||||
disabled={pagination.page === pagination.total_pages}
|
|
||||||
className="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
Last
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* Raw Transaction Modal */}
|
{/* Raw Transaction Modal */}
|
||||||
<RawTransactionModal
|
<RawTransactionModal
|
||||||
|
|||||||
191
frontend/src/components/analytics/BalanceChart.tsx
Normal file
191
frontend/src/components/analytics/BalanceChart.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import type { Balance, Account } from "../../types/api";
|
||||||
|
|
||||||
|
interface BalanceChartProps {
|
||||||
|
data: Balance[];
|
||||||
|
accounts: Account[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
date: string;
|
||||||
|
balance: number;
|
||||||
|
account_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AggregatedDataPoint {
|
||||||
|
date: string;
|
||||||
|
[key: string]: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}>;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceChart({
|
||||||
|
data,
|
||||||
|
accounts,
|
||||||
|
className,
|
||||||
|
}: BalanceChartProps) {
|
||||||
|
// Create a lookup map for account info
|
||||||
|
const accountMap = accounts.reduce(
|
||||||
|
(map, account) => {
|
||||||
|
map[account.id] = account;
|
||||||
|
return map;
|
||||||
|
},
|
||||||
|
{} as Record<string, Account>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper function to get bank name from institution_id
|
||||||
|
const getBankName = (institutionId: string): string => {
|
||||||
|
const bankMapping: Record<string, string> = {
|
||||||
|
REVOLUT_REVOLT21: "Revolut",
|
||||||
|
NUBANK_NUPBBR25: "Nu Pagamentos",
|
||||||
|
BANCOBPI_BBPIPTPL: "Banco BPI",
|
||||||
|
// Add more mappings as needed
|
||||||
|
};
|
||||||
|
return bankMapping[institutionId] || institutionId.split("_")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to create display name for account
|
||||||
|
const getAccountDisplayName = (accountId: string): string => {
|
||||||
|
const account = accountMap[accountId];
|
||||||
|
if (account) {
|
||||||
|
const bankName = getBankName(account.institution_id);
|
||||||
|
const accountName = account.name || `Account ${accountId.split("-")[1]}`;
|
||||||
|
return `${bankName} - ${accountName}`;
|
||||||
|
}
|
||||||
|
return `Account ${accountId.split("-")[1]}`;
|
||||||
|
};
|
||||||
|
// Process balance data for the chart
|
||||||
|
const chartData = data
|
||||||
|
.filter((balance) => balance.balance_type === "closingBooked")
|
||||||
|
.map((balance) => ({
|
||||||
|
date: new Date(balance.reference_date).toLocaleDateString("en-GB"), // DD/MM/YYYY format
|
||||||
|
balance: balance.balance_amount,
|
||||||
|
account_id: balance.account_id,
|
||||||
|
}))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.date.split("/").reverse().join("/")).getTime() -
|
||||||
|
new Date(b.date.split("/").reverse().join("/")).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group by account and aggregate
|
||||||
|
const accountBalances: { [key: string]: ChartDataPoint[] } = {};
|
||||||
|
chartData.forEach((item) => {
|
||||||
|
if (!accountBalances[item.account_id]) {
|
||||||
|
accountBalances[item.account_id] = [];
|
||||||
|
}
|
||||||
|
accountBalances[item.account_id].push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create aggregated data points
|
||||||
|
const aggregatedData: { [key: string]: AggregatedDataPoint } = {};
|
||||||
|
Object.entries(accountBalances).forEach(([accountId, balances]) => {
|
||||||
|
balances.forEach((balance) => {
|
||||||
|
if (!aggregatedData[balance.date]) {
|
||||||
|
aggregatedData[balance.date] = { date: balance.date };
|
||||||
|
}
|
||||||
|
aggregatedData[balance.date][accountId] = balance.balance;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalData = Object.values(aggregatedData).sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.date.split("/").reverse().join("/")).getTime() -
|
||||||
|
new Date(b.date.split("/").reverse().join("/")).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card p-3 border rounded shadow-lg">
|
||||||
|
<p className="font-medium text-foreground">Date: {label}</p>
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<p key={index} style={{ color: entry.color }}>
|
||||||
|
{getAccountDisplayName(entry.name)}: €
|
||||||
|
{entry.value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (finalData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
|
Balance Progress
|
||||||
|
</h3>
|
||||||
|
<div className="h-80 flex items-center justify-center text-muted-foreground">
|
||||||
|
No balance data available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
|
Balance Progress Over Time
|
||||||
|
</h3>
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={finalData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
// Convert DD/MM/YYYY back to a proper date for formatting
|
||||||
|
const [day, month, year] = value.split("/");
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
return date.toLocaleDateString("en-GB", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => `€${value.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend />
|
||||||
|
{Object.keys(accountBalances).map((accountId, index) => (
|
||||||
|
<Area
|
||||||
|
key={accountId}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={accountId}
|
||||||
|
stackId="1"
|
||||||
|
fill={colors[index % colors.length]}
|
||||||
|
stroke={colors[index % colors.length]}
|
||||||
|
name={getAccountDisplayName(accountId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
frontend/src/components/analytics/MonthlyTrends.tsx
Normal file
142
frontend/src/components/analytics/MonthlyTrends.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import apiClient from "../../lib/api";
|
||||||
|
|
||||||
|
interface MonthlyTrendsProps {
|
||||||
|
className?: string;
|
||||||
|
days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}>;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MonthlyTrends({
|
||||||
|
className,
|
||||||
|
days = 365,
|
||||||
|
}: MonthlyTrendsProps) {
|
||||||
|
// Get pre-calculated monthly stats from the new endpoint
|
||||||
|
const { data: monthlyData, isLoading } = useQuery({
|
||||||
|
queryKey: ["monthly-stats", days],
|
||||||
|
queryFn: async () => {
|
||||||
|
return await apiClient.getMonthlyTransactionStats(days);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate number of months to display based on days filter
|
||||||
|
const getMonthsToDisplay = (days: number): number => {
|
||||||
|
if (days <= 30) return 1;
|
||||||
|
if (days <= 180) return 6;
|
||||||
|
if (days <= 365) return 12;
|
||||||
|
return Math.ceil(days / 30);
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthsToDisplay = getMonthsToDisplay(days);
|
||||||
|
const displayData = monthlyData ? monthlyData.slice(-monthsToDisplay) : [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
|
Monthly Spending Trends
|
||||||
|
</h3>
|
||||||
|
<div className="h-80 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (displayData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
|
Monthly Spending Trends
|
||||||
|
</h3>
|
||||||
|
<div className="h-80 flex items-center justify-center text-muted-foreground">
|
||||||
|
No transaction data available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card p-3 border rounded shadow-lg">
|
||||||
|
<p className="font-medium text-foreground">{label}</p>
|
||||||
|
{payload.map((entry, index) => (
|
||||||
|
<p key={index} style={{ color: entry.color }}>
|
||||||
|
{entry.name}: €{Math.abs(entry.value).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate dynamic title based on time period
|
||||||
|
const getTitle = (days: number): string => {
|
||||||
|
if (days <= 30) return "Monthly Spending Trends (Last 30 Days)";
|
||||||
|
if (days <= 180) return "Monthly Spending Trends (Last 6 Months)";
|
||||||
|
if (days <= 365) return "Monthly Spending Trends (Last 12 Months)";
|
||||||
|
return `Monthly Spending Trends (Last ${Math.ceil(days / 30)} Months)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
|
{getTitle(days)}
|
||||||
|
</h3>
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={displayData}
|
||||||
|
margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
tickFormatter={(value) => `€${value.toLocaleString()}`}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar dataKey="income" fill="#10B981" name="Income" />
|
||||||
|
<Bar dataKey="expenses" fill="#EF4444" name="Expenses" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-center space-x-6 text-sm text-foreground">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-3 h-3 bg-green-500 rounded mr-2" />
|
||||||
|
<span>Income</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-3 h-3 bg-red-500 rounded mr-2" />
|
||||||
|
<span>Expenses</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
frontend/src/components/analytics/StatCard.tsx
Normal file
66
frontend/src/components/analytics/StatCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { Card, CardContent } from "../ui/card";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
trend?: {
|
||||||
|
value: number;
|
||||||
|
isPositive: boolean;
|
||||||
|
};
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
icon: Icon,
|
||||||
|
trend,
|
||||||
|
className,
|
||||||
|
}: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<Card className={cn(className)}>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<Icon className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt className="text-sm font-medium text-muted-foreground truncate">
|
||||||
|
{title}
|
||||||
|
</dt>
|
||||||
|
<dd className="flex items-baseline">
|
||||||
|
<div className="text-2xl font-semibold text-foreground">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{trend && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"ml-2 flex items-baseline text-sm font-semibold",
|
||||||
|
trend.isPositive
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-red-600 dark:text-red-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{trend.isPositive ? "+" : ""}
|
||||||
|
{trend.value}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
{subtitle && (
|
||||||
|
<dd className="text-sm text-muted-foreground mt-1">
|
||||||
|
{subtitle}
|
||||||
|
</dd>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
frontend/src/components/analytics/TimePeriodFilter.tsx
Normal file
39
frontend/src/components/analytics/TimePeriodFilter.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Calendar } from "lucide-react";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import type { TimePeriod } from "../../lib/timePeriods";
|
||||||
|
import { TIME_PERIODS } from "../../lib/timePeriods";
|
||||||
|
|
||||||
|
interface TimePeriodFilterProps {
|
||||||
|
selectedPeriod: TimePeriod;
|
||||||
|
onPeriodChange: (period: TimePeriod) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimePeriodFilter({
|
||||||
|
selectedPeriod,
|
||||||
|
onPeriodChange,
|
||||||
|
className = "",
|
||||||
|
}: TimePeriodFilterProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-4 ${className}`}>
|
||||||
|
<div className="flex items-center gap-2 text-foreground">
|
||||||
|
<Calendar size={20} />
|
||||||
|
<span className="font-medium">Time Period:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{TIME_PERIODS.map((period) => (
|
||||||
|
<Button
|
||||||
|
key={period.value}
|
||||||
|
onClick={() => onPeriodChange(period)}
|
||||||
|
variant={
|
||||||
|
selectedPeriod.value === period.value ? "default" : "outline"
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{period.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
frontend/src/components/analytics/TransactionDistribution.tsx
Normal file
147
frontend/src/components/analytics/TransactionDistribution.tsx
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import type { Account } from "../../types/api";
|
||||||
|
|
||||||
|
interface TransactionDistributionProps {
|
||||||
|
accounts: Account[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PieDataPoint {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
payload: PieDataPoint;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TransactionDistribution({
|
||||||
|
accounts,
|
||||||
|
className,
|
||||||
|
}: TransactionDistributionProps) {
|
||||||
|
// Helper function to get bank name from institution_id
|
||||||
|
const getBankName = (institutionId: string): string => {
|
||||||
|
const bankMapping: Record<string, string> = {
|
||||||
|
REVOLUT_REVOLT21: "Revolut",
|
||||||
|
NUBANK_NUPBBR25: "Nu Pagamentos",
|
||||||
|
BANCOBPI_BBPIPTPL: "Banco BPI",
|
||||||
|
// TODO: Add more bank mappings as needed
|
||||||
|
};
|
||||||
|
return bankMapping[institutionId] || institutionId.split("_")[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to create display name for account
|
||||||
|
const getAccountDisplayName = (account: Account): string => {
|
||||||
|
const bankName = getBankName(account.institution_id);
|
||||||
|
const accountName = account.name || `Account ${account.id.split("-")[1]}`;
|
||||||
|
return `${bankName} - ${accountName}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create pie chart data from account balances
|
||||||
|
const pieData: PieDataPoint[] = accounts.map((account, index) => {
|
||||||
|
const primaryBalance = account.balances?.[0]?.amount || 0;
|
||||||
|
|
||||||
|
const colors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: getAccountDisplayName(account),
|
||||||
|
value: primaryBalance,
|
||||||
|
color: colors[index % colors.length],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalBalance = pieData.reduce((sum, item) => sum + item.value, 0);
|
||||||
|
|
||||||
|
if (pieData.length === 0 || totalBalance === 0) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
|
Account Distribution
|
||||||
|
</h3>
|
||||||
|
<div className="h-80 flex items-center justify-center text-muted-foreground">
|
||||||
|
No account data available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload }: TooltipProps) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
const percentage = ((data.value / totalBalance) * 100).toFixed(1);
|
||||||
|
return (
|
||||||
|
<div className="bg-card p-3 border rounded shadow-lg">
|
||||||
|
<p className="font-medium text-foreground">{data.name}</p>
|
||||||
|
<p className="text-primary">
|
||||||
|
Balance: €{data.value.toLocaleString()}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">{percentage}% of total</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<h3 className="text-lg font-medium text-foreground mb-4">
|
||||||
|
Account Balance Distribution
|
||||||
|
</h3>
|
||||||
|
<div className="h-80">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={100}
|
||||||
|
innerRadius={40}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{pieData.map((entry, index) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend
|
||||||
|
formatter={(value, entry: { color?: string }) => (
|
||||||
|
<span style={{ color: entry.color }}>{value}</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-2">
|
||||||
|
{pieData.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full mr-2"
|
||||||
|
style={{ backgroundColor: item.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-foreground">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
€{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
frontend/src/components/filters/AccountCombobox.tsx
Normal file
124
frontend/src/components/filters/AccountCombobox.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Check, ChevronDown, Building2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import type { Account } from "../../types/api";
|
||||||
|
|
||||||
|
export interface AccountComboboxProps {
|
||||||
|
accounts?: Account[];
|
||||||
|
selectedAccount: string;
|
||||||
|
onAccountChange: (accountId: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountCombobox({
|
||||||
|
accounts = [],
|
||||||
|
selectedAccount,
|
||||||
|
onAccountChange,
|
||||||
|
className,
|
||||||
|
}: AccountComboboxProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const selectedAccountData = accounts.find(
|
||||||
|
(account) => account.id === selectedAccount,
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatAccountName = (account: Account) => {
|
||||||
|
const displayName = account.name || "Unnamed Account";
|
||||||
|
return `${displayName} (${account.institution_id})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Building2 className="mr-2 h-4 w-4" />
|
||||||
|
{selectedAccountData
|
||||||
|
? formatAccountName(selectedAccountData)
|
||||||
|
: "All accounts"}
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-[300px] p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search accounts..." className="h-9" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No accounts found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{/* All accounts option */}
|
||||||
|
<CommandItem
|
||||||
|
value="all-accounts"
|
||||||
|
onSelect={() => {
|
||||||
|
onAccountChange("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedAccount === "" ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Building2 className="mr-2 h-4 w-4 text-gray-400" />
|
||||||
|
All accounts
|
||||||
|
</CommandItem>
|
||||||
|
|
||||||
|
{/* Individual accounts */}
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<CommandItem
|
||||||
|
key={account.id}
|
||||||
|
value={`${account.name} ${account.institution_id}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onAccountChange(account.id);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
selectedAccount === account.id
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{account.name || "Unnamed Account"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{account.institution_id}
|
||||||
|
{account.iban && ` • ${account.iban.slice(-4)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
frontend/src/components/filters/ActiveFilterChips.tsx
Normal file
140
frontend/src/components/filters/ActiveFilterChips.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { formatDate } from "@/lib/utils";
|
||||||
|
import type { FilterState } from "./FilterBar";
|
||||||
|
import type { Account } from "../../types/api";
|
||||||
|
|
||||||
|
export interface ActiveFilterChipsProps {
|
||||||
|
filterState: FilterState;
|
||||||
|
onFilterChange: (key: keyof FilterState, value: string) => void;
|
||||||
|
accounts?: Account[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActiveFilterChips({
|
||||||
|
filterState,
|
||||||
|
onFilterChange,
|
||||||
|
accounts = [],
|
||||||
|
}: ActiveFilterChipsProps) {
|
||||||
|
const chips: Array<{
|
||||||
|
key: keyof FilterState;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
// Search term chip
|
||||||
|
if (filterState.searchTerm) {
|
||||||
|
chips.push({
|
||||||
|
key: "searchTerm",
|
||||||
|
label: `Search: "${filterState.searchTerm}"`,
|
||||||
|
value: filterState.searchTerm,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account chip
|
||||||
|
if (filterState.selectedAccount) {
|
||||||
|
const account = accounts.find(
|
||||||
|
(acc) => acc.id === filterState.selectedAccount,
|
||||||
|
);
|
||||||
|
const accountName = account
|
||||||
|
? `${account.name || "Unnamed Account"} (${account.institution_id})`
|
||||||
|
: "Unknown Account";
|
||||||
|
chips.push({
|
||||||
|
key: "selectedAccount",
|
||||||
|
label: accountName,
|
||||||
|
value: filterState.selectedAccount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date range chip
|
||||||
|
if (filterState.startDate || filterState.endDate) {
|
||||||
|
let dateLabel = "Date: ";
|
||||||
|
if (filterState.startDate && filterState.endDate) {
|
||||||
|
if (filterState.startDate === filterState.endDate) {
|
||||||
|
dateLabel += formatDate(filterState.startDate);
|
||||||
|
} else {
|
||||||
|
dateLabel += `${formatDate(filterState.startDate)} - ${formatDate(filterState.endDate)}`;
|
||||||
|
}
|
||||||
|
} else if (filterState.startDate) {
|
||||||
|
dateLabel += `From ${formatDate(filterState.startDate)}`;
|
||||||
|
} else if (filterState.endDate) {
|
||||||
|
dateLabel += `Until ${formatDate(filterState.endDate)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
chips.push({
|
||||||
|
key: "startDate", // We'll clear both start and end date when removing this chip
|
||||||
|
label: dateLabel,
|
||||||
|
value: `${filterState.startDate}-${filterState.endDate}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amount range chips
|
||||||
|
if (filterState.minAmount || filterState.maxAmount) {
|
||||||
|
let amountLabel = "Amount: ";
|
||||||
|
const minAmount = filterState.minAmount
|
||||||
|
? parseFloat(filterState.minAmount)
|
||||||
|
: null;
|
||||||
|
const maxAmount = filterState.maxAmount
|
||||||
|
? parseFloat(filterState.maxAmount)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (minAmount && maxAmount) {
|
||||||
|
amountLabel += `€${minAmount} - €${maxAmount}`;
|
||||||
|
} else if (minAmount) {
|
||||||
|
amountLabel += `≥ €${minAmount}`;
|
||||||
|
} else if (maxAmount) {
|
||||||
|
amountLabel += `≤ €${maxAmount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
chips.push({
|
||||||
|
key: "minAmount", // We'll clear both min and max when removing this chip
|
||||||
|
label: amountLabel,
|
||||||
|
value: `${filterState.minAmount}-${filterState.maxAmount}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveChip = (key: keyof FilterState) => {
|
||||||
|
switch (key) {
|
||||||
|
case "startDate":
|
||||||
|
// Clear both start and end date
|
||||||
|
onFilterChange("startDate", "");
|
||||||
|
onFilterChange("endDate", "");
|
||||||
|
break;
|
||||||
|
case "minAmount":
|
||||||
|
// Clear both min and max amount
|
||||||
|
onFilterChange("minAmount", "");
|
||||||
|
onFilterChange("maxAmount", "");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
onFilterChange(key, "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (chips.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-600 font-medium">Active filters:</span>
|
||||||
|
{chips.map((chip) => (
|
||||||
|
<Badge
|
||||||
|
key={`${chip.key}-${chip.value}`}
|
||||||
|
variant="secondary"
|
||||||
|
className="pl-3 pr-1 py-1 bg-blue-50 text-blue-700 border-blue-200 hover:bg-blue-100"
|
||||||
|
>
|
||||||
|
<span className="mr-1 text-xs">{chip.label}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-4 w-4 p-0 hover:bg-blue-200/50"
|
||||||
|
onClick={() => handleRemoveChip(chip.key)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
<span className="sr-only">Remove {chip.label} filter</span>
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
frontend/src/components/filters/AdvancedFiltersPopover.tsx
Normal file
127
frontend/src/components/filters/AdvancedFiltersPopover.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { MoreHorizontal, Euro } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
|
export interface AdvancedFiltersPopoverProps {
|
||||||
|
minAmount: string;
|
||||||
|
maxAmount: string;
|
||||||
|
onMinAmountChange: (value: string) => void;
|
||||||
|
onMaxAmountChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdvancedFiltersPopover({
|
||||||
|
minAmount,
|
||||||
|
maxAmount,
|
||||||
|
onMinAmountChange,
|
||||||
|
onMaxAmountChange,
|
||||||
|
}: AdvancedFiltersPopoverProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const hasAdvancedFilters = minAmount || maxAmount;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={hasAdvancedFilters ? "default" : "outline"}
|
||||||
|
size="default"
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4 mr-2" />
|
||||||
|
More
|
||||||
|
{hasAdvancedFilters && (
|
||||||
|
<div className="absolute -top-1 -right-1 h-2 w-2 bg-blue-600 rounded-full" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80" align="end">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium leading-none">Advanced Filters</h4>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Additional filters for more precise results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||||
|
Amount Range
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
Minimum
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={minAmount}
|
||||||
|
onChange={(e) => onMinAmountChange(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
Maximum
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Euro className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="1000.00"
|
||||||
|
value={maxAmount}
|
||||||
|
onChange={(e) => onMaxAmountChange(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Leave empty for no limit
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Future: Add transaction status filter */}
|
||||||
|
<div className="pt-2 border-t">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
More filters coming soon: transaction status, categories, and
|
||||||
|
more.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear advanced filters */}
|
||||||
|
{hasAdvancedFilters && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
onMinAmountChange("");
|
||||||
|
onMaxAmountChange("");
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Clear Advanced Filters
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
frontend/src/components/filters/DateRangePicker.tsx
Normal file
213
frontend/src/components/filters/DateRangePicker.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Calendar as CalendarIcon, ChevronDown } from "lucide-react";
|
||||||
|
import type { DateRange } from "react-day-picker";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
|
export interface DateRangePickerProps {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
onDateRangeChange: (startDate: string, endDate: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DatePreset {
|
||||||
|
label: string;
|
||||||
|
getValue: () => { startDate: string; endDate: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const datePresets: DatePreset[] = [
|
||||||
|
{
|
||||||
|
label: "Last 7 days",
|
||||||
|
getValue: () => {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(endDate.getDate() - 7);
|
||||||
|
return {
|
||||||
|
startDate: startDate.toISOString().split("T")[0],
|
||||||
|
endDate: endDate.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "This week",
|
||||||
|
getValue: () => {
|
||||||
|
const now = new Date();
|
||||||
|
const dayOfWeek = now.getDay();
|
||||||
|
const startOfWeek = new Date(now);
|
||||||
|
startOfWeek.setDate(
|
||||||
|
now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1),
|
||||||
|
); // Monday as start
|
||||||
|
|
||||||
|
const endOfWeek = new Date(startOfWeek);
|
||||||
|
endOfWeek.setDate(startOfWeek.getDate() + 6);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: startOfWeek.toISOString().split("T")[0],
|
||||||
|
endDate: endOfWeek.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Last 30 days",
|
||||||
|
getValue: () => {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date();
|
||||||
|
startDate.setDate(endDate.getDate() - 30);
|
||||||
|
return {
|
||||||
|
startDate: startDate.toISOString().split("T")[0],
|
||||||
|
endDate: endDate.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "This month",
|
||||||
|
getValue: () => {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: startOfMonth.toISOString().split("T")[0],
|
||||||
|
endDate: endOfMonth.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "This year",
|
||||||
|
getValue: () => {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||||
|
const endOfYear = new Date(now.getFullYear(), 11, 31);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: startOfYear.toISOString().split("T")[0],
|
||||||
|
endDate: endOfYear.toISOString().split("T")[0],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DateRangePicker({
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onDateRangeChange,
|
||||||
|
className,
|
||||||
|
}: DateRangePickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Convert string dates to Date objects for the calendar
|
||||||
|
const dateRange: DateRange | undefined =
|
||||||
|
startDate && endDate
|
||||||
|
? {
|
||||||
|
from: new Date(startDate),
|
||||||
|
to: new Date(endDate),
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleDateRangeSelect = (range: DateRange | undefined) => {
|
||||||
|
if (range?.from && range?.to) {
|
||||||
|
onDateRangeChange(
|
||||||
|
range.from.toISOString().split("T")[0],
|
||||||
|
range.to.toISOString().split("T")[0],
|
||||||
|
);
|
||||||
|
} else if (range?.from && !range?.to) {
|
||||||
|
onDateRangeChange(
|
||||||
|
range.from.toISOString().split("T")[0],
|
||||||
|
range.from.toISOString().split("T")[0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePresetClick = (preset: DatePreset) => {
|
||||||
|
const { startDate: presetStart, endDate: presetEnd } = preset.getValue();
|
||||||
|
onDateRangeChange(presetStart, presetEnd);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateRange = () => {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return "Select date range";
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
|
||||||
|
// Check if it matches a preset
|
||||||
|
const matchingPreset = datePresets.find((preset) => {
|
||||||
|
const { startDate: presetStart, endDate: presetEnd } = preset.getValue();
|
||||||
|
return presetStart === startDate && presetEnd === endDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingPreset) {
|
||||||
|
return matchingPreset.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format custom range
|
||||||
|
if (startDate === endDate) {
|
||||||
|
return format(start, "MMM d, yyyy");
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("grid gap-2", className)}>
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"justify-between text-left font-normal",
|
||||||
|
!dateRange && "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
|
{formatDateRange()}
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
|
<div className="flex">
|
||||||
|
{/* Presets */}
|
||||||
|
<div className="border-r p-3 space-y-1">
|
||||||
|
<div className="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Quick select
|
||||||
|
</div>
|
||||||
|
{datePresets.map((preset) => (
|
||||||
|
<Button
|
||||||
|
key={preset.label}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start text-sm"
|
||||||
|
onClick={() => handlePresetClick(preset)}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Calendar */}
|
||||||
|
<Calendar
|
||||||
|
initialFocus
|
||||||
|
mode="range"
|
||||||
|
defaultMonth={dateRange?.from}
|
||||||
|
selected={dateRange}
|
||||||
|
onSelect={handleDateRangeSelect}
|
||||||
|
numberOfMonths={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
frontend/src/components/filters/FilterBar.tsx
Normal file
140
frontend/src/components/filters/FilterBar.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { DateRangePicker } from "./DateRangePicker";
|
||||||
|
import { AccountCombobox } from "./AccountCombobox";
|
||||||
|
import { ActiveFilterChips } from "./ActiveFilterChips";
|
||||||
|
import { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
|
||||||
|
import type { Account } from "../../types/api";
|
||||||
|
|
||||||
|
export interface FilterState {
|
||||||
|
searchTerm: string;
|
||||||
|
selectedAccount: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
minAmount: string;
|
||||||
|
maxAmount: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterBarProps {
|
||||||
|
filterState: FilterState;
|
||||||
|
onFilterChange: (key: keyof FilterState, value: string) => void;
|
||||||
|
onClearFilters: () => void;
|
||||||
|
accounts?: Account[];
|
||||||
|
isSearchLoading?: boolean;
|
||||||
|
showRunningBalance: boolean;
|
||||||
|
onToggleRunningBalance: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
filterState,
|
||||||
|
onFilterChange,
|
||||||
|
onClearFilters,
|
||||||
|
accounts,
|
||||||
|
isSearchLoading = false,
|
||||||
|
showRunningBalance,
|
||||||
|
onToggleRunningBalance,
|
||||||
|
className,
|
||||||
|
}: FilterBarProps) {
|
||||||
|
const hasActiveFilters =
|
||||||
|
filterState.searchTerm ||
|
||||||
|
filterState.selectedAccount ||
|
||||||
|
filterState.startDate ||
|
||||||
|
filterState.endDate ||
|
||||||
|
filterState.minAmount ||
|
||||||
|
filterState.maxAmount;
|
||||||
|
|
||||||
|
const handleDateRangeChange = (startDate: string, endDate: string) => {
|
||||||
|
onFilterChange("startDate", startDate);
|
||||||
|
onFilterChange("endDate", endDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("bg-card rounded-lg shadow border", className)}>
|
||||||
|
{/* Main Filter Bar */}
|
||||||
|
<div className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-card-foreground">
|
||||||
|
Transactions
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
onClick={onToggleRunningBalance}
|
||||||
|
variant={showRunningBalance ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Balance
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary Filters Row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="relative flex-1 min-w-[240px]">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search transactions..."
|
||||||
|
value={filterState.searchTerm}
|
||||||
|
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
|
||||||
|
className="pl-9 pr-8 bg-background"
|
||||||
|
/>
|
||||||
|
{isSearchLoading && (
|
||||||
|
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||||
|
<div className="animate-spin h-4 w-4 border-2 border-border border-t-primary rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Selection */}
|
||||||
|
<AccountCombobox
|
||||||
|
accounts={accounts}
|
||||||
|
selectedAccount={filterState.selectedAccount}
|
||||||
|
onAccountChange={(accountId) =>
|
||||||
|
onFilterChange("selectedAccount", accountId)
|
||||||
|
}
|
||||||
|
className="w-[200px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Date Range Picker */}
|
||||||
|
<DateRangePicker
|
||||||
|
startDate={filterState.startDate}
|
||||||
|
endDate={filterState.endDate}
|
||||||
|
onDateRangeChange={handleDateRangeChange}
|
||||||
|
className="w-[240px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Advanced Filters Button */}
|
||||||
|
<AdvancedFiltersPopover
|
||||||
|
minAmount={filterState.minAmount}
|
||||||
|
maxAmount={filterState.maxAmount}
|
||||||
|
onMinAmountChange={(value) => onFilterChange("minAmount", value)}
|
||||||
|
onMaxAmountChange={(value) => onFilterChange("maxAmount", value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Clear Filters Button */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<Button
|
||||||
|
onClick={onClearFilters}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filter Chips */}
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<ActiveFilterChips
|
||||||
|
filterState={filterState}
|
||||||
|
onFilterChange={onFilterChange}
|
||||||
|
accounts={accounts}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
frontend/src/components/filters/index.ts
Normal file
6
frontend/src/components/filters/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { FilterBar } from "./FilterBar";
|
||||||
|
export { DateRangePicker } from "./DateRangePicker";
|
||||||
|
export { AccountCombobox } from "./AccountCombobox";
|
||||||
|
export { ActiveFilterChips } from "./ActiveFilterChips";
|
||||||
|
export { AdvancedFiltersPopover } from "./AdvancedFiltersPopover";
|
||||||
|
export type { FilterState, FilterBarProps } from "./FilterBar";
|
||||||
59
frontend/src/components/ui/alert.tsx
Normal file
59
frontend/src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-background text-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Alert.displayName = "Alert";
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertTitle.displayName = "AlertTitle";
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDescription.displayName = "AlertDescription";
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
36
frontend/src/components/ui/badge.tsx
Normal file
36
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||||
|
outline: "text-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
57
frontend/src/components/ui/button.tsx
Normal file
57
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2",
|
||||||
|
sm: "h-8 rounded-md px-3 text-xs",
|
||||||
|
lg: "h-10 rounded-md px-8",
|
||||||
|
icon: "h-9 w-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
211
frontend/src/components/ui/calendar.tsx
Normal file
211
frontend/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"relative flex flex-col gap-4 md:flex-row",
|
||||||
|
defaultClassNames.months,
|
||||||
|
),
|
||||||
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||||
|
defaultClassNames.nav,
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_previous,
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_next,
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||||
|
defaultClassNames.month_caption,
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
defaultClassNames.dropdowns,
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||||
|
defaultClassNames.dropdown_root,
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"bg-popover absolute inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown,
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label,
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
defaultClassNames.weekday,
|
||||||
|
),
|
||||||
|
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"w-[--cell-size] select-none",
|
||||||
|
defaultClassNames.week_number_header,
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-muted-foreground select-none text-[0.8rem]",
|
||||||
|
defaultClassNames.week_number,
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||||
|
defaultClassNames.day,
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"bg-accent rounded-l-md",
|
||||||
|
defaultClassNames.range_start,
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today,
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside,
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled,
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-[--cell-size] items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames();
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus();
|
||||||
|
}, [modifiers.focused]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton };
|
||||||
86
frontend/src/components/ui/card.tsx
Normal file
86
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
));
|
||||||
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
153
frontend/src/components/ui/command.tsx
Normal file
153
frontend/src/components/ui/command.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { type DialogProps } from "@radix-ui/react-dialog";
|
||||||
|
import { Command as CommandPrimitive } from "cmdk";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
|
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
ref={ref}
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const CommandShortcut = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
CommandShortcut.displayName = "CommandShortcut";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
};
|
||||||
137
frontend/src/components/ui/data-table-pagination.tsx
Normal file
137
frontend/src/components/ui/data-table-pagination.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
|
||||||
|
interface DataTablePaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
hasPrev: boolean;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (pageSize: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTablePagination({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
hasNext,
|
||||||
|
hasPrev,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: DataTablePaginationProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-2 py-4">
|
||||||
|
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium text-foreground">Rows per page</p>
|
||||||
|
<Select
|
||||||
|
value={`${pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onPageSizeChange(Number(value));
|
||||||
|
onPageChange(1); // Reset to first page when changing page size
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue placeholder={pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{[10, 25, 50, 100].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium text-foreground">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to first page</span>
|
||||||
|
<ChevronsLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={!hasPrev}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to previous page</span>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={!hasNext}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to next page</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => onPageChange(totalPages)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to last page</span>
|
||||||
|
<ChevronsRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Showing {(currentPage - 1) * pageSize + 1} to{" "}
|
||||||
|
{Math.min(currentPage * pageSize, total)} of {total} entries
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile view */}
|
||||||
|
<div className="flex w-full items-center justify-between space-x-4 sm:hidden">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={!hasPrev}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={!hasNext}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
frontend/src/components/ui/dialog.tsx
Normal file
120
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = "DialogHeader";
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = "DialogFooter";
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
22
frontend/src/components/ui/input.tsx
Normal file
22
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
|
|
||||||
|
export { Input };
|
||||||
19
frontend/src/components/ui/label.tsx
Normal file
19
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
HTMLLabelElement,
|
||||||
|
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = "Label";
|
||||||
|
|
||||||
|
export { Label };
|
||||||
118
frontend/src/components/ui/pagination.tsx
Normal file
118
frontend/src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import type { ButtonProps } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||||
|
<nav
|
||||||
|
role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
className={cn("mx-auto flex w-full justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
Pagination.displayName = "Pagination";
|
||||||
|
|
||||||
|
const PaginationContent = React.forwardRef<
|
||||||
|
HTMLUListElement,
|
||||||
|
React.ComponentProps<"ul">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ul
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
PaginationContent.displayName = "PaginationContent";
|
||||||
|
|
||||||
|
const PaginationItem = React.forwardRef<
|
||||||
|
HTMLLIElement,
|
||||||
|
React.ComponentProps<"li">
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<li ref={ref} className={cn("", className)} {...props} />
|
||||||
|
));
|
||||||
|
PaginationItem.displayName = "PaginationItem";
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean;
|
||||||
|
} & Pick<ButtonProps, "size"> &
|
||||||
|
React.ComponentProps<"a">;
|
||||||
|
|
||||||
|
const PaginationLink = ({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
size = "icon",
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) => (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
buttonVariants({
|
||||||
|
variant: isActive ? "outline" : "ghost",
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
PaginationLink.displayName = "PaginationLink";
|
||||||
|
|
||||||
|
const PaginationPrevious = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
<span>Previous</span>
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
PaginationPrevious.displayName = "PaginationPrevious";
|
||||||
|
|
||||||
|
const PaginationNext = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
size="default"
|
||||||
|
className={cn("gap-1 pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span>Next</span>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</PaginationLink>
|
||||||
|
);
|
||||||
|
PaginationNext.displayName = "PaginationNext";
|
||||||
|
|
||||||
|
const PaginationEllipsis = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) => (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
PaginationEllipsis.displayName = "PaginationEllipsis";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
};
|
||||||
31
frontend/src/components/ui/popover.tsx
Normal file
31
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Popover = PopoverPrimitive.Root;
|
||||||
|
|
||||||
|
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||||
|
|
||||||
|
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||||
|
|
||||||
|
const PopoverContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
|
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
));
|
||||||
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||||
157
frontend/src/components/ui/select.tsx
Normal file
157
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
117
frontend/src/components/ui/table.tsx
Normal file
117
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
Table.displayName = "Table";
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
));
|
||||||
|
TableHeader.displayName = "TableHeader";
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableBody.displayName = "TableBody";
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableFooter.displayName = "TableFooter";
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRow.displayName = "TableRow";
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableHead.displayName = "TableHead";
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCell.displayName = "TableCell";
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableCaption.displayName = "TableCaption";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
52
frontend/src/components/ui/theme-toggle.tsx
Normal file
52
frontend/src/components/ui/theme-toggle.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
import { useTheme } from "../../contexts/ThemeContext";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const cycleTheme = () => {
|
||||||
|
if (theme === "light") {
|
||||||
|
setTheme("dark");
|
||||||
|
} else if (theme === "dark") {
|
||||||
|
setTheme("system");
|
||||||
|
} else {
|
||||||
|
setTheme("light");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
switch (theme) {
|
||||||
|
case "light":
|
||||||
|
return <Sun className="h-4 w-4" />;
|
||||||
|
case "dark":
|
||||||
|
return <Moon className="h-4 w-4" />;
|
||||||
|
case "system":
|
||||||
|
return <Monitor className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLabel = () => {
|
||||||
|
switch (theme) {
|
||||||
|
case "light":
|
||||||
|
return "Switch to dark mode";
|
||||||
|
case "dark":
|
||||||
|
return "Switch to system mode";
|
||||||
|
case "system":
|
||||||
|
return "Switch to light mode";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={cycleTheme}
|
||||||
|
className="h-8 w-8"
|
||||||
|
title={getLabel()}
|
||||||
|
>
|
||||||
|
{getIcon()}
|
||||||
|
<span className="sr-only">{getLabel()}</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
frontend/src/contexts/ThemeContext.tsx
Normal file
76
frontend/src/contexts/ThemeContext.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
actualTheme: "light" | "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
const stored = localStorage.getItem("theme") as Theme;
|
||||||
|
return stored || "system";
|
||||||
|
});
|
||||||
|
|
||||||
|
const [actualTheme, setActualTheme] = useState<"light" | "dark">("light");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
const updateActualTheme = () => {
|
||||||
|
let resolvedTheme: "light" | "dark";
|
||||||
|
|
||||||
|
if (theme === "system") {
|
||||||
|
resolvedTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
} else {
|
||||||
|
resolvedTheme = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActualTheme(resolvedTheme);
|
||||||
|
|
||||||
|
// Remove previous theme classes
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
|
||||||
|
// Add resolved theme class
|
||||||
|
root.classList.add(resolvedTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateActualTheme();
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handleChange = () => {
|
||||||
|
if (theme === "system") {
|
||||||
|
updateActualTheme();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
|
|
||||||
|
// Store theme preference
|
||||||
|
localStorage.setItem("theme", theme);
|
||||||
|
|
||||||
|
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, setTheme, actualTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useTheme must be used within a ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -1,3 +1,68 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--secondary: 210 40% 96.1%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96.1%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96.1%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import axios from "axios";
|
|||||||
import type {
|
import type {
|
||||||
Account,
|
Account,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
AnalyticsTransaction,
|
||||||
Balance,
|
Balance,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
NotificationSettings,
|
NotificationSettings,
|
||||||
@@ -10,6 +11,7 @@ import type {
|
|||||||
NotificationServicesResponse,
|
NotificationServicesResponse,
|
||||||
HealthData,
|
HealthData,
|
||||||
AccountUpdate,
|
AccountUpdate,
|
||||||
|
TransactionStats,
|
||||||
} from "../types/api";
|
} from "../types/api";
|
||||||
|
|
||||||
// Use VITE_API_URL for development, relative URLs for production
|
// Use VITE_API_URL for development, relative URLs for production
|
||||||
@@ -53,6 +55,21 @@ export const apiClient = {
|
|||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get historical balances for balance progression chart
|
||||||
|
getHistoricalBalances: async (
|
||||||
|
days?: number,
|
||||||
|
accountId?: string,
|
||||||
|
): Promise<Balance[]> => {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (days) queryParams.append("days", days.toString());
|
||||||
|
if (accountId) queryParams.append("account_id", accountId);
|
||||||
|
|
||||||
|
const response = await api.get<ApiResponse<Balance[]>>(
|
||||||
|
`/balances/history?${queryParams.toString()}`,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get balances for specific account
|
// Get balances for specific account
|
||||||
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
|
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
|
||||||
const response = await api.get<ApiResponse<Balance[]>>(
|
const response = await api.get<ApiResponse<Balance[]>>(
|
||||||
@@ -70,6 +87,8 @@ export const apiClient = {
|
|||||||
perPage?: number;
|
perPage?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
summaryOnly?: boolean;
|
summaryOnly?: boolean;
|
||||||
|
minAmount?: number;
|
||||||
|
maxAmount?: number;
|
||||||
}): Promise<ApiResponse<Transaction[]>> => {
|
}): Promise<ApiResponse<Transaction[]>> => {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
|
||||||
@@ -83,6 +102,12 @@ export const apiClient = {
|
|||||||
if (params?.summaryOnly !== undefined) {
|
if (params?.summaryOnly !== undefined) {
|
||||||
queryParams.append("summary_only", params.summaryOnly.toString());
|
queryParams.append("summary_only", params.summaryOnly.toString());
|
||||||
}
|
}
|
||||||
|
if (params?.minAmount !== undefined) {
|
||||||
|
queryParams.append("min_amount", params.minAmount.toString());
|
||||||
|
}
|
||||||
|
if (params?.maxAmount !== undefined) {
|
||||||
|
queryParams.append("max_amount", params.maxAmount.toString());
|
||||||
|
}
|
||||||
|
|
||||||
const response = await api.get<ApiResponse<Transaction[]>>(
|
const response = await api.get<ApiResponse<Transaction[]>>(
|
||||||
`/transactions?${queryParams.toString()}`,
|
`/transactions?${queryParams.toString()}`,
|
||||||
@@ -142,6 +167,57 @@ export const apiClient = {
|
|||||||
const response = await api.get<ApiResponse<HealthData>>("/health");
|
const response = await api.get<ApiResponse<HealthData>>("/health");
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Analytics endpoints
|
||||||
|
getTransactionStats: async (days?: number): Promise<TransactionStats> => {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (days) queryParams.append("days", days.toString());
|
||||||
|
|
||||||
|
const response = await api.get<ApiResponse<TransactionStats>>(
|
||||||
|
`/transactions/stats?${queryParams.toString()}`,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get all transactions for analytics (no pagination)
|
||||||
|
getTransactionsForAnalytics: async (
|
||||||
|
days?: number,
|
||||||
|
): Promise<AnalyticsTransaction[]> => {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (days) queryParams.append("days", days.toString());
|
||||||
|
|
||||||
|
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
|
||||||
|
`/transactions/analytics?${queryParams.toString()}`,
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get monthly transaction statistics (pre-calculated)
|
||||||
|
getMonthlyTransactionStats: async (
|
||||||
|
days?: number,
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
month: string;
|
||||||
|
income: number;
|
||||||
|
expenses: number;
|
||||||
|
net: number;
|
||||||
|
}>
|
||||||
|
> => {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (days) queryParams.append("days", days.toString());
|
||||||
|
|
||||||
|
const response = await api.get<
|
||||||
|
ApiResponse<
|
||||||
|
Array<{
|
||||||
|
month: string;
|
||||||
|
income: number;
|
||||||
|
expenses: number;
|
||||||
|
net: number;
|
||||||
|
}>
|
||||||
|
>
|
||||||
|
>(`/transactions/monthly-stats?${queryParams.toString()}`);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|||||||
19
frontend/src/lib/timePeriods.ts
Normal file
19
frontend/src/lib/timePeriods.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export type TimePeriod = {
|
||||||
|
label: string;
|
||||||
|
days: number;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDaysFromYearStart(): number {
|
||||||
|
const now = new Date();
|
||||||
|
const yearStart = new Date(now.getFullYear(), 0, 1);
|
||||||
|
const diffTime = now.getTime() - yearStart.getTime();
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIME_PERIODS: TimePeriod[] = [
|
||||||
|
{ label: "Last 30 days", days: 30, value: "30d" },
|
||||||
|
{ label: "Last 6 months", days: 180, value: "6m" },
|
||||||
|
{ label: "Year to Date", days: getDaysFromYearStart(), value: "ytd" },
|
||||||
|
{ label: "Last 365 days", days: 365, value: "365d" },
|
||||||
|
];
|
||||||
@@ -1,62 +1,25 @@
|
|||||||
import { clsx, type ClassValue } from "clsx";
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return clsx(inputs);
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatCurrency(
|
export function formatCurrency(
|
||||||
amount: number,
|
amount: number,
|
||||||
currency: string = "EUR",
|
currency: string = "EUR",
|
||||||
): string {
|
): string {
|
||||||
// Validate currency code - must be 3 letters and a valid ISO 4217 code
|
return new Intl.NumberFormat("en-US", {
|
||||||
const validCurrency =
|
style: "currency",
|
||||||
currency && /^[A-Z]{3}$/.test(currency) ? currency : "EUR";
|
currency,
|
||||||
|
}).format(amount);
|
||||||
try {
|
|
||||||
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",
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDate(date: string): string {
|
export function formatDate(dateString: string): string {
|
||||||
if (!date) return "No date";
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
const parsedDate = new Date(date);
|
|
||||||
if (isNaN(parsedDate.getTime())) {
|
|
||||||
console.warn("Invalid date string:", date);
|
|
||||||
return "Invalid date";
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Intl.DateTimeFormat("en-US", {
|
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
}).format(parsedDate);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTime(date: string): string {
|
|
||||||
if (!date) return "No date";
|
|
||||||
|
|
||||||
const parsedDate = new Date(date);
|
|
||||||
if (isNaN(parsedDate.getTime())) {
|
|
||||||
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",
|
|
||||||
}).format(parsedDate);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { StrictMode } from "react";
|
|||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
@@ -19,7 +20,9 @@ const queryClient = new QueryClient({
|
|||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<ThemeProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ function RootLayout() {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen bg-gray-100">
|
<div className="flex h-screen bg-background">
|
||||||
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
<Sidebar sidebarOpen={sidebarOpen} setSidebarOpen={setSidebarOpen} />
|
||||||
|
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 bg-gray-600 bg-opacity-75 lg:hidden"
|
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,10 +1,153 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
CreditCard,
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Activity,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import apiClient from "../lib/api";
|
||||||
|
import StatCard from "../components/analytics/StatCard";
|
||||||
|
import BalanceChart from "../components/analytics/BalanceChart";
|
||||||
|
import TransactionDistribution from "../components/analytics/TransactionDistribution";
|
||||||
|
import MonthlyTrends from "../components/analytics/MonthlyTrends";
|
||||||
|
import TimePeriodFilter from "../components/analytics/TimePeriodFilter";
|
||||||
|
import { Card, CardContent } from "../components/ui/card";
|
||||||
|
import type { TimePeriod } from "../lib/timePeriods";
|
||||||
|
import { TIME_PERIODS } from "../lib/timePeriods";
|
||||||
|
|
||||||
|
function AnalyticsDashboard() {
|
||||||
|
// Default to Last 365 days
|
||||||
|
const [selectedPeriod, setSelectedPeriod] = useState<TimePeriod>(
|
||||||
|
TIME_PERIODS.find((p) => p.value === "365d") || TIME_PERIODS[3],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch analytics data
|
||||||
|
const { data: stats, isLoading: statsLoading } = useQuery({
|
||||||
|
queryKey: ["transaction-stats", selectedPeriod.days],
|
||||||
|
queryFn: () => apiClient.getTransactionStats(selectedPeriod.days),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: accounts, isLoading: accountsLoading } = useQuery({
|
||||||
|
queryKey: ["accounts"],
|
||||||
|
queryFn: () => apiClient.getAccounts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: balances, isLoading: balancesLoading } = useQuery({
|
||||||
|
queryKey: ["historical-balances", selectedPeriod.days],
|
||||||
|
queryFn: () => apiClient.getHistoricalBalances(selectedPeriod.days),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = statsLoading || accountsLoading || balancesLoading;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="animate-pulse">
|
||||||
|
<div className="h-8 bg-muted rounded w-48 mb-6"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="h-32 bg-muted rounded"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<div className="h-96 bg-muted rounded"></div>
|
||||||
|
<div className="h-96 bg-muted rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-8">
|
||||||
|
{/* Time Period Filter */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<TimePeriodFilter
|
||||||
|
selectedPeriod={selectedPeriod}
|
||||||
|
onPeriodChange={setSelectedPeriod}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<StatCard
|
||||||
|
title="Total Transactions"
|
||||||
|
value={stats?.total_transactions || 0}
|
||||||
|
subtitle={`Last ${stats?.period_days || 0} days`}
|
||||||
|
icon={Activity}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Total Income"
|
||||||
|
value={`€${(stats?.total_income || 0).toLocaleString()}`}
|
||||||
|
subtitle="Inflows this period"
|
||||||
|
icon={TrendingUp}
|
||||||
|
className="border-green-200"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Total Expenses"
|
||||||
|
value={`€${(stats?.total_expenses || 0).toLocaleString()}`}
|
||||||
|
subtitle="Outflows this period"
|
||||||
|
icon={TrendingDown}
|
||||||
|
className="border-red-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<StatCard
|
||||||
|
title="Net Change"
|
||||||
|
value={`€${(stats?.net_change || 0).toLocaleString()}`}
|
||||||
|
subtitle="Income minus expenses"
|
||||||
|
icon={CreditCard}
|
||||||
|
className={
|
||||||
|
(stats?.net_change || 0) >= 0
|
||||||
|
? "border-green-200"
|
||||||
|
: "border-red-200"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Average Transaction"
|
||||||
|
value={`€${Math.abs(stats?.average_transaction || 0).toLocaleString()}`}
|
||||||
|
subtitle="Per transaction"
|
||||||
|
icon={Activity}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Active Accounts"
|
||||||
|
value={stats?.accounts_included || 0}
|
||||||
|
subtitle="With recent activity"
|
||||||
|
icon={Users}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<BalanceChart data={balances || []} accounts={accounts || []} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<TransactionDistribution accounts={accounts || []} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly Trends */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<MonthlyTrends days={selectedPeriod.days} />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/analytics")({
|
export const Route = createFileRoute("/analytics")({
|
||||||
component: () => (
|
component: AnalyticsDashboard,
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ export interface RawTransactionData {
|
|||||||
[key: string]: unknown; // Allow additional fields
|
[key: string]: unknown; // Allow additional fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Type for analytics transaction data
|
||||||
|
export interface AnalyticsTransaction {
|
||||||
|
transaction_id: string;
|
||||||
|
date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
status: string;
|
||||||
|
account_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Transaction {
|
export interface Transaction {
|
||||||
transaction_id: string; // NEW: stable bank-provided transaction ID
|
transaction_id: string; // NEW: stable bank-provided transaction ID
|
||||||
internal_transaction_id: string | null; // OLD: unstable GoCardless ID
|
internal_transaction_id: string | null; // OLD: unstable GoCardless ID
|
||||||
@@ -188,3 +199,16 @@ export interface HealthData {
|
|||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Analytics data types
|
||||||
|
export interface TransactionStats {
|
||||||
|
period_days: number;
|
||||||
|
total_transactions: number;
|
||||||
|
booked_transactions: number;
|
||||||
|
pending_transactions: number;
|
||||||
|
total_income: number;
|
||||||
|
total_expenses: number;
|
||||||
|
net_change: number;
|
||||||
|
average_transaction: number;
|
||||||
|
accounts_included: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,57 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
|
darkMode: ["class"],
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
chart: {
|
||||||
|
1: "hsl(var(--chart-1))",
|
||||||
|
2: "hsl(var(--chart-2))",
|
||||||
|
3: "hsl(var(--chart-3))",
|
||||||
|
4: "hsl(var(--chart-4))",
|
||||||
|
5: "hsl(var(--chart-5))",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [require("@tailwindcss/forms")],
|
plugins: [require("@tailwindcss/forms"), require("tailwindcss-animate")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,12 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Path mapping */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
|
|||||||
@@ -3,5 +3,11 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
]
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,4 +5,9 @@ import { TanStackRouterVite } from "@tanstack/router-vite-plugin";
|
|||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [TanStackRouterVite(), react()],
|
plugins: [TanStackRouterVite(), react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": "/src",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Optional, List
|
from typing import List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
from typing import Optional, List, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.accounts import (
|
||||||
from leggend.api.models.accounts import (
|
|
||||||
AccountDetails,
|
|
||||||
AccountBalance,
|
AccountBalance,
|
||||||
|
AccountDetails,
|
||||||
|
AccountUpdate,
|
||||||
Transaction,
|
Transaction,
|
||||||
TransactionSummary,
|
TransactionSummary,
|
||||||
AccountUpdate,
|
|
||||||
)
|
)
|
||||||
from leggend.services.database_service import DatabaseService
|
from leggen.api.models.common import APIResponse
|
||||||
|
from leggen.services.database_service import DatabaseService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
database_service = DatabaseService()
|
database_service = DatabaseService()
|
||||||
@@ -215,6 +216,35 @@ async def get_all_balances() -> APIResponse:
|
|||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balances/history", response_model=APIResponse)
|
||||||
|
async def get_historical_balances(
|
||||||
|
days: Optional[int] = Query(
|
||||||
|
default=365, le=1095, ge=1, description="Number of days of history to retrieve"
|
||||||
|
),
|
||||||
|
account_id: Optional[str] = Query(
|
||||||
|
default=None, description="Filter by specific account ID"
|
||||||
|
),
|
||||||
|
) -> APIResponse:
|
||||||
|
"""Get historical balance progression calculated from transaction history"""
|
||||||
|
try:
|
||||||
|
# Get historical balances from database
|
||||||
|
historical_balances = await database_service.get_historical_balances_from_db(
|
||||||
|
account_id=account_id, days=days or 365
|
||||||
|
)
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
success=True,
|
||||||
|
data=historical_balances,
|
||||||
|
message=f"Retrieved {len(historical_balances)} historical balance points over {days} days",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get historical balances: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to get historical balances: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
|
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
|
||||||
async def get_account_transactions(
|
async def get_account_transactions(
|
||||||
account_id: str,
|
account_id: str,
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.banks import (
|
||||||
from leggend.api.models.banks import (
|
|
||||||
BankInstitution,
|
|
||||||
BankConnectionRequest,
|
BankConnectionRequest,
|
||||||
BankRequisition,
|
|
||||||
BankConnectionStatus,
|
BankConnectionStatus,
|
||||||
|
BankInstitution,
|
||||||
|
BankRequisition,
|
||||||
)
|
)
|
||||||
from leggend.services.gocardless_service import GoCardlessService
|
from leggen.api.models.common import APIResponse
|
||||||
from leggend.utils.gocardless import REQUISITION_STATUS
|
from leggen.services.gocardless_service import GoCardlessService
|
||||||
|
from leggen.utils.gocardless import REQUISITION_STATUS
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
gocardless_service = GoCardlessService()
|
gocardless_service = GoCardlessService()
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
from typing import Dict, Any
|
from typing import Any, Dict
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.common import APIResponse
|
||||||
from leggend.api.models.notifications import (
|
from leggen.api.models.notifications import (
|
||||||
|
DiscordConfig,
|
||||||
|
NotificationFilters,
|
||||||
NotificationSettings,
|
NotificationSettings,
|
||||||
NotificationTest,
|
NotificationTest,
|
||||||
DiscordConfig,
|
|
||||||
TelegramConfig,
|
TelegramConfig,
|
||||||
NotificationFilters,
|
|
||||||
)
|
)
|
||||||
from leggend.services.notification_service import NotificationService
|
from leggen.services.notification_service import NotificationService
|
||||||
from leggend.config import config
|
from leggen.utils.config import config
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
notification_service = NotificationService()
|
notification_service = NotificationService()
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.common import APIResponse
|
||||||
from leggend.api.models.sync import SyncRequest, SchedulerConfig
|
from leggen.api.models.sync import SchedulerConfig, SyncRequest
|
||||||
from leggend.services.sync_service import SyncService
|
from leggen.background.scheduler import scheduler
|
||||||
from leggend.background.scheduler import scheduler
|
from leggen.services.sync_service import SyncService
|
||||||
from leggend.config import config
|
from leggen.utils.config import config
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
sync_service = SyncService()
|
sync_service = SyncService()
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from typing import Optional, List, Union
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.common import APIResponse, PaginatedResponse
|
from leggen.api.models.accounts import Transaction, TransactionSummary
|
||||||
from leggend.api.models.accounts import Transaction, TransactionSummary
|
from leggen.api.models.common import APIResponse, PaginatedResponse
|
||||||
from leggend.services.database_service import DatabaseService
|
from leggen.services.database_service import DatabaseService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
database_service = DatabaseService()
|
database_service = DatabaseService()
|
||||||
@@ -202,3 +203,88 @@ async def get_transaction_stats(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to get transaction stats: {str(e)}"
|
status_code=500, detail=f"Failed to get transaction stats: {str(e)}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions/analytics", response_model=APIResponse)
|
||||||
|
async def get_transactions_for_analytics(
|
||||||
|
days: int = Query(default=365, description="Number of days to include"),
|
||||||
|
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||||
|
) -> APIResponse:
|
||||||
|
"""Get all transactions for analytics (no pagination) for the last N days"""
|
||||||
|
try:
|
||||||
|
# Date range for analytics
|
||||||
|
end_date = datetime.now()
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
|
||||||
|
# Format dates for database query
|
||||||
|
date_from = start_date.isoformat()
|
||||||
|
date_to = end_date.isoformat()
|
||||||
|
|
||||||
|
# Get ALL transactions from database (no limit for analytics)
|
||||||
|
transactions = await database_service.get_transactions_from_db(
|
||||||
|
account_id=account_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
limit=None, # No limit - get all transactions
|
||||||
|
)
|
||||||
|
|
||||||
|
# Transform for frontend (summary format)
|
||||||
|
transaction_summaries = [
|
||||||
|
{
|
||||||
|
"transaction_id": txn["transactionId"],
|
||||||
|
"date": txn["transactionDate"],
|
||||||
|
"description": txn["description"],
|
||||||
|
"amount": txn["transactionValue"],
|
||||||
|
"currency": txn["transactionCurrency"],
|
||||||
|
"status": txn["transactionStatus"],
|
||||||
|
"account_id": txn["accountId"],
|
||||||
|
}
|
||||||
|
for txn in transactions
|
||||||
|
]
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
success=True,
|
||||||
|
data=transaction_summaries,
|
||||||
|
message=f"Retrieved {len(transaction_summaries)} transactions for analytics",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get transactions for analytics: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to get analytics transactions: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transactions/monthly-stats", response_model=APIResponse)
|
||||||
|
async def get_monthly_transaction_stats(
|
||||||
|
days: int = Query(default=365, description="Number of days to include"),
|
||||||
|
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
|
||||||
|
) -> APIResponse:
|
||||||
|
"""Get monthly transaction statistics aggregated by the database"""
|
||||||
|
try:
|
||||||
|
# Date range for monthly stats
|
||||||
|
end_date = datetime.now()
|
||||||
|
start_date = end_date - timedelta(days=days)
|
||||||
|
|
||||||
|
# Format dates for database query
|
||||||
|
date_from = start_date.isoformat()
|
||||||
|
date_to = end_date.isoformat()
|
||||||
|
|
||||||
|
# Get monthly aggregated stats from database
|
||||||
|
monthly_stats = await database_service.get_monthly_transaction_stats_from_db(
|
||||||
|
account_id=account_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
success=True,
|
||||||
|
data=monthly_stats,
|
||||||
|
message=f"Retrieved monthly stats for last {days} days",
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get monthly transaction stats: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to get monthly stats: {str(e)}"
|
||||||
|
) from e
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
import os
|
import os
|
||||||
import requests
|
from typing import Any, Dict, List, Optional, Union
|
||||||
from typing import Dict, Any, Optional, List, Union
|
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from leggen.utils.text import error
|
from leggen.utils.text import error
|
||||||
|
|
||||||
|
|
||||||
class LeggendAPIClient:
|
class LeggenAPIClient:
|
||||||
"""Client for communicating with the leggend FastAPI service"""
|
"""Client for communicating with the leggen FastAPI service"""
|
||||||
|
|
||||||
base_url: str
|
base_url: str
|
||||||
|
|
||||||
def __init__(self, base_url: Optional[str] = None):
|
def __init__(self, base_url: Optional[str] = None):
|
||||||
self.base_url = (
|
self.base_url = (
|
||||||
base_url
|
base_url
|
||||||
or os.environ.get("LEGGEND_API_URL", "http://localhost:8000")
|
or os.environ.get("LEGGEN_API_URL", "http://localhost:8000")
|
||||||
or "http://localhost:8000"
|
or "http://localhost:8000"
|
||||||
)
|
)
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
@@ -31,7 +32,7 @@ class LeggendAPIClient:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
except requests.exceptions.ConnectionError:
|
except requests.exceptions.ConnectionError:
|
||||||
error("Could not connect to leggend service. Is it running?")
|
error("Could not connect to leggen server. Is it running?")
|
||||||
error(f"Trying to connect to: {self.base_url}")
|
error(f"Trying to connect to: {self.base_url}")
|
||||||
raise
|
raise
|
||||||
except requests.exceptions.HTTPError as e:
|
except requests.exceptions.HTTPError as e:
|
||||||
@@ -48,7 +49,7 @@ class LeggendAPIClient:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
def health_check(self) -> bool:
|
def health_check(self) -> bool:
|
||||||
"""Check if the leggend service is healthy"""
|
"""Check if the leggen server is healthy"""
|
||||||
try:
|
try:
|
||||||
response = self._make_request("GET", "/health")
|
response = self._make_request("GET", "/health")
|
||||||
return response.get("status") == "healthy"
|
return response.get("status") == "healthy"
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.config import config
|
from leggen.services.notification_service import NotificationService
|
||||||
from leggend.services.sync_service import SyncService
|
from leggen.services.sync_service import SyncService
|
||||||
from leggend.services.notification_service import NotificationService
|
from leggen.utils.config import config
|
||||||
|
|
||||||
|
|
||||||
class BackgroundScheduler:
|
class BackgroundScheduler:
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.api_client import LeggenAPIClient
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.api_client import LeggendAPIClient
|
|
||||||
from leggen.utils.text import datefmt, print_table
|
from leggen.utils.text import datefmt, print_table
|
||||||
|
|
||||||
|
|
||||||
@@ -11,12 +11,12 @@ def balances(ctx: click.Context):
|
|||||||
"""
|
"""
|
||||||
List balances of all connected accounts
|
List balances of all connected accounts
|
||||||
"""
|
"""
|
||||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggend service is available
|
# Check if leggen server is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.api_client import LeggenAPIClient
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.api_client import LeggendAPIClient
|
|
||||||
from leggen.utils.disk import save_file
|
from leggen.utils.disk import save_file
|
||||||
from leggen.utils.text import info, print_table, warning, success
|
from leggen.utils.text import info, print_table, success, warning
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
@@ -12,12 +12,12 @@ def add(ctx):
|
|||||||
"""
|
"""
|
||||||
Connect to a bank
|
Connect to a bank
|
||||||
"""
|
"""
|
||||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggend service is available
|
# Check if leggen server is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Generate sample database command."""
|
"""Generate sample database command."""
|
||||||
|
|
||||||
import click
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from leggen.utils.paths import path_manager
|
import click
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@@ -30,29 +29,33 @@ from leggen.utils.paths import path_manager
|
|||||||
help="Overwrite existing database without confirmation",
|
help="Overwrite existing database without confirmation",
|
||||||
)
|
)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def generate_sample_db(ctx: click.Context, database: Path, accounts: int, transactions: int, force: bool):
|
def generate_sample_db(
|
||||||
|
ctx: click.Context, database: Path, accounts: int, transactions: int, force: bool
|
||||||
|
):
|
||||||
"""Generate a sample database with realistic financial data for testing."""
|
"""Generate a sample database with realistic financial data for testing."""
|
||||||
|
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
import sys
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
from pathlib import Path as PathlibPath
|
from pathlib import Path as PathlibPath
|
||||||
|
|
||||||
# Get the script path
|
# Get the script path
|
||||||
script_path = PathlibPath(__file__).parent.parent.parent / "scripts" / "generate_sample_db.py"
|
script_path = (
|
||||||
|
PathlibPath(__file__).parent.parent.parent / "scripts" / "generate_sample_db.py"
|
||||||
|
)
|
||||||
|
|
||||||
# Build command arguments
|
# Build command arguments
|
||||||
cmd = [sys.executable, str(script_path)]
|
cmd = [sys.executable, str(script_path)]
|
||||||
|
|
||||||
if database:
|
if database:
|
||||||
cmd.extend(["--database", str(database)])
|
cmd.extend(["--database", str(database)])
|
||||||
|
|
||||||
cmd.extend(["--accounts", str(accounts)])
|
cmd.extend(["--accounts", str(accounts)])
|
||||||
cmd.extend(["--transactions", str(transactions)])
|
cmd.extend(["--transactions", str(transactions)])
|
||||||
|
|
||||||
if force:
|
if force:
|
||||||
cmd.append("--force")
|
cmd.append("--force")
|
||||||
|
|
||||||
# Execute the script
|
# Execute the script
|
||||||
try:
|
try:
|
||||||
subprocess.run(cmd, check=True)
|
subprocess.run(cmd, check=True)
|
||||||
@@ -62,4 +65,4 @@ def generate_sample_db(ctx: click.Context, database: Path, accounts: int, transa
|
|||||||
|
|
||||||
|
|
||||||
# Export the command
|
# Export the command
|
||||||
generate_sample_db = generate_sample_db
|
generate_sample_db = generate_sample_db
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from importlib import metadata
|
from importlib import metadata
|
||||||
|
|
||||||
|
import click
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.routes import banks, accounts, sync, notifications, transactions
|
from leggen.api.routes import accounts, banks, notifications, sync, transactions
|
||||||
from leggend.background.scheduler import scheduler
|
from leggen.background.scheduler import scheduler
|
||||||
from leggend.config import config
|
from leggen.utils.config import config
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
logger.info("Starting leggend service...")
|
logger.info("Starting leggen server...")
|
||||||
|
|
||||||
# Load configuration
|
# Load configuration
|
||||||
try:
|
try:
|
||||||
@@ -26,7 +28,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Run database migrations
|
# Run database migrations
|
||||||
try:
|
try:
|
||||||
from leggend.services.database_service import DatabaseService
|
from leggen.services.database_service import DatabaseService
|
||||||
|
|
||||||
db_service = DatabaseService()
|
db_service = DatabaseService()
|
||||||
await db_service.run_migrations_if_needed()
|
await db_service.run_migrations_if_needed()
|
||||||
@@ -42,7 +44,7 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown
|
# Shutdown
|
||||||
logger.info("Shutting down leggend service...")
|
logger.info("Shutting down leggen server...")
|
||||||
scheduler.shutdown()
|
scheduler.shutdown()
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ def create_app() -> FastAPI:
|
|||||||
version = "unknown"
|
version = "unknown"
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Leggend API",
|
title="Leggen API",
|
||||||
description="Open Banking API for Leggen",
|
description="Open Banking API for Leggen",
|
||||||
version=version,
|
version=version,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -87,13 +89,13 @@ def create_app() -> FastAPI:
|
|||||||
version = metadata.version("leggen")
|
version = metadata.version("leggen")
|
||||||
except metadata.PackageNotFoundError:
|
except metadata.PackageNotFoundError:
|
||||||
version = "unknown"
|
version = "unknown"
|
||||||
return {"message": "Leggend API is running", "version": version}
|
return {"message": "Leggen API is running", "version": version}
|
||||||
|
|
||||||
@app.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
async def health():
|
async def health():
|
||||||
"""Health check endpoint for API connectivity"""
|
"""Health check endpoint for API connectivity"""
|
||||||
try:
|
try:
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.common import APIResponse
|
||||||
|
|
||||||
config_loaded = config._config is not None
|
config_loaded = config._config is not None
|
||||||
|
|
||||||
@@ -108,7 +110,7 @@ def create_app() -> FastAPI:
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Health check failed: {e}")
|
logger.error(f"Health check failed: {e}")
|
||||||
from leggend.api.models.common import APIResponse
|
from leggen.api.models.common import APIResponse
|
||||||
|
|
||||||
return APIResponse(
|
return APIResponse(
|
||||||
success=False,
|
success=False,
|
||||||
@@ -119,61 +121,58 @@ def create_app() -> FastAPI:
|
|||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def main():
|
@click.command()
|
||||||
import argparse
|
@click.option(
|
||||||
from pathlib import Path
|
"--reload",
|
||||||
from leggen.utils.paths import path_manager
|
is_flag=True,
|
||||||
|
help="Enable auto-reload for development",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--host",
|
||||||
|
default="0.0.0.0",
|
||||||
|
help="Host to bind to (default: 0.0.0.0)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--port",
|
||||||
|
type=int,
|
||||||
|
default=8000,
|
||||||
|
help="Port to bind to (default: 8000)",
|
||||||
|
)
|
||||||
|
@click.pass_context
|
||||||
|
def server(ctx: click.Context, reload: bool, host: str, port: int):
|
||||||
|
"""Start the Leggen API server"""
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Start the Leggend API service")
|
# Get config_dir and database from main CLI context
|
||||||
parser.add_argument(
|
config_dir = None
|
||||||
"--reload", action="store_true", help="Enable auto-reload for development"
|
database = None
|
||||||
)
|
if ctx.parent:
|
||||||
parser.add_argument(
|
config_dir = ctx.parent.params.get("config_dir")
|
||||||
"--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)"
|
database = ctx.parent.params.get("database")
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--port", type=int, default=8000, help="Port to bind to (default: 8000)"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--config-dir",
|
|
||||||
type=Path,
|
|
||||||
help="Directory containing configuration files (default: ~/.config/leggen)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--database",
|
|
||||||
type=Path,
|
|
||||||
help="Path to SQLite database file (default: <config-dir>/leggen.db)",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Set up path manager with user-provided paths
|
# Set up path manager with user-provided paths
|
||||||
if args.config_dir:
|
if config_dir:
|
||||||
path_manager.set_config_dir(args.config_dir)
|
path_manager.set_config_dir(config_dir)
|
||||||
if args.database:
|
if database:
|
||||||
path_manager.set_database_path(args.database)
|
path_manager.set_database_path(database)
|
||||||
|
|
||||||
if args.reload:
|
if reload:
|
||||||
# Use string import for reload to work properly
|
# Use string import for reload to work properly
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"leggend.main:create_app",
|
"leggen.commands.server:create_app",
|
||||||
factory=True,
|
factory=True,
|
||||||
host=args.host,
|
host=host,
|
||||||
port=args.port,
|
port=port,
|
||||||
log_level="info",
|
log_level="info",
|
||||||
access_log=True,
|
access_log=True,
|
||||||
reload=True,
|
reload=True,
|
||||||
reload_dirs=["leggend", "leggen"], # Watch both directories
|
reload_dirs=["leggen"], # Watch leggen directory
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
app = create_app()
|
app = create_app()
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
host=args.host,
|
host=host,
|
||||||
port=args.port,
|
port=port,
|
||||||
log_level="info",
|
log_level="info",
|
||||||
access_log=True,
|
access_log=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.api_client import LeggenAPIClient
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.api_client import LeggendAPIClient
|
|
||||||
from leggen.utils.text import datefmt, echo, info, print_table
|
from leggen.utils.text import datefmt, echo, info, print_table
|
||||||
|
|
||||||
|
|
||||||
@@ -11,12 +11,12 @@ def status(ctx: click.Context):
|
|||||||
"""
|
"""
|
||||||
List all connected banks and their status
|
List all connected banks and their status
|
||||||
"""
|
"""
|
||||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggend service is available
|
# Check if leggen server is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.api_client import LeggenAPIClient
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.api_client import LeggendAPIClient
|
|
||||||
from leggen.utils.text import error, info, success
|
from leggen.utils.text import error, info, success
|
||||||
|
|
||||||
|
|
||||||
@@ -13,11 +13,11 @@ def sync(ctx: click.Context, wait: bool, force: bool):
|
|||||||
"""
|
"""
|
||||||
Sync all transactions with database
|
Sync all transactions with database
|
||||||
"""
|
"""
|
||||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggend service is available
|
# Check if leggen server is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
error("Cannot connect to leggend service. Please ensure it's running.")
|
error("Cannot connect to leggen server. Please ensure it's running.")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.api_client import LeggenAPIClient
|
||||||
from leggen.main import cli
|
from leggen.main import cli
|
||||||
from leggen.api_client import LeggendAPIClient
|
|
||||||
from leggen.utils.text import datefmt, info, print_table
|
from leggen.utils.text import datefmt, info, print_table
|
||||||
|
|
||||||
|
|
||||||
@@ -20,12 +20,12 @@ def transactions(ctx: click.Context, account: str, limit: int, full: bool):
|
|||||||
|
|
||||||
If the --account option is used, it will only list transactions for that account.
|
If the --account option is used, it will only list transactions for that account.
|
||||||
"""
|
"""
|
||||||
api_client = LeggendAPIClient(ctx.obj.get("api_url"))
|
api_client = LeggenAPIClient(ctx.obj.get("api_url"))
|
||||||
|
|
||||||
# Check if leggend service is available
|
# Check if leggen server is available
|
||||||
if not api_client.health_check():
|
if not api_client.health_check():
|
||||||
click.echo(
|
click.echo(
|
||||||
"Error: Cannot connect to leggend service. Please ensure it's running."
|
"Error: Cannot connect to leggen server. Please ensure it's running."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,522 +0,0 @@
|
|||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
from sqlite3 import IntegrityError
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from leggen.utils.text import success, warning
|
|
||||||
from leggen.utils.paths import path_manager
|
|
||||||
|
|
||||||
|
|
||||||
def persist_balances(ctx: click.Context, balance: dict):
|
|
||||||
# Connect to SQLite database
|
|
||||||
db_path = path_manager.get_database_path()
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Create the accounts table if it doesn't exist
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE TABLE IF NOT EXISTS accounts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
institution_id TEXT,
|
|
||||||
status TEXT,
|
|
||||||
iban TEXT,
|
|
||||||
name TEXT,
|
|
||||||
currency TEXT,
|
|
||||||
created DATETIME,
|
|
||||||
last_accessed DATETIME,
|
|
||||||
last_updated DATETIME
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for accounts table
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
|
|
||||||
ON accounts(institution_id)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
|
|
||||||
ON accounts(status)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the balances table if it doesn't exist
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE TABLE IF NOT EXISTS balances (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
account_id TEXT,
|
|
||||||
bank TEXT,
|
|
||||||
status TEXT,
|
|
||||||
iban TEXT,
|
|
||||||
amount REAL,
|
|
||||||
currency TEXT,
|
|
||||||
type TEXT,
|
|
||||||
timestamp DATETIME
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for better performance
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_balances_account_id
|
|
||||||
ON balances(account_id)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_balances_timestamp
|
|
||||||
ON balances(timestamp)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp
|
|
||||||
ON balances(account_id, type, timestamp)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert balance into SQLite database
|
|
||||||
try:
|
|
||||||
cursor.execute(
|
|
||||||
"""INSERT INTO balances (
|
|
||||||
account_id,
|
|
||||||
bank,
|
|
||||||
status,
|
|
||||||
iban,
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
type,
|
|
||||||
timestamp
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
||||||
(
|
|
||||||
balance["account_id"],
|
|
||||||
balance["bank"],
|
|
||||||
balance["status"],
|
|
||||||
balance["iban"],
|
|
||||||
balance["amount"],
|
|
||||||
balance["currency"],
|
|
||||||
balance["type"],
|
|
||||||
balance["timestamp"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except IntegrityError:
|
|
||||||
warning(f"[{balance['account_id']}] Skipped duplicate balance")
|
|
||||||
|
|
||||||
# Commit changes and close the connection
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
success(f"[{balance['account_id']}] Inserted balance of type {balance['type']}")
|
|
||||||
|
|
||||||
return balance
|
|
||||||
|
|
||||||
|
|
||||||
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
|
|
||||||
# Connect to SQLite database
|
|
||||||
db_path = path_manager.get_database_path()
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Create the transactions table if it doesn't exist
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE TABLE IF NOT EXISTS transactions (
|
|
||||||
accountId TEXT NOT NULL,
|
|
||||||
transactionId TEXT NOT NULL,
|
|
||||||
internalTransactionId TEXT,
|
|
||||||
institutionId TEXT,
|
|
||||||
iban TEXT,
|
|
||||||
transactionDate DATETIME,
|
|
||||||
description TEXT,
|
|
||||||
transactionValue REAL,
|
|
||||||
transactionCurrency TEXT,
|
|
||||||
transactionStatus TEXT,
|
|
||||||
rawTransaction JSON,
|
|
||||||
PRIMARY KEY (accountId, transactionId)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for better performance
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_internal_id
|
|
||||||
ON transactions(internalTransactionId)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_date
|
|
||||||
ON transactions(transactionDate)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_date
|
|
||||||
ON transactions(accountId, transactionDate)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_transactions_amount
|
|
||||||
ON transactions(transactionValue)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Insert transactions into SQLite database
|
|
||||||
duplicates_count = 0
|
|
||||||
|
|
||||||
# Prepare an SQL statement for inserting data
|
|
||||||
insert_sql = """INSERT OR REPLACE INTO transactions (
|
|
||||||
accountId,
|
|
||||||
transactionId,
|
|
||||||
internalTransactionId,
|
|
||||||
institutionId,
|
|
||||||
iban,
|
|
||||||
transactionDate,
|
|
||||||
description,
|
|
||||||
transactionValue,
|
|
||||||
transactionCurrency,
|
|
||||||
transactionStatus,
|
|
||||||
rawTransaction
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
|
|
||||||
|
|
||||||
new_transactions = []
|
|
||||||
|
|
||||||
for transaction in transactions:
|
|
||||||
try:
|
|
||||||
cursor.execute(
|
|
||||||
insert_sql,
|
|
||||||
(
|
|
||||||
transaction["accountId"],
|
|
||||||
transaction["transactionId"],
|
|
||||||
transaction.get("internalTransactionId"),
|
|
||||||
transaction["institutionId"],
|
|
||||||
transaction["iban"],
|
|
||||||
transaction["transactionDate"],
|
|
||||||
transaction["description"],
|
|
||||||
transaction["transactionValue"],
|
|
||||||
transaction["transactionCurrency"],
|
|
||||||
transaction["transactionStatus"],
|
|
||||||
json.dumps(transaction["rawTransaction"]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
new_transactions.append(transaction)
|
|
||||||
except IntegrityError:
|
|
||||||
# A transaction with the same ID already exists, indicating a duplicate
|
|
||||||
duplicates_count += 1
|
|
||||||
|
|
||||||
# Commit changes and close the connection
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
success(f"[{account}] Inserted {len(new_transactions)} new transactions")
|
|
||||||
if duplicates_count:
|
|
||||||
warning(f"[{account}] Skipped {duplicates_count} duplicate transactions")
|
|
||||||
|
|
||||||
return new_transactions
|
|
||||||
|
|
||||||
|
|
||||||
def get_transactions(
|
|
||||||
account_id=None,
|
|
||||||
limit=100,
|
|
||||||
offset=0,
|
|
||||||
date_from=None,
|
|
||||||
date_to=None,
|
|
||||||
min_amount=None,
|
|
||||||
max_amount=None,
|
|
||||||
search=None,
|
|
||||||
):
|
|
||||||
"""Get transactions from SQLite database with optional filtering"""
|
|
||||||
db_path = path_manager.get_database_path()
|
|
||||||
if not db_path.exists():
|
|
||||||
return []
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row # Enable dict-like access
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Build query with filters
|
|
||||||
query = "SELECT * FROM transactions WHERE 1=1"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if account_id:
|
|
||||||
query += " AND accountId = ?"
|
|
||||||
params.append(account_id)
|
|
||||||
|
|
||||||
if date_from:
|
|
||||||
query += " AND transactionDate >= ?"
|
|
||||||
params.append(date_from)
|
|
||||||
|
|
||||||
if date_to:
|
|
||||||
query += " AND transactionDate <= ?"
|
|
||||||
params.append(date_to)
|
|
||||||
|
|
||||||
if min_amount is not None:
|
|
||||||
query += " AND transactionValue >= ?"
|
|
||||||
params.append(min_amount)
|
|
||||||
|
|
||||||
if max_amount is not None:
|
|
||||||
query += " AND transactionValue <= ?"
|
|
||||||
params.append(max_amount)
|
|
||||||
|
|
||||||
if search:
|
|
||||||
query += " AND description LIKE ?"
|
|
||||||
params.append(f"%{search}%")
|
|
||||||
|
|
||||||
# Add ordering and pagination
|
|
||||||
query += " ORDER BY transactionDate DESC"
|
|
||||||
|
|
||||||
if limit:
|
|
||||||
query += " LIMIT ?"
|
|
||||||
params.append(limit)
|
|
||||||
|
|
||||||
if offset:
|
|
||||||
query += " OFFSET ?"
|
|
||||||
params.append(offset)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute(query, params)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
# Convert to list of dicts and parse JSON fields
|
|
||||||
transactions = []
|
|
||||||
for row in rows:
|
|
||||||
transaction = dict(row)
|
|
||||||
if transaction["rawTransaction"]:
|
|
||||||
transaction["rawTransaction"] = json.loads(
|
|
||||||
transaction["rawTransaction"]
|
|
||||||
)
|
|
||||||
transactions.append(transaction)
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
return transactions
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def get_balances(account_id=None):
|
|
||||||
"""Get latest balances from SQLite database"""
|
|
||||||
db_path = path_manager.get_database_path()
|
|
||||||
if not db_path.exists():
|
|
||||||
return []
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Get latest balance for each account_id and type combination
|
|
||||||
query = """
|
|
||||||
SELECT * FROM balances b1
|
|
||||||
WHERE b1.timestamp = (
|
|
||||||
SELECT MAX(b2.timestamp)
|
|
||||||
FROM balances b2
|
|
||||||
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if account_id:
|
|
||||||
query += " AND b1.account_id = ?"
|
|
||||||
params.append(account_id)
|
|
||||||
|
|
||||||
query += " ORDER BY b1.account_id, b1.type"
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute(query, params)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
balances = [dict(row) for row in rows]
|
|
||||||
conn.close()
|
|
||||||
return balances
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def get_account_summary(account_id):
|
|
||||||
"""Get basic account info from transactions table (avoids GoCardless API call)"""
|
|
||||||
db_path = path_manager.get_database_path()
|
|
||||||
if not db_path.exists():
|
|
||||||
return None
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get account info from most recent transaction
|
|
||||||
cursor.execute(
|
|
||||||
"""
|
|
||||||
SELECT DISTINCT accountId, institutionId, iban
|
|
||||||
FROM transactions
|
|
||||||
WHERE accountId = ?
|
|
||||||
ORDER BY transactionDate DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(account_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
row = cursor.fetchone()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if row:
|
|
||||||
return dict(row)
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def get_transaction_count(account_id=None, **filters):
|
|
||||||
"""Get total count of transactions matching filters"""
|
|
||||||
db_path = path_manager.get_database_path()
|
|
||||||
if not db_path.exists():
|
|
||||||
return 0
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
query = "SELECT COUNT(*) FROM transactions WHERE 1=1"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if account_id:
|
|
||||||
query += " AND accountId = ?"
|
|
||||||
params.append(account_id)
|
|
||||||
|
|
||||||
# Add same filters as get_transactions
|
|
||||||
if filters.get("date_from"):
|
|
||||||
query += " AND transactionDate >= ?"
|
|
||||||
params.append(filters["date_from"])
|
|
||||||
|
|
||||||
if filters.get("date_to"):
|
|
||||||
query += " AND transactionDate <= ?"
|
|
||||||
params.append(filters["date_to"])
|
|
||||||
|
|
||||||
if filters.get("min_amount") is not None:
|
|
||||||
query += " AND transactionValue >= ?"
|
|
||||||
params.append(filters["min_amount"])
|
|
||||||
|
|
||||||
if filters.get("max_amount") is not None:
|
|
||||||
query += " AND transactionValue <= ?"
|
|
||||||
params.append(filters["max_amount"])
|
|
||||||
|
|
||||||
if filters.get("search"):
|
|
||||||
query += " AND description LIKE ?"
|
|
||||||
params.append(f"%{filters['search']}%")
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute(query, params)
|
|
||||||
count = cursor.fetchone()[0]
|
|
||||||
conn.close()
|
|
||||||
return count
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def persist_account(account_data: dict):
|
|
||||||
"""Persist account details to SQLite database"""
|
|
||||||
db_path = path_manager.get_database_path()
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Create the accounts table if it doesn't exist
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE TABLE IF NOT EXISTS accounts (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
institution_id TEXT,
|
|
||||||
status TEXT,
|
|
||||||
iban TEXT,
|
|
||||||
name TEXT,
|
|
||||||
currency TEXT,
|
|
||||||
created DATETIME,
|
|
||||||
last_accessed DATETIME,
|
|
||||||
last_updated DATETIME
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for accounts table
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
|
|
||||||
ON accounts(institution_id)"""
|
|
||||||
)
|
|
||||||
cursor.execute(
|
|
||||||
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
|
|
||||||
ON accounts(status)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Insert or replace account data
|
|
||||||
cursor.execute(
|
|
||||||
"""INSERT OR REPLACE INTO accounts (
|
|
||||||
id,
|
|
||||||
institution_id,
|
|
||||||
status,
|
|
||||||
iban,
|
|
||||||
name,
|
|
||||||
currency,
|
|
||||||
created,
|
|
||||||
last_accessed,
|
|
||||||
last_updated
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
||||||
(
|
|
||||||
account_data["id"],
|
|
||||||
account_data["institution_id"],
|
|
||||||
account_data["status"],
|
|
||||||
account_data.get("iban"),
|
|
||||||
account_data.get("name"),
|
|
||||||
account_data.get("currency"),
|
|
||||||
account_data["created"],
|
|
||||||
account_data.get("last_accessed"),
|
|
||||||
account_data.get("last_updated", account_data["created"]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
success(f"[{account_data['id']}] Account details persisted to database")
|
|
||||||
return account_data
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def get_accounts(account_ids=None):
|
|
||||||
"""Get account details from SQLite database"""
|
|
||||||
db_path = path_manager.get_database_path()
|
|
||||||
if not db_path.exists():
|
|
||||||
return []
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
query = "SELECT * FROM accounts"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if account_ids:
|
|
||||||
placeholders = ",".join("?" * len(account_ids))
|
|
||||||
query += f" WHERE id IN ({placeholders})"
|
|
||||||
params.extend(account_ids)
|
|
||||||
|
|
||||||
query += " ORDER BY created DESC"
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute(query, params)
|
|
||||||
rows = cursor.fetchall()
|
|
||||||
|
|
||||||
accounts = [dict(row) for row in rows]
|
|
||||||
conn.close()
|
|
||||||
return accounts
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
|
|
||||||
|
|
||||||
def get_account(account_id: str):
|
|
||||||
"""Get specific account details from SQLite database"""
|
|
||||||
db_path = path_manager.get_database_path()
|
|
||||||
if not db_path.exists():
|
|
||||||
return None
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if row:
|
|
||||||
return dict(row)
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
conn.close()
|
|
||||||
raise e
|
|
||||||
@@ -6,8 +6,8 @@ from pathlib import Path
|
|||||||
import click
|
import click
|
||||||
|
|
||||||
from leggen.utils.config import load_config
|
from leggen.utils.config import load_config
|
||||||
from leggen.utils.text import error
|
|
||||||
from leggen.utils.paths import path_manager
|
from leggen.utils.paths import path_manager
|
||||||
|
from leggen.utils.text import error
|
||||||
|
|
||||||
cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands"))
|
cmd_folder = os.path.abspath(os.path.join(os.path.dirname(__file__), "commands"))
|
||||||
|
|
||||||
@@ -105,9 +105,9 @@ class Group(click.Group):
|
|||||||
"--api-url",
|
"--api-url",
|
||||||
type=str,
|
type=str,
|
||||||
default="http://localhost:8000",
|
default="http://localhost:8000",
|
||||||
envvar="LEGGEND_API_URL",
|
envvar="LEGGEN_API_URL",
|
||||||
show_envvar=True,
|
show_envvar=True,
|
||||||
help="URL of the leggend API service",
|
help="URL of the leggen API service",
|
||||||
)
|
)
|
||||||
@click.group(
|
@click.group(
|
||||||
cls=Group,
|
cls=Group,
|
||||||
|
|||||||
65
leggen/models/config.py
Normal file
65
leggen/models/config.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class GoCardlessConfig(BaseModel):
|
||||||
|
key: str = Field(..., description="GoCardless API key")
|
||||||
|
secret: str = Field(..., description="GoCardless API secret")
|
||||||
|
url: str = Field(
|
||||||
|
default="https://bankaccountdata.gocardless.com/api/v2",
|
||||||
|
description="GoCardless API URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseConfig(BaseModel):
|
||||||
|
sqlite: bool = Field(default=True, description="Enable SQLite database")
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordNotificationConfig(BaseModel):
|
||||||
|
webhook: str = Field(..., description="Discord webhook URL")
|
||||||
|
enabled: bool = Field(default=True, description="Enable Discord notifications")
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramNotificationConfig(BaseModel):
|
||||||
|
token: str = Field(..., alias="api-key", description="Telegram bot token")
|
||||||
|
chat_id: int = Field(..., alias="chat-id", description="Telegram chat ID")
|
||||||
|
enabled: bool = Field(default=True, description="Enable Telegram notifications")
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationConfig(BaseModel):
|
||||||
|
discord: Optional[DiscordNotificationConfig] = None
|
||||||
|
telegram: Optional[TelegramNotificationConfig] = None
|
||||||
|
|
||||||
|
|
||||||
|
class FilterConfig(BaseModel):
|
||||||
|
case_insensitive: Optional[List[str]] = Field(
|
||||||
|
default_factory=list, alias="case-insensitive"
|
||||||
|
)
|
||||||
|
case_sensitive: Optional[List[str]] = Field(
|
||||||
|
default_factory=list, alias="case-sensitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncScheduleConfig(BaseModel):
|
||||||
|
enabled: bool = Field(default=True, description="Enable sync scheduling")
|
||||||
|
hour: int = Field(default=3, ge=0, le=23, description="Hour to run sync (0-23)")
|
||||||
|
minute: int = Field(default=0, ge=0, le=59, description="Minute to run sync (0-59)")
|
||||||
|
cron: Optional[str] = Field(
|
||||||
|
default=None, description="Custom cron expression (overrides hour/minute)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulerConfig(BaseModel):
|
||||||
|
sync: SyncScheduleConfig = Field(default_factory=SyncScheduleConfig)
|
||||||
|
|
||||||
|
|
||||||
|
class Config(BaseModel):
|
||||||
|
gocardless: GoCardlessConfig
|
||||||
|
database: DatabaseConfig = Field(default_factory=DatabaseConfig)
|
||||||
|
notifications: Optional[NotificationConfig] = None
|
||||||
|
filters: Optional[FilterConfig] = None
|
||||||
|
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
validate_by_name = True
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from datetime import datetime
|
import json
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.config import config
|
from leggen.services.transaction_processor import TransactionProcessor
|
||||||
import leggen.database.sqlite as sqlite_db
|
from leggen.utils.config import config
|
||||||
from leggen.utils.paths import path_manager
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ class DatabaseService:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.db_config = config.database_config
|
self.db_config = config.database_config
|
||||||
self.sqlite_enabled = self.db_config.get("sqlite", True)
|
self.sqlite_enabled = self.db_config.get("sqlite", True)
|
||||||
|
self.transaction_processor = TransactionProcessor()
|
||||||
|
|
||||||
async def persist_balance(
|
async def persist_balance(
|
||||||
self, account_id: str, balance_data: Dict[str, Any]
|
self, account_id: str, balance_data: Dict[str, Any]
|
||||||
@@ -41,84 +43,14 @@ class DatabaseService:
|
|||||||
transaction_data: Dict[str, Any],
|
transaction_data: Dict[str, Any],
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Process raw transaction data into standardized format"""
|
"""Process raw transaction data into standardized format"""
|
||||||
transactions = []
|
return self.transaction_processor.process_transactions(
|
||||||
|
account_id, account_info, transaction_data
|
||||||
# Process booked transactions
|
|
||||||
for transaction in transaction_data.get("transactions", {}).get("booked", []):
|
|
||||||
processed = self._process_single_transaction(
|
|
||||||
account_id, account_info, transaction, "booked"
|
|
||||||
)
|
|
||||||
transactions.append(processed)
|
|
||||||
|
|
||||||
# Process pending transactions
|
|
||||||
for transaction in transaction_data.get("transactions", {}).get("pending", []):
|
|
||||||
processed = self._process_single_transaction(
|
|
||||||
account_id, account_info, transaction, "pending"
|
|
||||||
)
|
|
||||||
transactions.append(processed)
|
|
||||||
|
|
||||||
return transactions
|
|
||||||
|
|
||||||
def _process_single_transaction(
|
|
||||||
self,
|
|
||||||
account_id: str,
|
|
||||||
account_info: Dict[str, Any],
|
|
||||||
transaction: Dict[str, Any],
|
|
||||||
status: str,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Process a single transaction into standardized format"""
|
|
||||||
# Extract dates
|
|
||||||
booked_date = transaction.get("bookingDateTime") or transaction.get(
|
|
||||||
"bookingDate"
|
|
||||||
)
|
)
|
||||||
value_date = transaction.get("valueDateTime") or transaction.get("valueDate")
|
|
||||||
|
|
||||||
if booked_date and value_date:
|
|
||||||
min_date = min(
|
|
||||||
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
date_str = booked_date or value_date
|
|
||||||
if not date_str:
|
|
||||||
raise ValueError("No valid date found in transaction")
|
|
||||||
min_date = datetime.fromisoformat(date_str)
|
|
||||||
|
|
||||||
# Extract amount and currency
|
|
||||||
transaction_amount = transaction.get("transactionAmount", {})
|
|
||||||
amount = float(transaction_amount.get("amount", 0))
|
|
||||||
currency = transaction_amount.get("currency", "")
|
|
||||||
|
|
||||||
# Extract description
|
|
||||||
description = transaction.get(
|
|
||||||
"remittanceInformationUnstructured",
|
|
||||||
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract transaction IDs - transactionId is now primary, internalTransactionId is reference
|
|
||||||
transaction_id = transaction.get("transactionId")
|
|
||||||
internal_transaction_id = transaction.get("internalTransactionId")
|
|
||||||
|
|
||||||
if not transaction_id:
|
|
||||||
raise ValueError("Transaction missing required transactionId field")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"accountId": account_id,
|
|
||||||
"transactionId": transaction_id,
|
|
||||||
"internalTransactionId": internal_transaction_id,
|
|
||||||
"institutionId": account_info["institution_id"],
|
|
||||||
"iban": account_info.get("iban", "N/A"),
|
|
||||||
"transactionDate": min_date,
|
|
||||||
"description": description,
|
|
||||||
"transactionValue": amount,
|
|
||||||
"transactionCurrency": currency,
|
|
||||||
"transactionStatus": status,
|
|
||||||
"rawTransaction": transaction,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_transactions_from_db(
|
async def get_transactions_from_db(
|
||||||
self,
|
self,
|
||||||
account_id: Optional[str] = None,
|
account_id: Optional[str] = None,
|
||||||
limit: Optional[int] = 100,
|
limit: Optional[int] = None, # None means no limit, used for stats
|
||||||
offset: Optional[int] = 0,
|
offset: Optional[int] = 0,
|
||||||
date_from: Optional[str] = None,
|
date_from: Optional[str] = None,
|
||||||
date_to: Optional[str] = None,
|
date_to: Optional[str] = None,
|
||||||
@@ -132,9 +64,9 @@ class DatabaseService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transactions = sqlite_db.get_transactions(
|
transactions = self._get_transactions(
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
limit=limit or 100,
|
limit=limit, # Pass limit as-is, None means no limit
|
||||||
offset=offset or 0,
|
offset=offset or 0,
|
||||||
date_from=date_from,
|
date_from=date_from,
|
||||||
date_to=date_to,
|
date_to=date_to,
|
||||||
@@ -172,7 +104,7 @@ class DatabaseService:
|
|||||||
# Remove None values
|
# Remove None values
|
||||||
filters = {k: v for k, v in filters.items() if v is not None}
|
filters = {k: v for k, v in filters.items() if v is not None}
|
||||||
|
|
||||||
count = sqlite_db.get_transaction_count(account_id=account_id, **filters)
|
count = self._get_transaction_count(account_id=account_id, **filters)
|
||||||
logger.debug(f"Total transaction count: {count}")
|
logger.debug(f"Total transaction count: {count}")
|
||||||
return count
|
return count
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -188,13 +120,31 @@ class DatabaseService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
balances = sqlite_db.get_balances(account_id=account_id)
|
balances = self._get_balances(account_id=account_id)
|
||||||
logger.debug(f"Retrieved {len(balances)} balances from database")
|
logger.debug(f"Retrieved {len(balances)} balances from database")
|
||||||
return balances
|
return balances
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get balances from database: {e}")
|
logger.error(f"Failed to get balances from database: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def get_historical_balances_from_db(
|
||||||
|
self, account_id: Optional[str] = None, days: int = 365
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get historical balance progression from SQLite database"""
|
||||||
|
if not self.sqlite_enabled:
|
||||||
|
logger.warning("SQLite database disabled, cannot read historical balances")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
balances = self._get_historical_balances(account_id=account_id, days=days)
|
||||||
|
logger.debug(
|
||||||
|
f"Retrieved {len(balances)} historical balance points from database"
|
||||||
|
)
|
||||||
|
return balances
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get historical balances from database: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
async def get_account_summary_from_db(
|
async def get_account_summary_from_db(
|
||||||
self, account_id: str
|
self, account_id: str
|
||||||
) -> Optional[Dict[str, Any]]:
|
) -> Optional[Dict[str, Any]]:
|
||||||
@@ -203,7 +153,7 @@ class DatabaseService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
summary = sqlite_db.get_account_summary(account_id)
|
summary = self._get_account_summary(account_id)
|
||||||
if summary:
|
if summary:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Retrieved account summary from database for {account_id}"
|
f"Retrieved account summary from database for {account_id}"
|
||||||
@@ -230,7 +180,7 @@ class DatabaseService:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
accounts = sqlite_db.get_accounts(account_ids=account_ids)
|
accounts = self._get_accounts(account_ids=account_ids)
|
||||||
logger.debug(f"Retrieved {len(accounts)} accounts from database")
|
logger.debug(f"Retrieved {len(accounts)} accounts from database")
|
||||||
return accounts
|
return accounts
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -246,7 +196,7 @@ class DatabaseService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
account = sqlite_db.get_account(account_id)
|
account = self._get_account(account_id)
|
||||||
if account:
|
if account:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Retrieved account details from database for {account_id}"
|
f"Retrieved account details from database for {account_id}"
|
||||||
@@ -424,7 +374,7 @@ class DatabaseService:
|
|||||||
async def _migrate_null_transaction_ids(self):
|
async def _migrate_null_transaction_ids(self):
|
||||||
"""Populate null internalTransactionId fields using transactionId from raw data"""
|
"""Populate null internalTransactionId fields using transactionId from raw data"""
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
db_path = path_manager.get_database_path()
|
db_path = path_manager.get_database_path()
|
||||||
if not db_path.exists():
|
if not db_path.exists():
|
||||||
logger.warning("Database file not found, skipping migration")
|
logger.warning("Database file not found, skipping migration")
|
||||||
@@ -770,8 +720,8 @@ class DatabaseService:
|
|||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Persist transactions to SQLite"""
|
"""Persist transactions to SQLite"""
|
||||||
try:
|
try:
|
||||||
import sqlite3
|
|
||||||
import json
|
import json
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
db_path = path_manager.get_database_path()
|
db_path = path_manager.get_database_path()
|
||||||
conn = sqlite3.connect(str(db_path))
|
conn = sqlite3.connect(str(db_path))
|
||||||
@@ -873,7 +823,7 @@ class DatabaseService:
|
|||||||
"""Persist account details to SQLite"""
|
"""Persist account details to SQLite"""
|
||||||
try:
|
try:
|
||||||
# Use the sqlite_db module function
|
# Use the sqlite_db module function
|
||||||
sqlite_db.persist_account(account_data)
|
self._persist_account(account_data)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Persisted account details to SQLite for account {account_data['id']}"
|
f"Persisted account details to SQLite for account {account_data['id']}"
|
||||||
@@ -881,3 +831,500 @@ class DatabaseService:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to persist account details to SQLite: {e}")
|
logger.error(f"Failed to persist account details to SQLite: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def _get_transactions(
|
||||||
|
self,
|
||||||
|
account_id=None,
|
||||||
|
limit=100,
|
||||||
|
offset=0,
|
||||||
|
date_from=None,
|
||||||
|
date_to=None,
|
||||||
|
min_amount=None,
|
||||||
|
max_amount=None,
|
||||||
|
search=None,
|
||||||
|
):
|
||||||
|
"""Get transactions from SQLite database with optional filtering"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row # Enable dict-like access
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Build query with filters
|
||||||
|
query = "SELECT * FROM transactions WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
query += " AND accountId = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query += " AND transactionDate >= ?"
|
||||||
|
params.append(date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query += " AND transactionDate <= ?"
|
||||||
|
params.append(date_to)
|
||||||
|
|
||||||
|
if min_amount is not None:
|
||||||
|
query += " AND transactionValue >= ?"
|
||||||
|
params.append(min_amount)
|
||||||
|
|
||||||
|
if max_amount is not None:
|
||||||
|
query += " AND transactionValue <= ?"
|
||||||
|
params.append(max_amount)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
query += " AND description LIKE ?"
|
||||||
|
params.append(f"%{search}%")
|
||||||
|
|
||||||
|
# Add ordering and pagination
|
||||||
|
query += " ORDER BY transactionDate DESC"
|
||||||
|
|
||||||
|
if limit:
|
||||||
|
query += " LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
if offset:
|
||||||
|
query += " OFFSET ?"
|
||||||
|
params.append(offset)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# Convert to list of dicts and parse JSON fields
|
||||||
|
transactions = []
|
||||||
|
for row in rows:
|
||||||
|
transaction = dict(row)
|
||||||
|
if transaction["rawTransaction"]:
|
||||||
|
transaction["rawTransaction"] = json.loads(
|
||||||
|
transaction["rawTransaction"]
|
||||||
|
)
|
||||||
|
transactions.append(transaction)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_balances(self, account_id=None):
|
||||||
|
"""Get latest balances from SQLite database"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get latest balance for each account_id and type combination
|
||||||
|
query = """
|
||||||
|
SELECT * FROM balances b1
|
||||||
|
WHERE b1.timestamp = (
|
||||||
|
SELECT MAX(b2.timestamp)
|
||||||
|
FROM balances b2
|
||||||
|
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
query += " AND b1.account_id = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
|
||||||
|
query += " ORDER BY b1.account_id, b1.type"
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
balances = [dict(row) for row in rows]
|
||||||
|
conn.close()
|
||||||
|
return balances
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_account_summary(self, account_id):
|
||||||
|
"""Get basic account info from transactions table (avoids GoCardless API call)"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return None
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get account info from most recent transaction
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT accountId, institutionId, iban
|
||||||
|
FROM transactions
|
||||||
|
WHERE accountId = ?
|
||||||
|
ORDER BY transactionDate DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(account_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_transaction_count(self, account_id=None, **filters):
|
||||||
|
"""Get total count of transactions matching filters"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return 0
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = "SELECT COUNT(*) FROM transactions WHERE 1=1"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
query += " AND accountId = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
|
||||||
|
# Add same filters as get_transactions
|
||||||
|
if filters.get("date_from"):
|
||||||
|
query += " AND transactionDate >= ?"
|
||||||
|
params.append(filters["date_from"])
|
||||||
|
|
||||||
|
if filters.get("date_to"):
|
||||||
|
query += " AND transactionDate <= ?"
|
||||||
|
params.append(filters["date_to"])
|
||||||
|
|
||||||
|
if filters.get("min_amount") is not None:
|
||||||
|
query += " AND transactionValue >= ?"
|
||||||
|
params.append(filters["min_amount"])
|
||||||
|
|
||||||
|
if filters.get("max_amount") is not None:
|
||||||
|
query += " AND transactionValue <= ?"
|
||||||
|
params.append(filters["max_amount"])
|
||||||
|
|
||||||
|
if filters.get("search"):
|
||||||
|
query += " AND description LIKE ?"
|
||||||
|
params.append(f"%{filters['search']}%")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
return count
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _persist_account(self, account_data: dict):
|
||||||
|
"""Persist account details to SQLite database"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create the accounts table if it doesn't exist
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS accounts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
institution_id TEXT,
|
||||||
|
status TEXT,
|
||||||
|
iban TEXT,
|
||||||
|
name TEXT,
|
||||||
|
currency TEXT,
|
||||||
|
created DATETIME,
|
||||||
|
last_accessed DATETIME,
|
||||||
|
last_updated DATETIME
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for accounts table
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
|
||||||
|
ON accounts(institution_id)"""
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
|
||||||
|
ON accounts(status)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Insert or replace account data
|
||||||
|
cursor.execute(
|
||||||
|
"""INSERT OR REPLACE INTO accounts (
|
||||||
|
id,
|
||||||
|
institution_id,
|
||||||
|
status,
|
||||||
|
iban,
|
||||||
|
name,
|
||||||
|
currency,
|
||||||
|
created,
|
||||||
|
last_accessed,
|
||||||
|
last_updated
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(
|
||||||
|
account_data["id"],
|
||||||
|
account_data["institution_id"],
|
||||||
|
account_data["status"],
|
||||||
|
account_data.get("iban"),
|
||||||
|
account_data.get("name"),
|
||||||
|
account_data.get("currency"),
|
||||||
|
account_data["created"],
|
||||||
|
account_data.get("last_accessed"),
|
||||||
|
account_data.get("last_updated", account_data["created"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return account_data
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_accounts(self, account_ids=None):
|
||||||
|
"""Get account details from SQLite database"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = "SELECT * FROM accounts"
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_ids:
|
||||||
|
placeholders = ",".join("?" * len(account_ids))
|
||||||
|
query += f" WHERE id IN ({placeholders})"
|
||||||
|
params.extend(account_ids)
|
||||||
|
|
||||||
|
query += " ORDER BY created DESC"
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
accounts = [dict(row) for row in rows]
|
||||||
|
conn.close()
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_account(self, account_id: str):
|
||||||
|
"""Get specific account details from SQLite database"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return None
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return dict(row)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def _get_historical_balances(self, account_id=None, days=365):
|
||||||
|
"""Get historical balance progression based on transaction history"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cutoff_date = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||||
|
today_date = datetime.now().date().isoformat()
|
||||||
|
|
||||||
|
# Single SQL query to generate historical balances using window functions
|
||||||
|
query = """
|
||||||
|
WITH RECURSIVE date_series AS (
|
||||||
|
-- Generate weekly dates from cutoff_date to today
|
||||||
|
SELECT date(?) as ref_date
|
||||||
|
UNION ALL
|
||||||
|
SELECT date(ref_date, '+7 days')
|
||||||
|
FROM date_series
|
||||||
|
WHERE ref_date < date(?)
|
||||||
|
),
|
||||||
|
current_balances AS (
|
||||||
|
-- Get current balance for each account/type
|
||||||
|
SELECT account_id, type, amount, currency
|
||||||
|
FROM balances b1
|
||||||
|
WHERE b1.timestamp = (
|
||||||
|
SELECT MAX(b2.timestamp)
|
||||||
|
FROM balances b2
|
||||||
|
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
|
||||||
|
)
|
||||||
|
{account_filter}
|
||||||
|
AND b1.type = 'closingBooked' -- Focus on closingBooked for charts
|
||||||
|
),
|
||||||
|
historical_points AS (
|
||||||
|
-- Calculate balance at each weekly point by subtracting future transactions
|
||||||
|
SELECT
|
||||||
|
cb.account_id,
|
||||||
|
cb.type as balance_type,
|
||||||
|
cb.currency,
|
||||||
|
ds.ref_date,
|
||||||
|
cb.amount - COALESCE(
|
||||||
|
(SELECT SUM(t.transactionValue)
|
||||||
|
FROM transactions t
|
||||||
|
WHERE t.accountId = cb.account_id
|
||||||
|
AND date(t.transactionDate) > ds.ref_date), 0
|
||||||
|
) as balance_amount
|
||||||
|
FROM current_balances cb
|
||||||
|
CROSS JOIN date_series ds
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
account_id || '_' || balance_type || '_' || ref_date as id,
|
||||||
|
account_id,
|
||||||
|
balance_amount,
|
||||||
|
balance_type,
|
||||||
|
currency,
|
||||||
|
ref_date as reference_date
|
||||||
|
FROM historical_points
|
||||||
|
ORDER BY account_id, ref_date
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Build parameters and account filter
|
||||||
|
params = [cutoff_date, today_date]
|
||||||
|
if account_id:
|
||||||
|
account_filter = "AND b1.account_id = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
else:
|
||||||
|
account_filter = ""
|
||||||
|
|
||||||
|
# Format the query with conditional filter
|
||||||
|
formatted_query = query.format(account_filter=account_filter)
|
||||||
|
|
||||||
|
cursor.execute(formatted_query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def get_monthly_transaction_stats_from_db(
|
||||||
|
self,
|
||||||
|
account_id: Optional[str] = None,
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
date_to: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get monthly transaction statistics aggregated by the database"""
|
||||||
|
if not self.sqlite_enabled:
|
||||||
|
logger.warning("SQLite database disabled, cannot read monthly stats")
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
monthly_stats = self._get_monthly_transaction_stats(
|
||||||
|
account_id=account_id,
|
||||||
|
date_from=date_from,
|
||||||
|
date_to=date_to,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Retrieved {len(monthly_stats)} monthly stat points from database"
|
||||||
|
)
|
||||||
|
return monthly_stats
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get monthly transaction stats from database: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _get_monthly_transaction_stats(
|
||||||
|
self,
|
||||||
|
account_id: Optional[str] = None,
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
date_to: Optional[str] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Get monthly transaction statistics from SQLite database"""
|
||||||
|
db_path = path_manager.get_database_path()
|
||||||
|
if not db_path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# SQL query to aggregate transactions by month
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
strftime('%Y-%m', transactionDate) as month,
|
||||||
|
COALESCE(SUM(CASE WHEN transactionValue > 0 THEN transactionValue ELSE 0 END), 0) as income,
|
||||||
|
COALESCE(SUM(CASE WHEN transactionValue < 0 THEN ABS(transactionValue) ELSE 0 END), 0) as expenses,
|
||||||
|
COALESCE(SUM(transactionValue), 0) as net
|
||||||
|
FROM transactions
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if account_id:
|
||||||
|
query += " AND accountId = ?"
|
||||||
|
params.append(account_id)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
query += " AND transactionDate >= ?"
|
||||||
|
params.append(date_from)
|
||||||
|
|
||||||
|
if date_to:
|
||||||
|
query += " AND transactionDate <= ?"
|
||||||
|
params.append(date_to)
|
||||||
|
|
||||||
|
query += """
|
||||||
|
GROUP BY strftime('%Y-%m', transactionDate)
|
||||||
|
ORDER BY month ASC
|
||||||
|
"""
|
||||||
|
|
||||||
|
cursor.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# Convert to desired format with proper month display
|
||||||
|
monthly_stats = []
|
||||||
|
for row in rows:
|
||||||
|
# Convert YYYY-MM to display format like "Mar 2024"
|
||||||
|
year, month_num = row["month"].split("-")
|
||||||
|
month_date = datetime.strptime(f"{year}-{month_num}-01", "%Y-%m-%d")
|
||||||
|
display_month = month_date.strftime("%b %Y")
|
||||||
|
|
||||||
|
monthly_stats.append(
|
||||||
|
{
|
||||||
|
"month": display_month,
|
||||||
|
"income": round(row["income"], 2),
|
||||||
|
"expenses": round(row["expenses"], 2),
|
||||||
|
"net": round(row["net"], 2),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return monthly_stats
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.close()
|
||||||
|
raise e
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
import httpx
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import httpx
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.config import config
|
from leggen.utils.config import config
|
||||||
from leggen.utils.paths import path_manager
|
from leggen.utils.paths import path_manager
|
||||||
|
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from typing import List, Dict, Any
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.config import config
|
from leggen.utils.config import config
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
@@ -109,33 +109,78 @@ class NotificationService:
|
|||||||
"""Check if Telegram notifications are enabled"""
|
"""Check if Telegram notifications are enabled"""
|
||||||
telegram_config = self.notifications_config.get("telegram", {})
|
telegram_config = self.notifications_config.get("telegram", {})
|
||||||
return bool(
|
return bool(
|
||||||
telegram_config.get("api-key")
|
telegram_config.get("token")
|
||||||
and telegram_config.get("chat-id")
|
and telegram_config.get("chat_id")
|
||||||
and telegram_config.get("enabled", True)
|
and telegram_config.get("enabled", True)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _send_discord_notifications(
|
async def _send_discord_notifications(
|
||||||
self, transactions: List[Dict[str, Any]]
|
self, transactions: List[Dict[str, Any]]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send Discord notifications - placeholder implementation"""
|
"""Send Discord notifications for transactions"""
|
||||||
# Would import and use leggen.notifications.discord
|
try:
|
||||||
logger.info(f"Sending {len(transactions)} transaction notifications to Discord")
|
import click
|
||||||
|
|
||||||
|
from leggen.notifications.discord import send_transactions_message
|
||||||
|
|
||||||
|
# Create a mock context with the webhook
|
||||||
|
ctx = click.Context(click.Command("notifications"))
|
||||||
|
ctx.obj = {
|
||||||
|
"notifications": {
|
||||||
|
"discord": {
|
||||||
|
"webhook": self.notifications_config.get("discord", {}).get(
|
||||||
|
"webhook"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send transaction notifications using the actual implementation
|
||||||
|
send_transactions_message(ctx, transactions)
|
||||||
|
logger.info(
|
||||||
|
f"Sent {len(transactions)} transaction notifications to Discord"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send Discord transaction notifications: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def _send_telegram_notifications(
|
async def _send_telegram_notifications(
|
||||||
self, transactions: List[Dict[str, Any]]
|
self, transactions: List[Dict[str, Any]]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send Telegram notifications - placeholder implementation"""
|
"""Send Telegram notifications for transactions"""
|
||||||
# Would import and use leggen.notifications.telegram
|
try:
|
||||||
logger.info(
|
import click
|
||||||
f"Sending {len(transactions)} transaction notifications to Telegram"
|
|
||||||
)
|
from leggen.notifications.telegram import send_transaction_message
|
||||||
|
|
||||||
|
# Create a mock context with the telegram config
|
||||||
|
ctx = click.Context(click.Command("notifications"))
|
||||||
|
telegram_config = self.notifications_config.get("telegram", {})
|
||||||
|
ctx.obj = {
|
||||||
|
"notifications": {
|
||||||
|
"telegram": {
|
||||||
|
"api-key": telegram_config.get("token"),
|
||||||
|
"chat-id": telegram_config.get("chat_id"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send transaction notifications using the actual implementation
|
||||||
|
send_transaction_message(ctx, transactions)
|
||||||
|
logger.info(
|
||||||
|
f"Sent {len(transactions)} transaction notifications to Telegram"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send Telegram transaction notifications: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def _send_discord_test(self, message: str) -> None:
|
async def _send_discord_test(self, message: str) -> None:
|
||||||
"""Send Discord test notification"""
|
"""Send Discord test notification"""
|
||||||
try:
|
try:
|
||||||
from leggen.notifications.discord import send_expire_notification
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.notifications.discord import send_expire_notification
|
||||||
|
|
||||||
# Create a mock context with the webhook
|
# Create a mock context with the webhook
|
||||||
ctx = click.Context(click.Command("test"))
|
ctx = click.Context(click.Command("test"))
|
||||||
ctx.obj = {
|
ctx.obj = {
|
||||||
@@ -164,17 +209,18 @@ class NotificationService:
|
|||||||
async def _send_telegram_test(self, message: str) -> None:
|
async def _send_telegram_test(self, message: str) -> None:
|
||||||
"""Send Telegram test notification"""
|
"""Send Telegram test notification"""
|
||||||
try:
|
try:
|
||||||
from leggen.notifications.telegram import send_expire_notification
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from leggen.notifications.telegram import send_expire_notification
|
||||||
|
|
||||||
# Create a mock context with the telegram config
|
# Create a mock context with the telegram config
|
||||||
ctx = click.Context(click.Command("test"))
|
ctx = click.Context(click.Command("test"))
|
||||||
telegram_config = self.notifications_config.get("telegram", {})
|
telegram_config = self.notifications_config.get("telegram", {})
|
||||||
ctx.obj = {
|
ctx.obj = {
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"api-key": telegram_config.get("api-key"),
|
"api-key": telegram_config.get("token"),
|
||||||
"chat-id": telegram_config.get("chat-id"),
|
"chat-id": telegram_config.get("chat_id"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,8 +240,52 @@ class NotificationService:
|
|||||||
|
|
||||||
async def _send_discord_expiry(self, notification_data: Dict[str, Any]) -> None:
|
async def _send_discord_expiry(self, notification_data: Dict[str, Any]) -> None:
|
||||||
"""Send Discord expiry notification"""
|
"""Send Discord expiry notification"""
|
||||||
logger.info(f"Sending Discord expiry notification: {notification_data}")
|
try:
|
||||||
|
import click
|
||||||
|
|
||||||
|
from leggen.notifications.discord import send_expire_notification
|
||||||
|
|
||||||
|
# Create a mock context with the webhook
|
||||||
|
ctx = click.Context(click.Command("expiry"))
|
||||||
|
ctx.obj = {
|
||||||
|
"notifications": {
|
||||||
|
"discord": {
|
||||||
|
"webhook": self.notifications_config.get("discord", {}).get(
|
||||||
|
"webhook"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send expiry notification using the actual implementation
|
||||||
|
send_expire_notification(ctx, notification_data)
|
||||||
|
logger.info(f"Sent Discord expiry notification: {notification_data}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send Discord expiry notification: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def _send_telegram_expiry(self, notification_data: Dict[str, Any]) -> None:
|
async def _send_telegram_expiry(self, notification_data: Dict[str, Any]) -> None:
|
||||||
"""Send Telegram expiry notification"""
|
"""Send Telegram expiry notification"""
|
||||||
logger.info(f"Sending Telegram expiry notification: {notification_data}")
|
try:
|
||||||
|
import click
|
||||||
|
|
||||||
|
from leggen.notifications.telegram import send_expire_notification
|
||||||
|
|
||||||
|
# Create a mock context with the telegram config
|
||||||
|
ctx = click.Context(click.Command("expiry"))
|
||||||
|
telegram_config = self.notifications_config.get("telegram", {})
|
||||||
|
ctx.obj = {
|
||||||
|
"notifications": {
|
||||||
|
"telegram": {
|
||||||
|
"api-key": telegram_config.get("token"),
|
||||||
|
"chat-id": telegram_config.get("chat_id"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Send expiry notification using the actual implementation
|
||||||
|
send_expire_notification(ctx, notification_data)
|
||||||
|
logger.info(f"Sent Telegram expiry notification: {notification_data}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send Telegram expiry notification: {e}")
|
||||||
|
raise
|
||||||
@@ -3,10 +3,10 @@ from typing import List
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from leggend.api.models.sync import SyncResult, SyncStatus
|
from leggen.api.models.sync import SyncResult, SyncStatus
|
||||||
from leggend.services.gocardless_service import GoCardlessService
|
from leggen.services.database_service import DatabaseService
|
||||||
from leggend.services.database_service import DatabaseService
|
from leggen.services.gocardless_service import GoCardlessService
|
||||||
from leggend.services.notification_service import NotificationService
|
from leggen.services.notification_service import NotificationService
|
||||||
|
|
||||||
|
|
||||||
class SyncService:
|
class SyncService:
|
||||||
87
leggen/services/transaction_processor.py
Normal file
87
leggen/services/transaction_processor.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionProcessor:
|
||||||
|
"""Handles processing and transformation of raw transaction data"""
|
||||||
|
|
||||||
|
def process_transactions(
|
||||||
|
self,
|
||||||
|
account_id: str,
|
||||||
|
account_info: Dict[str, Any],
|
||||||
|
transaction_data: Dict[str, Any],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Process raw transaction data into standardized format"""
|
||||||
|
transactions = []
|
||||||
|
|
||||||
|
# Process booked transactions
|
||||||
|
for transaction in transaction_data.get("transactions", {}).get("booked", []):
|
||||||
|
processed = self._process_single_transaction(
|
||||||
|
account_id, account_info, transaction, "booked"
|
||||||
|
)
|
||||||
|
transactions.append(processed)
|
||||||
|
|
||||||
|
# Process pending transactions
|
||||||
|
for transaction in transaction_data.get("transactions", {}).get("pending", []):
|
||||||
|
processed = self._process_single_transaction(
|
||||||
|
account_id, account_info, transaction, "pending"
|
||||||
|
)
|
||||||
|
transactions.append(processed)
|
||||||
|
|
||||||
|
return transactions
|
||||||
|
|
||||||
|
def _process_single_transaction(
|
||||||
|
self,
|
||||||
|
account_id: str,
|
||||||
|
account_info: Dict[str, Any],
|
||||||
|
transaction: Dict[str, Any],
|
||||||
|
status: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Process a single transaction into standardized format"""
|
||||||
|
# Extract dates
|
||||||
|
booked_date = transaction.get("bookingDateTime") or transaction.get(
|
||||||
|
"bookingDate"
|
||||||
|
)
|
||||||
|
value_date = transaction.get("valueDateTime") or transaction.get("valueDate")
|
||||||
|
|
||||||
|
if booked_date and value_date:
|
||||||
|
min_date = min(
|
||||||
|
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
date_str = booked_date or value_date
|
||||||
|
if not date_str:
|
||||||
|
raise ValueError("No valid date found in transaction")
|
||||||
|
min_date = datetime.fromisoformat(date_str)
|
||||||
|
|
||||||
|
# Extract amount and currency
|
||||||
|
transaction_amount = transaction.get("transactionAmount", {})
|
||||||
|
amount = float(transaction_amount.get("amount", 0))
|
||||||
|
currency = transaction_amount.get("currency", "")
|
||||||
|
|
||||||
|
# Extract description
|
||||||
|
description = transaction.get(
|
||||||
|
"remittanceInformationUnstructured",
|
||||||
|
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract transaction IDs - transactionId is now primary, internalTransactionId is reference
|
||||||
|
transaction_id = transaction.get("transactionId")
|
||||||
|
internal_transaction_id = transaction.get("internalTransactionId")
|
||||||
|
|
||||||
|
if not transaction_id:
|
||||||
|
raise ValueError("Transaction missing required transactionId field")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"accountId": account_id,
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"internalTransactionId": internal_transaction_id,
|
||||||
|
"institutionId": account_info["institution_id"],
|
||||||
|
"iban": account_info.get("iban", "N/A"),
|
||||||
|
"transactionDate": min_date,
|
||||||
|
"description": description,
|
||||||
|
"transactionValue": amount,
|
||||||
|
"transactionCurrency": currency,
|
||||||
|
"transactionStatus": status,
|
||||||
|
"rawTransaction": transaction,
|
||||||
|
}
|
||||||
@@ -1,18 +1,189 @@
|
|||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tomllib
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import tomli_w
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from leggen.models.config import Config as ConfigModel
|
||||||
|
from leggen.utils.paths import path_manager
|
||||||
from leggen.utils.text import error
|
from leggen.utils.text import error
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
_instance = None
|
||||||
|
_config = None
|
||||||
|
_config_model = None
|
||||||
|
_config_path = None
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
if self._config is not None:
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
if config_path is None:
|
||||||
|
config_path = os.environ.get("LEGGEN_CONFIG_FILE")
|
||||||
|
if not config_path:
|
||||||
|
config_path = str(path_manager.get_config_file_path())
|
||||||
|
|
||||||
|
self._config_path = config_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, "rb") as f:
|
||||||
|
raw_config = tomllib.load(f)
|
||||||
|
logger.info(f"Configuration loaded from {config_path}")
|
||||||
|
|
||||||
|
# Validate configuration using Pydantic
|
||||||
|
try:
|
||||||
|
self._config_model = ConfigModel(**raw_config)
|
||||||
|
self._config = self._config_model.dict(by_alias=True, exclude_none=True)
|
||||||
|
logger.info("Configuration validation successful")
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.error(f"Configuration validation failed: {e}")
|
||||||
|
raise ValueError(f"Invalid configuration: {e}") from e
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"Configuration file not found: {config_path}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading configuration: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
def save_config(
|
||||||
|
self,
|
||||||
|
config_data: Optional[Dict[str, Any]] = None,
|
||||||
|
config_path: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Save configuration to TOML file"""
|
||||||
|
if config_data is None:
|
||||||
|
config_data = self._config
|
||||||
|
|
||||||
|
if config_path is None:
|
||||||
|
config_path = self._config_path or os.environ.get("LEGGEN_CONFIG_FILE")
|
||||||
|
if not config_path:
|
||||||
|
config_path = str(path_manager.get_config_file_path())
|
||||||
|
|
||||||
|
if config_path is None:
|
||||||
|
raise ValueError("No config path specified")
|
||||||
|
if config_data is None:
|
||||||
|
raise ValueError("No config data to save")
|
||||||
|
|
||||||
|
# Validate the configuration before saving
|
||||||
|
try:
|
||||||
|
validated_model = ConfigModel(**config_data)
|
||||||
|
validated_config = validated_model.dict(by_alias=True, exclude_none=True)
|
||||||
|
except ValidationError as e:
|
||||||
|
logger.error(f"Configuration validation failed before save: {e}")
|
||||||
|
raise ValueError(f"Invalid configuration: {e}") from e
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(config_path, "wb") as f:
|
||||||
|
tomli_w.dump(validated_config, f)
|
||||||
|
|
||||||
|
# Update in-memory config
|
||||||
|
self._config = validated_config
|
||||||
|
self._config_model = validated_model
|
||||||
|
self._config_path = config_path
|
||||||
|
logger.info(f"Configuration saved to {config_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving configuration: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def update_config(self, section: str, key: str, value: Any) -> None:
|
||||||
|
"""Update a specific configuration value"""
|
||||||
|
if self._config is None:
|
||||||
|
self.load_config()
|
||||||
|
|
||||||
|
if self._config is None:
|
||||||
|
raise RuntimeError("Failed to load config")
|
||||||
|
|
||||||
|
if section not in self._config:
|
||||||
|
self._config[section] = {}
|
||||||
|
|
||||||
|
self._config[section][key] = value
|
||||||
|
self.save_config()
|
||||||
|
|
||||||
|
def update_section(self, section: str, data: Dict[str, Any]) -> None:
|
||||||
|
"""Update an entire configuration section"""
|
||||||
|
if self._config is None:
|
||||||
|
self.load_config()
|
||||||
|
|
||||||
|
if self._config is None:
|
||||||
|
raise RuntimeError("Failed to load config")
|
||||||
|
|
||||||
|
self._config[section] = data
|
||||||
|
self.save_config()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> Dict[str, Any]:
|
||||||
|
if self._config is None:
|
||||||
|
self.load_config()
|
||||||
|
if self._config is None:
|
||||||
|
raise RuntimeError("Failed to load config")
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gocardless_config(self) -> Dict[str, str]:
|
||||||
|
return self.config.get("gocardless", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database_config(self) -> Dict[str, Any]:
|
||||||
|
return self.config.get("database", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def notifications_config(self) -> Dict[str, Any]:
|
||||||
|
return self.config.get("notifications", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filters_config(self) -> Dict[str, Any]:
|
||||||
|
return self.config.get("filters", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scheduler_config(self) -> Dict[str, Any]:
|
||||||
|
"""Get scheduler configuration with defaults"""
|
||||||
|
default_schedule = {
|
||||||
|
"sync": {
|
||||||
|
"enabled": True,
|
||||||
|
"hour": 3,
|
||||||
|
"minute": 0,
|
||||||
|
"cron": None, # Optional custom cron expression
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.config.get("scheduler", default_schedule)
|
||||||
|
|
||||||
|
|
||||||
def load_config(ctx: click.Context, _, filename):
|
def load_config(ctx: click.Context, _, filename):
|
||||||
try:
|
try:
|
||||||
with click.open_file(str(filename), "rb") as f:
|
with click.open_file(str(filename), "rb") as f:
|
||||||
# TODO: Implement configuration file validation (use pydantic?)
|
raw_config = tomllib.load(f)
|
||||||
ctx.obj = tomllib.load(f)
|
|
||||||
|
# Validate configuration using Pydantic
|
||||||
|
try:
|
||||||
|
validated_model = ConfigModel(**raw_config)
|
||||||
|
ctx.obj = validated_model.dict(by_alias=True, exclude_none=True)
|
||||||
|
except ValidationError as e:
|
||||||
|
error(f"Configuration validation failed: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
error(
|
error(
|
||||||
"Configuration file not found. Provide a valid configuration file path with leggen --config <path> or LEGGEN_CONFIG=<path> environment variable."
|
"Configuration file not found. Provide a valid configuration file path with leggen --config <path> or LEGGEN_CONFIG=<path> environment variable."
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# Global singleton instance
|
||||||
|
config = Config()
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
import leggen.database.sqlite as sqlite_engine
|
|
||||||
from leggen.utils.text import info, warning
|
|
||||||
|
|
||||||
|
|
||||||
def persist_balance(ctx: click.Context, account: str, balance: dict) -> None:
|
|
||||||
sqlite = ctx.obj.get("database", {}).get("sqlite", True)
|
|
||||||
|
|
||||||
if not sqlite:
|
|
||||||
warning("SQLite database is disabled, skipping balance saving")
|
|
||||||
return
|
|
||||||
|
|
||||||
info(f"[{account}] Fetched balances, saving to SQLite")
|
|
||||||
sqlite_engine.persist_balances(ctx, balance)
|
|
||||||
|
|
||||||
|
|
||||||
def persist_transactions(ctx: click.Context, account: str, transactions: list) -> list:
|
|
||||||
sqlite = ctx.obj.get("database", {}).get("sqlite", True)
|
|
||||||
|
|
||||||
if not sqlite:
|
|
||||||
warning("SQLite database is disabled, skipping transaction saving")
|
|
||||||
# WARNING: This will return the transactions list as is, without saving it to any database
|
|
||||||
# Possible duplicate notifications will be sent if the filters are enabled
|
|
||||||
return transactions
|
|
||||||
|
|
||||||
info(f"[{account}] Fetched {len(transactions)} transactions, saving to SQLite")
|
|
||||||
return sqlite_engine.persist_transactions(ctx, account, transactions)
|
|
||||||
|
|
||||||
|
|
||||||
def save_transactions(ctx: click.Context, account: str) -> list:
|
|
||||||
import requests
|
|
||||||
|
|
||||||
api_url = ctx.obj.get("api_url", "http://localhost:8000")
|
|
||||||
|
|
||||||
info(f"[{account}] Getting account details")
|
|
||||||
res = requests.get(f"{api_url}/accounts/{account}")
|
|
||||||
res.raise_for_status()
|
|
||||||
account_info = res.json()
|
|
||||||
|
|
||||||
info(f"[{account}] Getting transactions")
|
|
||||||
transactions = []
|
|
||||||
|
|
||||||
res = requests.get(f"{api_url}/accounts/{account}/transactions/")
|
|
||||||
res.raise_for_status()
|
|
||||||
account_transactions = res.json().get("transactions", [])
|
|
||||||
|
|
||||||
for transaction in account_transactions.get("booked", []):
|
|
||||||
booked_date = transaction.get("bookingDateTime") or transaction.get(
|
|
||||||
"bookingDate"
|
|
||||||
)
|
|
||||||
value_date = transaction.get("valueDateTime") or transaction.get("valueDate")
|
|
||||||
if booked_date and value_date:
|
|
||||||
min_date = min(
|
|
||||||
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
min_date = datetime.fromisoformat(booked_date or value_date)
|
|
||||||
|
|
||||||
transactionValue = float(
|
|
||||||
transaction.get("transactionAmount", {}).get("amount", 0)
|
|
||||||
)
|
|
||||||
currency = transaction.get("transactionAmount", {}).get("currency", "")
|
|
||||||
|
|
||||||
description = transaction.get(
|
|
||||||
"remittanceInformationUnstructured",
|
|
||||||
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract transaction ID, using transactionId as fallback when internalTransactionId is missing
|
|
||||||
transaction_id = transaction.get("internalTransactionId") or transaction.get(
|
|
||||||
"transactionId"
|
|
||||||
)
|
|
||||||
|
|
||||||
t = {
|
|
||||||
"internalTransactionId": transaction_id,
|
|
||||||
"institutionId": account_info["institution_id"],
|
|
||||||
"iban": account_info.get("iban", "N/A"),
|
|
||||||
"transactionDate": min_date,
|
|
||||||
"description": description,
|
|
||||||
"transactionValue": transactionValue,
|
|
||||||
"transactionCurrency": currency,
|
|
||||||
"transactionStatus": "booked",
|
|
||||||
"accountId": account,
|
|
||||||
"rawTransaction": transaction,
|
|
||||||
}
|
|
||||||
transactions.append(t)
|
|
||||||
|
|
||||||
for transaction in account_transactions.get("pending", []):
|
|
||||||
booked_date = transaction.get("bookingDateTime") or transaction.get(
|
|
||||||
"bookingDate"
|
|
||||||
)
|
|
||||||
value_date = transaction.get("valueDateTime") or transaction.get("valueDate")
|
|
||||||
if booked_date and value_date:
|
|
||||||
min_date = min(
|
|
||||||
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
min_date = datetime.fromisoformat(booked_date or value_date)
|
|
||||||
|
|
||||||
transactionValue = float(
|
|
||||||
transaction.get("transactionAmount", {}).get("amount", 0)
|
|
||||||
)
|
|
||||||
currency = transaction.get("transactionAmount", {}).get("currency", "")
|
|
||||||
|
|
||||||
description = transaction.get(
|
|
||||||
"remittanceInformationUnstructured",
|
|
||||||
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract transaction ID, using transactionId as fallback when internalTransactionId is missing
|
|
||||||
transaction_id = transaction.get("internalTransactionId") or transaction.get(
|
|
||||||
"transactionId"
|
|
||||||
)
|
|
||||||
|
|
||||||
t = {
|
|
||||||
"internalTransactionId": transaction_id,
|
|
||||||
"institutionId": account_info["institution_id"],
|
|
||||||
"iban": account_info.get("iban", "N/A"),
|
|
||||||
"transactionDate": min_date,
|
|
||||||
"description": description,
|
|
||||||
"transactionValue": transactionValue,
|
|
||||||
"transactionCurrency": currency,
|
|
||||||
"transactionStatus": "pending",
|
|
||||||
"accountId": account,
|
|
||||||
"rawTransaction": transaction,
|
|
||||||
}
|
|
||||||
transactions.append(t)
|
|
||||||
|
|
||||||
return persist_transactions(ctx, account, transactions)
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Centralized path management for Leggen."""
|
"""Centralized path management for Leggen."""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -7,32 +8,32 @@ from typing import Optional
|
|||||||
|
|
||||||
class PathManager:
|
class PathManager:
|
||||||
"""Manages configurable paths for config and database files."""
|
"""Manages configurable paths for config and database files."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._config_dir: Optional[Path] = None
|
self._config_dir: Optional[Path] = None
|
||||||
self._database_path: Optional[Path] = None
|
self._database_path: Optional[Path] = None
|
||||||
|
|
||||||
def get_config_dir(self) -> Path:
|
def get_config_dir(self) -> Path:
|
||||||
"""Get the configuration directory."""
|
"""Get the configuration directory."""
|
||||||
if self._config_dir is not None:
|
if self._config_dir is not None:
|
||||||
return self._config_dir
|
return self._config_dir
|
||||||
|
|
||||||
# Check environment variable first
|
# Check environment variable first
|
||||||
config_dir = os.environ.get("LEGGEN_CONFIG_DIR")
|
config_dir = os.environ.get("LEGGEN_CONFIG_DIR")
|
||||||
if config_dir:
|
if config_dir:
|
||||||
return Path(config_dir)
|
return Path(config_dir)
|
||||||
|
|
||||||
# Default to ~/.config/leggen
|
# Default to ~/.config/leggen
|
||||||
return Path.home() / ".config" / "leggen"
|
return Path.home() / ".config" / "leggen"
|
||||||
|
|
||||||
def set_config_dir(self, path: Path) -> None:
|
def set_config_dir(self, path: Path) -> None:
|
||||||
"""Set the configuration directory."""
|
"""Set the configuration directory."""
|
||||||
self._config_dir = Path(path)
|
self._config_dir = Path(path)
|
||||||
|
|
||||||
def get_config_file_path(self) -> Path:
|
def get_config_file_path(self) -> Path:
|
||||||
"""Get the configuration file path."""
|
"""Get the configuration file path."""
|
||||||
return self.get_config_dir() / "config.toml"
|
return self.get_config_dir() / "config.toml"
|
||||||
|
|
||||||
def get_database_path(self) -> Path:
|
def get_database_path(self) -> Path:
|
||||||
"""Get the database file path and ensure the directory exists."""
|
"""Get the database file path and ensure the directory exists."""
|
||||||
if self._database_path is not None:
|
if self._database_path is not None:
|
||||||
@@ -45,32 +46,28 @@ class PathManager:
|
|||||||
else:
|
else:
|
||||||
# Default to config_dir/leggen.db
|
# Default to config_dir/leggen.db
|
||||||
db_path = self.get_config_dir() / "leggen.db"
|
db_path = self.get_config_dir() / "leggen.db"
|
||||||
|
|
||||||
# Try to ensure the directory exists, but handle permission errors gracefully
|
# Try to ensure the directory exists, but handle permission errors gracefully
|
||||||
try:
|
with contextlib.suppress(PermissionError, OSError):
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
except (PermissionError, OSError):
|
|
||||||
# If we can't create the directory, continue anyway
|
|
||||||
# This allows tests and error cases to work as expected
|
|
||||||
pass
|
|
||||||
|
|
||||||
return db_path
|
return db_path
|
||||||
|
|
||||||
def set_database_path(self, path: Path) -> None:
|
def set_database_path(self, path: Path) -> None:
|
||||||
"""Set the database file path."""
|
"""Set the database file path."""
|
||||||
self._database_path = Path(path)
|
self._database_path = Path(path)
|
||||||
|
|
||||||
def get_auth_file_path(self) -> Path:
|
def get_auth_file_path(self) -> Path:
|
||||||
"""Get the authentication file path."""
|
"""Get the authentication file path."""
|
||||||
return self.get_config_dir() / "auth.json"
|
return self.get_config_dir() / "auth.json"
|
||||||
|
|
||||||
def ensure_config_dir_exists(self) -> None:
|
def ensure_config_dir_exists(self) -> None:
|
||||||
"""Ensure the configuration directory exists."""
|
"""Ensure the configuration directory exists."""
|
||||||
self.get_config_dir().mkdir(parents=True, exist_ok=True)
|
self.get_config_dir().mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
def ensure_database_dir_exists(self) -> None:
|
def ensure_database_dir_exists(self) -> None:
|
||||||
"""Ensure the database directory exists.
|
"""Ensure the database directory exists.
|
||||||
|
|
||||||
Note: get_database_path() now automatically ensures the directory exists,
|
Note: get_database_path() now automatically ensures the directory exists,
|
||||||
so this method is mainly for explicit directory creation in tests.
|
so this method is mainly for explicit directory creation in tests.
|
||||||
"""
|
"""
|
||||||
@@ -78,4 +75,4 @@ class PathManager:
|
|||||||
|
|
||||||
|
|
||||||
# Global instance for the application
|
# Global instance for the application
|
||||||
path_manager = PathManager()
|
path_manager = PathManager()
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
import os
|
|
||||||
import tomllib
|
|
||||||
import tomli_w
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Any, Optional
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
from leggen.utils.paths import path_manager
|
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
_instance = None
|
|
||||||
_config = None
|
|
||||||
_config_path = None
|
|
||||||
|
|
||||||
def __new__(cls):
|
|
||||||
if cls._instance is None:
|
|
||||||
cls._instance = super().__new__(cls)
|
|
||||||
return cls._instance
|
|
||||||
|
|
||||||
def load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
if self._config is not None:
|
|
||||||
return self._config
|
|
||||||
|
|
||||||
if config_path is None:
|
|
||||||
config_path = os.environ.get(
|
|
||||||
"LEGGEN_CONFIG_FILE"
|
|
||||||
)
|
|
||||||
if not config_path:
|
|
||||||
config_path = str(path_manager.get_config_file_path())
|
|
||||||
|
|
||||||
self._config_path = config_path
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(config_path, "rb") as f:
|
|
||||||
self._config = tomllib.load(f)
|
|
||||||
logger.info(f"Configuration loaded from {config_path}")
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.error(f"Configuration file not found: {config_path}")
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error loading configuration: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
return self._config
|
|
||||||
|
|
||||||
def save_config(
|
|
||||||
self,
|
|
||||||
config_data: Optional[Dict[str, Any]] = None,
|
|
||||||
config_path: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
"""Save configuration to TOML file"""
|
|
||||||
if config_data is None:
|
|
||||||
config_data = self._config
|
|
||||||
|
|
||||||
if config_path is None:
|
|
||||||
config_path = self._config_path or os.environ.get(
|
|
||||||
"LEGGEN_CONFIG_FILE"
|
|
||||||
)
|
|
||||||
if not config_path:
|
|
||||||
config_path = str(path_manager.get_config_file_path())
|
|
||||||
|
|
||||||
if config_path is None:
|
|
||||||
raise ValueError("No config path specified")
|
|
||||||
if config_data is None:
|
|
||||||
raise ValueError("No config data to save")
|
|
||||||
|
|
||||||
# Ensure directory exists
|
|
||||||
Path(config_path).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(config_path, "wb") as f:
|
|
||||||
tomli_w.dump(config_data, f)
|
|
||||||
|
|
||||||
# Update in-memory config
|
|
||||||
self._config = config_data
|
|
||||||
self._config_path = config_path
|
|
||||||
logger.info(f"Configuration saved to {config_path}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error saving configuration: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def update_config(self, section: str, key: str, value: Any) -> None:
|
|
||||||
"""Update a specific configuration value"""
|
|
||||||
if self._config is None:
|
|
||||||
self.load_config()
|
|
||||||
|
|
||||||
if self._config is None:
|
|
||||||
raise RuntimeError("Failed to load config")
|
|
||||||
|
|
||||||
if section not in self._config:
|
|
||||||
self._config[section] = {}
|
|
||||||
|
|
||||||
self._config[section][key] = value
|
|
||||||
self.save_config()
|
|
||||||
|
|
||||||
def update_section(self, section: str, data: Dict[str, Any]) -> None:
|
|
||||||
"""Update an entire configuration section"""
|
|
||||||
if self._config is None:
|
|
||||||
self.load_config()
|
|
||||||
|
|
||||||
if self._config is None:
|
|
||||||
raise RuntimeError("Failed to load config")
|
|
||||||
|
|
||||||
self._config[section] = data
|
|
||||||
self.save_config()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def config(self) -> Dict[str, Any]:
|
|
||||||
if self._config is None:
|
|
||||||
self.load_config()
|
|
||||||
if self._config is None:
|
|
||||||
raise RuntimeError("Failed to load config")
|
|
||||||
return self._config
|
|
||||||
|
|
||||||
@property
|
|
||||||
def gocardless_config(self) -> Dict[str, str]:
|
|
||||||
return self.config.get("gocardless", {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def database_config(self) -> Dict[str, Any]:
|
|
||||||
return self.config.get("database", {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def notifications_config(self) -> Dict[str, Any]:
|
|
||||||
return self.config.get("notifications", {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def filters_config(self) -> Dict[str, Any]:
|
|
||||||
return self.config.get("filters", {})
|
|
||||||
|
|
||||||
@property
|
|
||||||
def scheduler_config(self) -> Dict[str, Any]:
|
|
||||||
"""Get scheduler configuration with defaults"""
|
|
||||||
default_schedule = {
|
|
||||||
"sync": {
|
|
||||||
"enabled": True,
|
|
||||||
"hour": 3,
|
|
||||||
"minute": 0,
|
|
||||||
"cron": None, # Optional custom cron expression
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return self.config.get("scheduler", default_schedule)
|
|
||||||
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
REQUISITION_STATUS = {
|
|
||||||
"CR": "CREATED",
|
|
||||||
"GC": "GIVING_CONSENT",
|
|
||||||
"UA": "UNDERGOING_AUTHENTICATION",
|
|
||||||
"RJ": "REJECTED",
|
|
||||||
"SA": "SELECTING_ACCOUNTS",
|
|
||||||
"GA": "GRANTING_ACCESS",
|
|
||||||
"LN": "LINKED",
|
|
||||||
"EX": "EXPIRED",
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user